@hraness/ghostget 0.17.2
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.
- package/CHANGELOG.md +458 -0
- package/LICENSE +21 -0
- package/README.md +1359 -0
- package/bunfig.toml +2 -0
- package/dist/apple-photos-client.js +704 -0
- package/dist/beeper-client.js +4602 -0
- package/dist/client.js +1605 -0
- package/dist/index-26yq8q16.js +31 -0
- package/dist/index-9t13b4k7.js +508 -0
- package/dist/index-axwm9b3v.js +5 -0
- package/dist/index-gwk7rbyj.js +30 -0
- package/dist/index.js +701 -0
- package/dist/messaging.js +1130 -0
- package/dist/omni-client.js +1406 -0
- package/dist/whatsapp-client.js +355 -0
- package/docs/imessage-direct-provider.md +106 -0
- package/docs/rental-listings.md +94 -0
- package/package.json +600 -0
- package/skills/ghostget/SKILL.md +251 -0
- package/skills/ghostget/agents/openai.yaml +4 -0
- package/skills/ghostget/references/adapter-contract.md +181 -0
- package/skills/ghostget/references/apple-photos.md +115 -0
- package/skills/ghostget/references/article-drafts.md +235 -0
- package/skills/ghostget/references/code-owned-provider-scaffold.md +134 -0
- package/skills/ghostget/references/cross-posting-video.md +139 -0
- package/skills/ghostget/references/cross-posting.md +82 -0
- package/skills/ghostget/references/derivation.md +390 -0
- package/skills/ghostget/references/hraness-social-profile-stats.json +269 -0
- package/skills/ghostget/references/install.md +66 -0
- package/skills/ghostget/references/linkedin-adapter.md +442 -0
- package/skills/ghostget/references/messaging.md +168 -0
- package/skills/ghostget/references/meta-comet-contract.md +153 -0
- package/skills/ghostget/references/platform-patterns.md +122 -0
- package/skills/ghostget/references/provider-plugins.md +496 -0
- package/skills/ghostget/references/reddit-flair.md +78 -0
- package/skills/ghostget/references/safety-and-state.md +214 -0
- package/skills/ghostget/references/settlement-and-duplicate-cleanup.md +79 -0
- package/skills/ghostget/references/social-platform-routing.md +66 -0
- package/skills/ghostget/references/social-profile-stats.md +195 -0
- package/skills/ghostget/references/social-video-platform-routing.md +89 -0
- package/skills/ghostget/references/whatsapp-adapter.md +101 -0
- package/skills/ghostget/references/x-adapter.md +332 -0
- package/skills/ghostget/references/x-ai-disclosure.md +71 -0
- package/src/apple-photos-cli.ts +639 -0
- package/src/apple-photos-client-types.ts +168 -0
- package/src/apple-photos-client.ts +201 -0
- package/src/apple-photos-contact-evidence.ts +838 -0
- package/src/apple-photos-local-source.ts +1131 -0
- package/src/args.ts +2053 -0
- package/src/article-draft-document.ts +417 -0
- package/src/article-draft-embeds.ts +123 -0
- package/src/article-draft-images.ts +214 -0
- package/src/assets/adapter-template/wrench-adapter.json +40 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.json +1500 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v1.0.0.json +129 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v1.1.0.json +207 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.0.0.json +1463 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.1.0.json +1463 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.2.0.json +1475 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.3.0.json +1488 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.2.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.3.0.json +615 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.4.0.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.5.0.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.6.0.json +684 -0
- package/src/assets/adapters/clasificados/wrench-web-adapter.json +54 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.json +750 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.0.0.json +706 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.1.0.json +703 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.2.0.json +750 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.json +682 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.0.0.json +583 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.1.0.json +682 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.json +437 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.0.0.json +383 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.0.json +437 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.1.json +437 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.json +880 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.0.0.json +740 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.1.0.json +880 -0
- package/src/assets/adapters/github/wrench-web-adapter.json +69 -0
- package/src/assets/adapters/github/wrench-web-adapter.v1.0.0.json +42 -0
- package/src/assets/adapters/github/wrench-web-adapter.v1.1.0.json +69 -0
- package/src/assets/adapters/gmail/wrench-adapter.json +161 -0
- package/src/assets/adapters/gmail/wrench-adapter.v1.2.0.json +157 -0
- package/src/assets/adapters/hacker-news/wrench-web-adapter.json +315 -0
- package/src/assets/adapters/hacker-news/wrench-web-adapter.v1.0.0.json +315 -0
- package/src/assets/adapters/imessage/wrench-web-adapter.json +236 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.json +719 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.0.0.json +627 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.1.0.json +609 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.2.0.json +640 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.3.0.json +671 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.4.0.json +710 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.5.0.json +710 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.6.0.json +719 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.7.0.json +719 -0
- package/src/assets/adapters/linkedin/wrench-adapter.json +442 -0
- package/src/assets/adapters/linkedin/wrench-adapter.v0.4.0.json +124 -0
- package/src/assets/adapters/linkedin/wrench-adapter.v1.0.0.json +411 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.json +914 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.0.0.json +604 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.1.0.json +604 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.10.0.json +809 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.11.0.json +809 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.12.0.json +820 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.13.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.18.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.19.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.2.0.json +731 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.3.0.json +768 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.4.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.5.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.6.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.7.0.json +825 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.8.0.json +792 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.9.0.json +809 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.json +855 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json +577 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json +604 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.10.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.11.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.12.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.2.0.json +653 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.3.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.4.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.5.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.6.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.7.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.8.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.9.0.json +713 -0
- package/src/assets/adapters/substack/wrench-web-adapter.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.0.0.json +817 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.1.0.json +812 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.2.0.json +812 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.3.0.json +902 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.4.0.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.5.0.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.6.0.json +936 -0
- package/src/assets/adapters/threads/wrench-web-adapter.json +692 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.0.0.json +629 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json +623 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json +699 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json +693 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.7.0.json +692 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.json +762 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json +681 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.1.0.json +708 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.2.0.json +762 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.3.0.json +762 -0
- package/src/assets/adapters/twitch/wrench-web-adapter.json +42 -0
- package/src/assets/adapters/twitch/wrench-web-adapter.v1.0.0.json +42 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.json +400 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json +355 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.1.0.json +386 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.2.0.json +386 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.3.0.json +400 -0
- package/src/assets/adapters/x/wrench-adapter.json +749 -0
- package/src/assets/adapters/x/wrench-adapter.v1.0.0.json +700 -0
- package/src/assets/adapters/x/wrench-adapter.v1.1.0.json +707 -0
- package/src/assets/adapters/x/wrench-web-adapter.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.1.0.json +579 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +685 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.11.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.12.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.13.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.2.0.json +578 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.3.0.json +610 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.4.0.json +617 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.5.0.json +634 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.6.0.json +632 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.7.0.json +649 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.8.0.json +649 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.9.0.json +683 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.json +561 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json +465 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json +519 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.2.0.json +561 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.3.0.json +561 -0
- package/src/assets/code-owned-provider-template/plugin.test.ts.template +54 -0
- package/src/assets/code-owned-provider-template/plugin.ts.template +102 -0
- package/src/assets/code-owned-provider-template/runtime.internal.test.ts.template +44 -0
- package/src/assets/code-owned-provider-template/runtime.ts.template +59 -0
- package/src/assets/code-owned-provider-template/wrench-adapter.json.template +33 -0
- package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
- package/src/assets/fonts/nebula-sans/PROVENANCE.md +14 -0
- package/src/assets.d.ts +4 -0
- package/src/auth.ts +1157 -0
- package/src/beeper-client-types.ts +169 -0
- package/src/beeper-client.ts +250 -0
- package/src/beeper-contact-interactions-cli.ts +180 -0
- package/src/beeper-contact-interactions.ts +1612 -0
- package/src/beeper-message-bundle-v1.ts +257 -0
- package/src/beeper-message-like-me-cli.ts +84 -0
- package/src/beeper-message-like-me-export.ts +2274 -0
- package/src/beeper-message-like-me-recovery.ts +931 -0
- package/src/beeper-message-like-me-source.ts +4034 -0
- package/src/browser-admission.ts +928 -0
- package/src/browser-snapshots.ts +260 -0
- package/src/browser.ts +3873 -0
- package/src/canonical-json.ts +60 -0
- package/src/catalog-cli.ts +638 -0
- package/src/cli.ts +208 -0
- package/src/client-types.ts +301 -0
- package/src/client.ts +2726 -0
- package/src/confirmed-write-failure.ts +44 -0
- package/src/confirmed-write-model.ts +72 -0
- package/src/confirmed-write-platform.ts +1055 -0
- package/src/confirmed-write-program.ts +224 -0
- package/src/confirmed-write-runtime.ts +16 -0
- package/src/cursor-token.ts +228 -0
- package/src/derivation-dnr-readiness.ts +367 -0
- package/src/derivation-file-chooser.ts +384 -0
- package/src/derivation-network-boundary.ts +835 -0
- package/src/derivation-network-guard.ts +782 -0
- package/src/derivation-network-proxy-helper.ts +440 -0
- package/src/derivation-network-proxy.ts +385 -0
- package/src/derive-command-helper.ts +693 -0
- package/src/derive-fixtures.ts +343 -0
- package/src/derive-review.ts +980 -0
- package/src/derive.ts +4754 -0
- package/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +2 -0
- package/src/fixtures/beeper-message-like-me-v1/conversations.ndjson +1 -0
- package/src/fixtures/beeper-message-like-me-v1/manifest.json +1 -0
- package/src/fixtures/beeper-message-like-me-v1/messages.ndjson +2 -0
- package/src/fixtures/beeper-message-like-me-v1/participants.ndjson +3 -0
- package/src/fixtures/beeper-message-like-me-v1/reactions.ndjson +1 -0
- package/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson +1 -0
- package/src/ghostget.ts +3672 -0
- package/src/gmail-capture.ts +1244 -0
- package/src/har-internal.ts +1242 -0
- package/src/har.ts +743 -0
- package/src/index.ts +218 -0
- package/src/invocation-read-platform.ts +55 -0
- package/src/invocation-read-program.ts +93 -0
- package/src/invocation-read-runtime.ts +11 -0
- package/src/linked-device-lifecycle-admission.ts +450 -0
- package/src/linked-device-lifecycle-journal.ts +1560 -0
- package/src/linked-device-lifecycle-runtime.ts +1602 -0
- package/src/local-cli-admission.ts +135 -0
- package/src/local-cli-contracts.ts +271 -0
- package/src/local-cli-execution.ts +98 -0
- package/src/local-cli-surface-contract.ts +1510 -0
- package/src/local-cli-tool-identity.ts +336 -0
- package/src/media/archive.ts +2325 -0
- package/src/media/args.ts +303 -0
- package/src/media/cli.ts +287 -0
- package/src/media/doctor.ts +475 -0
- package/src/media/ffmpeg.ts +1330 -0
- package/src/media/http-capture.ts +711 -0
- package/src/media/http-probe.ts +593 -0
- package/src/media/http.ts +387 -0
- package/src/media/index.ts +10 -0
- package/src/media/local-transcription.ts +613 -0
- package/src/media/lock.ts +321 -0
- package/src/media/manifest.ts +1712 -0
- package/src/media/metadata.ts +906 -0
- package/src/media/process.ts +723 -0
- package/src/media/revision.ts +166 -0
- package/src/media/runtime-closure.ts +1063 -0
- package/src/media/source-router.ts +187 -0
- package/src/media/transcriber-config.ts +1122 -0
- package/src/media/transcript-persistence-model.ts +15 -0
- package/src/media/transcript-persistence-platform.ts +89 -0
- package/src/media/transcript-persistence-program.ts +39 -0
- package/src/media/transcript-persistence-runtime.ts +24 -0
- package/src/media/transcript.ts +616 -0
- package/src/media/utf8-order.ts +30 -0
- package/src/media/whisper-cpp.ts +464 -0
- package/src/media/whisper-language.ts +58 -0
- package/src/media/yt-dlp.ts +439 -0
- package/src/message-like-me-agentic-messaging.ts +882 -0
- package/src/messaging-action-store.ts +793 -0
- package/src/messaging-runtime.ts +2136 -0
- package/src/messaging-store.ts +539 -0
- package/src/messaging-types.ts +1663 -0
- package/src/messaging.ts +334 -0
- package/src/model.ts +2671 -0
- package/src/oauth-google.ts +888 -0
- package/src/omni-client-types.ts +306 -0
- package/src/omni-client.ts +2225 -0
- package/src/omni-limits.ts +34 -0
- package/src/omni-model.ts +1614 -0
- package/src/omni-request.ts +379 -0
- package/src/omni-runtime.ts +1632 -0
- package/src/operation-deadline.ts +189 -0
- package/src/path-helper.ts +1656 -0
- package/src/pinned-https.ts +194 -0
- package/src/plan-assets.ts +832 -0
- package/src/platform-catalog.ts +1832 -0
- package/src/plugins/beeper-linked-device/plugin.ts +310 -0
- package/src/plugins/bluesky-web/plugin.ts +178 -0
- package/src/plugins/clasificados-web/plugin.ts +66 -0
- package/src/plugins/github-web/plugin.ts +80 -0
- package/src/plugins/gmail-official/plugin.ts +110 -0
- package/src/plugins/hacker-news-web/plugin.ts +52 -0
- package/src/plugins/imessage-direct/plugin.ts +195 -0
- package/src/plugins/imessage-direct/vendor/0001-fix-keep-AppleScript-send-payloads-out-of-child-argv.patch +1430 -0
- package/src/plugins/imessage-direct/vendor/0002-feat-rpc-add-exact-chat-lookup.patch +225 -0
- package/src/plugins/imessage-direct/vendor/provenance.json +80 -0
- package/src/plugins/linkedin-official/plugin.ts +84 -0
- package/src/plugins/linkedin-web/plugin.ts +412 -0
- package/src/plugins/meta-web/plugin.ts +317 -0
- package/src/plugins/reddit-web/flair.ts +492 -0
- package/src/plugins/reddit-web/plugin.ts +477 -0
- package/src/plugins/substack-web/plugin.ts +124 -0
- package/src/plugins/tiktok-web/plugin.ts +67 -0
- package/src/plugins/twitch-web/plugin.ts +54 -0
- package/src/plugins/whatsapp-linked-device/plugin.ts +105 -0
- package/src/plugins/x-official/plugin.ts +64 -0
- package/src/plugins/x-web/plugin.ts +488 -0
- package/src/plugins/youtube-web/plugin.ts +101 -0
- package/src/portable-run-recovery.ts +466 -0
- package/src/process-identity.ts +501 -0
- package/src/profile-clone-helper.ts +104 -0
- package/src/provider-catalog-views.ts +51 -0
- package/src/provider-context.ts +69 -0
- package/src/provider-contract-definitions-x.ts +45 -0
- package/src/provider-contract-definitions.ts +948 -0
- package/src/provider-contract-input-gmail.ts +176 -0
- package/src/provider-contract-input-linkedin.ts +77 -0
- package/src/provider-contract-input-x.ts +304 -0
- package/src/provider-contract-planning.ts +23 -0
- package/src/provider-contract-semantic-identity.ts +54 -0
- package/src/provider-contracts.ts +251 -0
- package/src/provider-http.ts +723 -0
- package/src/provider-plugin-auth.ts +36 -0
- package/src/provider-plugin-builtins.ts +229 -0
- package/src/provider-plugin-cleanup-barrier.ts +229 -0
- package/src/provider-plugin-cleanup-execution.ts +79 -0
- package/src/provider-plugin-cleanup-resource.ts +470 -0
- package/src/provider-plugin-contract-identity.ts +707 -0
- package/src/provider-plugin-host.ts +1972 -0
- package/src/provider-plugin-identifiers.ts +60 -0
- package/src/provider-plugin-invocation-lease.ts +889 -0
- package/src/provider-plugin-lifecycle-kernel.ts +566 -0
- package/src/provider-plugin-lifecycle.ts +1455 -0
- package/src/provider-plugin-module-analysis.ts +336 -0
- package/src/provider-plugin-package.ts +2530 -0
- package/src/provider-plugin-portable-authority.ts +106 -0
- package/src/provider-plugin-portable-catalog.ts +94 -0
- package/src/provider-plugin-portable-identity.ts +315 -0
- package/src/provider-plugin-portable-runtime.ts +2057 -0
- package/src/provider-plugin-protocol.ts +1625 -0
- package/src/provider-plugin-registry.ts +3342 -0
- package/src/provider-plugin-store.ts +2155 -0
- package/src/provider-plugin-test-fixture.ts +5 -0
- package/src/provider-plugin.ts +4907 -0
- package/src/provider-plugins.generated.ts +43 -0
- package/src/provider-plugins.ts +8 -0
- package/src/provider-subject.ts +41 -0
- package/src/provider.ts +315 -0
- package/src/providers/beeper-local-runtime.ts +5426 -0
- package/src/providers/beeper-local.ts +2739 -0
- package/src/providers/beeper-messaging.ts +462 -0
- package/src/providers/beeper-omni.ts +1159 -0
- package/src/providers/bluesky-web-runtime.ts +2895 -0
- package/src/providers/bluesky-web.ts +1614 -0
- package/src/providers/clasificados-web-runtime.ts +255 -0
- package/src/providers/clasificados-web.ts +503 -0
- package/src/providers/contact-projection.ts +301 -0
- package/src/providers/github-read-model.ts +94 -0
- package/src/providers/github-read-platform.ts +165 -0
- package/src/providers/github-read-program.ts +97 -0
- package/src/providers/github-web-runtime.ts +320 -0
- package/src/providers/github-web.ts +403 -0
- package/src/providers/gmail-api.ts +2834 -0
- package/src/providers/gmail-omni.ts +830 -0
- package/src/providers/gmail.ts +1130 -0
- package/src/providers/hacker-news-web-runtime.ts +220 -0
- package/src/providers/hacker-news-web.ts +809 -0
- package/src/providers/imessage-direct-install.ts +354 -0
- package/src/providers/imessage-direct-messaging.ts +532 -0
- package/src/providers/imessage-direct-omni.ts +400 -0
- package/src/providers/imessage-direct-runtime.ts +1498 -0
- package/src/providers/imessage-direct.ts +351 -0
- package/src/providers/instagram-video-foundations.ts +913 -0
- package/src/providers/instagram-web-profile-browser.ts +600 -0
- package/src/providers/iso-bmff.ts +338 -0
- package/src/providers/linkedin-company-platform.ts +42 -0
- package/src/providers/linkedin-company-program.ts +88 -0
- package/src/providers/linkedin-contact-failure.ts +84 -0
- package/src/providers/linkedin-contact-platform.ts +45 -0
- package/src/providers/linkedin-contact-program.ts +91 -0
- package/src/providers/linkedin-profile-activity-failure.ts +76 -0
- package/src/providers/linkedin-profile-activity-platform.ts +41 -0
- package/src/providers/linkedin-profile-activity-program.ts +67 -0
- package/src/providers/linkedin-read-failure.ts +52 -0
- package/src/providers/linkedin-self-platform.ts +53 -0
- package/src/providers/linkedin-self-program.ts +122 -0
- package/src/providers/linkedin-web-article-browser.ts +794 -0
- package/src/providers/linkedin-web-bootstrap.ts +146 -0
- package/src/providers/linkedin-web-contact.ts +830 -0
- package/src/providers/linkedin-web-feed-browser.ts +832 -0
- package/src/providers/linkedin-web-feed.ts +864 -0
- package/src/providers/linkedin-web-post-browser.ts +653 -0
- package/src/providers/linkedin-web-profile-browser.ts +683 -0
- package/src/providers/linkedin-web-runtime.ts +2388 -0
- package/src/providers/linkedin-web.ts +3305 -0
- package/src/providers/linkedin.ts +1885 -0
- package/src/providers/meta-bootstrap.ts +1043 -0
- package/src/providers/meta-facebook-group.ts +690 -0
- package/src/providers/meta-marketplace-relay.ts +747 -0
- package/src/providers/meta-omni.ts +373 -0
- package/src/providers/meta-relay-bundle.ts +1049 -0
- package/src/providers/meta-web-descriptors.ts +1740 -0
- package/src/providers/meta-web-runtime.ts +3388 -0
- package/src/providers/meta-web.ts +2931 -0
- package/src/providers/read-failure.ts +139 -0
- package/src/providers/reddit-omni.ts +462 -0
- package/src/providers/reddit-web-runtime.ts +1929 -0
- package/src/providers/reddit-web.ts +1611 -0
- package/src/providers/rental-listings.ts +460 -0
- package/src/providers/substack-omni.ts +406 -0
- package/src/providers/substack-video-mp4.ts +33 -0
- package/src/providers/substack-web-runtime.ts +2812 -0
- package/src/providers/substack-web.ts +1502 -0
- package/src/providers/tiktok-video-mp4.ts +33 -0
- package/src/providers/tiktok-web-runtime.ts +770 -0
- package/src/providers/tiktok-web.ts +2786 -0
- package/src/providers/twitch-web-runtime.ts +226 -0
- package/src/providers/twitch-web.ts +274 -0
- package/src/providers/whatsapp-account-identity.ts +55 -0
- package/src/providers/whatsapp-contact-projection-helper.ts +874 -0
- package/src/providers/whatsapp-contact-projection-protocol.ts +403 -0
- package/src/providers/whatsapp-interaction-projection-helper.ts +1452 -0
- package/src/providers/whatsapp-interaction-projection-protocol.ts +462 -0
- package/src/providers/whatsapp-message-export-projection-protocol.ts +532 -0
- package/src/providers/whatsapp-messaging.ts +132 -0
- package/src/providers/whatsapp-omni.ts +586 -0
- package/src/providers/whatsapp-web-runtime.ts +3108 -0
- package/src/providers/whatsapp-web.ts +1037 -0
- package/src/providers/x-image-provenance.ts +523 -0
- package/src/providers/x-made-with-ai.ts +106 -0
- package/src/providers/x-transaction-id.ts +413 -0
- package/src/providers/x-web-runtime.ts +3485 -0
- package/src/providers/x-web.ts +2332 -0
- package/src/providers/x.ts +1889 -0
- package/src/providers/youtube-web-runtime.ts +1400 -0
- package/src/providers/youtube-web.ts +1221 -0
- package/src/read-admission-runtime.ts +91 -0
- package/src/read-client.ts +521 -0
- package/src/read-effect-platform.ts +7 -0
- package/src/read-effect-runtime.ts +14 -0
- package/src/read-effect.ts +52 -0
- package/src/read-projection-admission.ts +866 -0
- package/src/read-projections.ts +3151 -0
- package/src/recovery.ts +1492 -0
- package/src/reviewed-template.ts +146 -0
- package/src/run-journal.ts +1272 -0
- package/src/runtime.ts +5116 -0
- package/src/scripts/generate-provider-plugin-catalog.ts +121 -0
- package/src/scripts/install-whatsapp-protocol.sh +232 -0
- package/src/scripts/recover-install-stages.ts +1491 -0
- package/src/scripts/resolve-state-home.ts +30 -0
- package/src/scripts/scaffold-web-provider.ts +973 -0
- package/src/scripts/sync-bundled-adapters.ts +695 -0
- package/src/session-secrets.ts +1183 -0
- package/src/state-helper.bunfig.toml +2 -0
- package/src/state-helper.ts +2914 -0
- package/src/storage.ts +3842 -0
- package/src/transport-policy.ts +2 -0
- package/src/usage.ts +182 -0
- package/src/version.ts +2 -0
- package/src/web-session-authentication-policy.ts +136 -0
- package/src/web-session-cleanup-admission.ts +2344 -0
- package/src/web-session-cleanup-join.ts +99 -0
- package/src/web-session-client.ts +996 -0
- package/src/web-session-contract-definitions.ts +820 -0
- package/src/web-session-contract-planning.ts +42 -0
- package/src/web-session-contracts.ts +228 -0
- package/src/web-session-cookies.ts +500 -0
- package/src/web-session-execution.ts +385 -0
- package/src/web-session-http.ts +373 -0
- package/src/web-session-read-errors.ts +19 -0
- package/src/web-session-read-runtime.ts +124 -0
- package/src/web-session-recovery.ts +818 -0
- package/src/web-session-sites.ts +58 -0
- package/src/web-session-template.ts +994 -0
- package/src/web-session.ts +93 -0
- package/src/whatsapp-client-binding.ts +21 -0
- package/src/whatsapp-client-types.ts +75 -0
- package/src/whatsapp-client.ts +395 -0
- package/src/whatsapp-export-coordinate.ts +17 -0
- package/src/whatsapp-message-bundle-v2.ts +195 -0
- package/src/whatsapp-message-like-me-cli.ts +195 -0
- package/src/whatsapp-message-like-me-export.ts +1072 -0
- package/src/whatsapp-message-like-me-source.ts +1012 -0
- package/tsconfig.json +67 -0
|
@@ -0,0 +1,2325 @@
|
|
|
1
|
+
import { persistLocalTranscript } from "./transcript-persistence-runtime";
|
|
2
|
+
import type { TranscriptArtifactsResult } from "./transcript-persistence-model";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { existsSync, type Dirent } from "node:fs";
|
|
5
|
+
import { chmod, lstat, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
8
|
+
import type { CaptureMode } from "./args";
|
|
9
|
+
import { parseFfmpegVersion } from "./doctor";
|
|
10
|
+
import { createMediaDerivatives, type CreateMediaDerivativesOptions, type MediaDerivativeReport, type MediaDerivativeRole } from "./ffmpeg";
|
|
11
|
+
import {
|
|
12
|
+
GHOSTGET_MEDIA_PCM_NORMALIZATION_PROFILE,
|
|
13
|
+
GHOSTGET_MEDIA_SCHEMA_VERSION,
|
|
14
|
+
GHOSTGET_MEDIA_YT_DLP_YOUTUBE_IDENTITY_PROFILE,
|
|
15
|
+
GHOSTGET_MEDIA_VERSION,
|
|
16
|
+
createMediaArtifact,
|
|
17
|
+
localTranscriptVariantSegments,
|
|
18
|
+
parseMediaDirectHttpProvenance,
|
|
19
|
+
readMediaManifest,
|
|
20
|
+
relativeArtifactPath,
|
|
21
|
+
verifyMediaItem,
|
|
22
|
+
writeMediaManifest,
|
|
23
|
+
type ArtifactRole,
|
|
24
|
+
type MediaArtifact,
|
|
25
|
+
type MediaManifest,
|
|
26
|
+
type MediaYtDlpManifest,
|
|
27
|
+
type MediaLocalTranscriptIdentity,
|
|
28
|
+
type MediaLocalTranscriptProvenance,
|
|
29
|
+
type MediaTranscript,
|
|
30
|
+
type MediaYtDlpIdentity,
|
|
31
|
+
} from "./manifest";
|
|
32
|
+
import {
|
|
33
|
+
AUTH_CONTEXT_IDENTITY_PROFILE,
|
|
34
|
+
authContextSha256,
|
|
35
|
+
createDirectHttpMetadata,
|
|
36
|
+
identityDirectorySegment,
|
|
37
|
+
isNormalizedProbeMetadata,
|
|
38
|
+
isPortableIdentityDirectorySegment,
|
|
39
|
+
providerIdentitySha256,
|
|
40
|
+
renderProviderMetadataJson,
|
|
41
|
+
selectCaption,
|
|
42
|
+
variantAssetKey,
|
|
43
|
+
type CaptionSelection,
|
|
44
|
+
type ProbeMetadata,
|
|
45
|
+
} from "./metadata";
|
|
46
|
+
import {
|
|
47
|
+
acquireItemLock,
|
|
48
|
+
ItemLockBusyError,
|
|
49
|
+
ItemLockLostError,
|
|
50
|
+
type ItemLock,
|
|
51
|
+
} from "./lock";
|
|
52
|
+
import {
|
|
53
|
+
findExecutable,
|
|
54
|
+
redactDiagnostic,
|
|
55
|
+
runProcess,
|
|
56
|
+
urlDerivedRedactions,
|
|
57
|
+
} from "./process";
|
|
58
|
+
import { parseWebVtt } from "./transcript";
|
|
59
|
+
import {
|
|
60
|
+
loadConfiguredTranscriber,
|
|
61
|
+
type LoadConfiguredTranscriberOptions,
|
|
62
|
+
type LoadConfiguredTranscriberResult,
|
|
63
|
+
type ReadyTranscriber,
|
|
64
|
+
} from "./transcriber-config";
|
|
65
|
+
import { sameRuntimeClosureRecord } from "./runtime-closure";
|
|
66
|
+
import {
|
|
67
|
+
transcribeAudioLocally,
|
|
68
|
+
type LocalTranscriptionResult,
|
|
69
|
+
type TranscribeAudioLocallyOptions,
|
|
70
|
+
} from "./local-transcription";
|
|
71
|
+
import { normalizeWhisperCppLanguage } from "./whisper-cpp";
|
|
72
|
+
import { compareUtf8 } from "./utf8-order";
|
|
73
|
+
import {
|
|
74
|
+
resolveDirectMediaProbe,
|
|
75
|
+
routeSource,
|
|
76
|
+
type DirectHttpMediaProbeRoute,
|
|
77
|
+
type DirectHttpTranscriptProbeRoute,
|
|
78
|
+
} from "./source-router";
|
|
79
|
+
import {
|
|
80
|
+
captureDirectHttp,
|
|
81
|
+
type DirectHttpCapture,
|
|
82
|
+
type DirectHttpCaptureOptions,
|
|
83
|
+
type DirectHttpCaptureResult,
|
|
84
|
+
type DirectHttpCaptureSink,
|
|
85
|
+
} from "./http-capture";
|
|
86
|
+
import {
|
|
87
|
+
directHttpMediaForContainer,
|
|
88
|
+
type DirectHttpMedia,
|
|
89
|
+
} from "./http";
|
|
90
|
+
import {
|
|
91
|
+
DirectHttpProbeTransport,
|
|
92
|
+
probeDirectHttp,
|
|
93
|
+
type DirectHttpProbe,
|
|
94
|
+
type DirectHttpProbeOptions,
|
|
95
|
+
type DirectHttpProbeResult,
|
|
96
|
+
} from "./http-probe";
|
|
97
|
+
import {
|
|
98
|
+
captureWithYtDlp,
|
|
99
|
+
isSafeYtDlpCaptureExtension,
|
|
100
|
+
probeWithYtDlp,
|
|
101
|
+
ytDlpVersion,
|
|
102
|
+
type CaptureYtDlpOptions,
|
|
103
|
+
type ProbeYtDlpOptions,
|
|
104
|
+
type YtDlpCaptureResult,
|
|
105
|
+
type YtDlpProbeResult,
|
|
106
|
+
} from "./yt-dlp";
|
|
107
|
+
import {
|
|
108
|
+
MAX_REVISION_SEQUENCE,
|
|
109
|
+
MAX_TRACKED_REVISION_ITEMS,
|
|
110
|
+
GHOSTGET_MEDIA_REVISION_CONTENT_PROFILE,
|
|
111
|
+
GHOSTGET_MEDIA_TRACKED_REVISION_PROFILE,
|
|
112
|
+
REVISION_CAPTURE_NAMESPACE,
|
|
113
|
+
parseRevisionItemLeaf,
|
|
114
|
+
revisionContentSha256,
|
|
115
|
+
revisionItemLeaf,
|
|
116
|
+
trackedRevisionAssetKey,
|
|
117
|
+
type MediaTrackedRevision,
|
|
118
|
+
} from "./revision";
|
|
119
|
+
|
|
120
|
+
const imageExtensions = new Set(["avif", "gif", "jpeg", "jpg", "png", "webp"]);
|
|
121
|
+
export const FOCUSED_CAPTURE_NAMESPACE = ".wrench-media-variants" as const;
|
|
122
|
+
export const DIRECT_HTTP_CAPTURE_NAMESPACE = "direct-http-v1" as const;
|
|
123
|
+
|
|
124
|
+
export type MediaArchiveErrorCode =
|
|
125
|
+
| "CANCELLED"
|
|
126
|
+
| "DEPENDENCY_MISSING"
|
|
127
|
+
| "UNSUPPORTED_SOURCE"
|
|
128
|
+
| "PROBE_FAILED"
|
|
129
|
+
| "TRANSCRIPT_UNAVAILABLE"
|
|
130
|
+
| "CAPTURE_FAILED"
|
|
131
|
+
| "DERIVATION_FAILED"
|
|
132
|
+
| "TRANSCRIPTION_FAILED"
|
|
133
|
+
| "ARCHIVE_CONFLICT"
|
|
134
|
+
| "ARCHIVE_INVALID"
|
|
135
|
+
| "BUSY"
|
|
136
|
+
| "IO_ERROR";
|
|
137
|
+
|
|
138
|
+
export class MediaArchiveError extends Error {
|
|
139
|
+
readonly code: MediaArchiveErrorCode;
|
|
140
|
+
readonly details: Readonly<Record<string, unknown>>;
|
|
141
|
+
|
|
142
|
+
constructor(code: MediaArchiveErrorCode, message: string, details: Readonly<Record<string, unknown>> = {}) {
|
|
143
|
+
super(message);
|
|
144
|
+
this.name = "MediaArchiveError";
|
|
145
|
+
this.code = code;
|
|
146
|
+
this.details = details;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface MediaArchiveOptions {
|
|
151
|
+
readonly url: string;
|
|
152
|
+
readonly mode: CaptureMode;
|
|
153
|
+
readonly language: string;
|
|
154
|
+
readonly signal?: AbortSignal;
|
|
155
|
+
readonly libraryDirectory?: string;
|
|
156
|
+
readonly browser?: string;
|
|
157
|
+
readonly authContext?: string;
|
|
158
|
+
readonly inheritYtDlpConfig: boolean;
|
|
159
|
+
/** Explicitly reacquire yt-dlp content before resolving the immutable head. */
|
|
160
|
+
readonly refresh?: boolean;
|
|
161
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
162
|
+
readonly homeDirectory?: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface MediaArchiveResult {
|
|
166
|
+
readonly status: "created" | "existing";
|
|
167
|
+
readonly itemDirectory: string;
|
|
168
|
+
readonly manifest: MediaManifest;
|
|
169
|
+
readonly warnings: readonly string[];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface OrganizedCapture {
|
|
173
|
+
readonly mediaPath: string | null;
|
|
174
|
+
readonly captionPath: string | null;
|
|
175
|
+
readonly descriptionPath: string | null;
|
|
176
|
+
readonly thumbnailPath: string | null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface MediaArchiveDependencies {
|
|
180
|
+
readonly findExecutable: (name: string, options: { readonly env?: Readonly<Record<string, string | undefined>>; readonly homeDirectory?: string }) => Promise<string | null>;
|
|
181
|
+
readonly probe: (options: ProbeYtDlpOptions) => Promise<YtDlpProbeResult>;
|
|
182
|
+
readonly capture: (options: CaptureYtDlpOptions) => Promise<YtDlpCaptureResult>;
|
|
183
|
+
readonly derive: (options: CreateMediaDerivativesOptions) => Promise<MediaDerivativeReport>;
|
|
184
|
+
readonly ytDlpVersion: (executable: string) => Promise<string>;
|
|
185
|
+
readonly ffmpegVersion: (executable: string) => Promise<string>;
|
|
186
|
+
readonly probeDirectHttp: (
|
|
187
|
+
url: string,
|
|
188
|
+
options?: DirectHttpProbeOptions,
|
|
189
|
+
) => Promise<DirectHttpProbeResult>;
|
|
190
|
+
readonly captureDirectHttp: (
|
|
191
|
+
probe: DirectHttpProbe,
|
|
192
|
+
sink: DirectHttpCaptureSink,
|
|
193
|
+
options?: DirectHttpCaptureOptions,
|
|
194
|
+
) => Promise<DirectHttpCaptureResult>;
|
|
195
|
+
readonly loadConfiguredTranscriber: (
|
|
196
|
+
options: LoadConfiguredTranscriberOptions,
|
|
197
|
+
) => Promise<LoadConfiguredTranscriberResult>;
|
|
198
|
+
readonly transcribeAudioLocally: (
|
|
199
|
+
options: TranscribeAudioLocallyOptions,
|
|
200
|
+
) => Promise<LocalTranscriptionResult>;
|
|
201
|
+
readonly now: () => Date;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const defaultDependencies: MediaArchiveDependencies = {
|
|
205
|
+
findExecutable: async (name, options) => await findExecutable(name, options),
|
|
206
|
+
probe: async (options) => await probeWithYtDlp(options),
|
|
207
|
+
capture: async (options) => await captureWithYtDlp(options),
|
|
208
|
+
derive: async (options) => await createMediaDerivatives(options),
|
|
209
|
+
ytDlpVersion: async (executable) => await ytDlpVersion(executable),
|
|
210
|
+
ffmpegVersion: async (executable) => {
|
|
211
|
+
const result = await runProcess([executable, "-version"], { timeoutMs: 15_000, maxOutputBytes: 64 * 1024 });
|
|
212
|
+
if (!result.ok) return "unknown";
|
|
213
|
+
return parseFfmpegVersion(`${result.stdout}\n${result.stderr}`) ?? "unknown";
|
|
214
|
+
},
|
|
215
|
+
probeDirectHttp: async (url, options) => await probeDirectHttp(url, options),
|
|
216
|
+
captureDirectHttp: async (probe, sink, options) =>
|
|
217
|
+
await captureDirectHttp(probe, sink, options),
|
|
218
|
+
loadConfiguredTranscriber: async (options) => await loadConfiguredTranscriber(options),
|
|
219
|
+
transcribeAudioLocally: async (options) => await transcribeAudioLocally(options),
|
|
220
|
+
now: () => new Date(),
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
function isErrno(error: unknown, code: string): boolean {
|
|
224
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isCancelled(signal: AbortSignal | undefined): boolean {
|
|
228
|
+
return signal?.aborted === true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function libraryDirectory(options: MediaArchiveOptions): string {
|
|
232
|
+
if (options.libraryDirectory !== undefined) return resolve(options.libraryDirectory);
|
|
233
|
+
const configured = [options.environment?.["GHOSTGET_MEDIA_HOME"], options.environment?.["WRENCH_MEDIA_HOME"]]
|
|
234
|
+
.filter((value): value is string => value !== undefined && value.length > 0 && !value.includes("\0"))
|
|
235
|
+
.map((value) => resolve(value));
|
|
236
|
+
if (new Set(configured).size > 1) throw new Error("GHOSTGET_MEDIA_HOME and WRENCH_MEDIA_HOME select different media libraries");
|
|
237
|
+
if (configured[0] !== undefined) return configured[0];
|
|
238
|
+
// Existing verified archives and interrupted locks stay in their original library.
|
|
239
|
+
const dataRoot = resolve(options.homeDirectory ?? homedir(), ".local", "share");
|
|
240
|
+
const parentRoots = ["ghostget", "wrench", "oh", "io"].map((name) => join(dataRoot, name));
|
|
241
|
+
const existing = parentRoots.map((path) => join(path, "media")).filter((path) => existsSync(path));
|
|
242
|
+
if (existing.length > 1) throw new Error("multiple Ghostget and legacy media libraries exist; set GHOSTGET_MEDIA_HOME explicitly");
|
|
243
|
+
if (existing[0] !== undefined) return existing[0];
|
|
244
|
+
// A first archive must not create a competing default root beside existing
|
|
245
|
+
// auth or dispatch journals, which would make later state discovery ambiguous.
|
|
246
|
+
const existingParents = parentRoots.filter((path) => existsSync(path));
|
|
247
|
+
if (existingParents.length > 1) throw new Error("multiple Ghostget and legacy state roots exist; set GHOSTGET_MEDIA_HOME explicitly");
|
|
248
|
+
return join(existingParents[0] ?? join(dataRoot, "ghostget"), "media");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function requireExecutable(
|
|
252
|
+
name: "yt-dlp" | "ffmpeg" | "ffprobe",
|
|
253
|
+
options: MediaArchiveOptions,
|
|
254
|
+
dependencies: MediaArchiveDependencies,
|
|
255
|
+
): Promise<string> {
|
|
256
|
+
const executable = await dependencies.findExecutable(name, {
|
|
257
|
+
...(options.environment === undefined ? {} : { env: options.environment }),
|
|
258
|
+
...(options.homeDirectory === undefined ? {} : { homeDirectory: options.homeDirectory }),
|
|
259
|
+
});
|
|
260
|
+
if (executable === null) throw new MediaArchiveError("DEPENDENCY_MISSING", `${name} is required; run ghostget doctor for setup guidance`, { tool: name });
|
|
261
|
+
return executable;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function readExistingManifest(itemDirectory: string): Promise<MediaManifest> {
|
|
265
|
+
const verification = await verifyMediaItem(itemDirectory);
|
|
266
|
+
if (!verification.ok) {
|
|
267
|
+
throw new MediaArchiveError("ARCHIVE_INVALID", "an existing item failed integrity verification", {
|
|
268
|
+
itemDirectory,
|
|
269
|
+
failures: verification.failures,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
return await readMediaManifest(itemDirectory);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
throw new MediaArchiveError(
|
|
276
|
+
"ARCHIVE_INVALID",
|
|
277
|
+
error instanceof Error ? error.message : "existing wrench-media.json is invalid",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function manifestSatisfiesMode(manifest: MediaManifest, mode: CaptureMode): boolean {
|
|
283
|
+
if (manifest.mode !== mode) return false;
|
|
284
|
+
const roles = new Set(manifest.artifacts.map((artifact) => artifact.role));
|
|
285
|
+
const hasTranscriptArtifact = roles.has("transcript_vtt")
|
|
286
|
+
|| roles.has("transcript_text")
|
|
287
|
+
|| roles.has("transcript_json");
|
|
288
|
+
switch (mode) {
|
|
289
|
+
case "archive":
|
|
290
|
+
return roles.has("capture") && (roles.has("video") || roles.has("audio"));
|
|
291
|
+
case "audio":
|
|
292
|
+
return roles.has("capture")
|
|
293
|
+
&& roles.has("audio")
|
|
294
|
+
&& !roles.has("video")
|
|
295
|
+
&& !hasTranscriptArtifact
|
|
296
|
+
&& manifest.transcript.status === "unavailable";
|
|
297
|
+
case "video":
|
|
298
|
+
return roles.has("capture")
|
|
299
|
+
&& roles.has("video")
|
|
300
|
+
&& !roles.has("audio")
|
|
301
|
+
&& !hasTranscriptArtifact
|
|
302
|
+
&& manifest.transcript.status === "unavailable";
|
|
303
|
+
case "transcript":
|
|
304
|
+
if (manifest.transcript.status !== "available") return false;
|
|
305
|
+
return manifest.transcript.source === "local"
|
|
306
|
+
? roles.has("capture") && roles.has("audio") && !roles.has("video")
|
|
307
|
+
: !roles.has("capture") && !roles.has("audio") && !roles.has("video");
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function requestedDerivativeRoles(mode: CaptureMode): readonly MediaDerivativeRole[] {
|
|
312
|
+
switch (mode) {
|
|
313
|
+
case "archive":
|
|
314
|
+
return ["video", "audio"];
|
|
315
|
+
case "audio":
|
|
316
|
+
return ["audio"];
|
|
317
|
+
case "video":
|
|
318
|
+
return ["video"];
|
|
319
|
+
case "transcript":
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function assertManifestSatisfiesMode(manifest: MediaManifest, mode: CaptureMode): void {
|
|
325
|
+
if (manifestSatisfiesMode(manifest, mode)) return;
|
|
326
|
+
if (mode === "transcript") {
|
|
327
|
+
throw new MediaArchiveError(
|
|
328
|
+
"TRANSCRIPTION_FAILED",
|
|
329
|
+
"the completed capture does not contain the requested transcript artifact contract",
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
throw new MediaArchiveError(
|
|
333
|
+
"DERIVATION_FAILED",
|
|
334
|
+
`the completed capture does not contain the requested ${mode} artifact contract`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export interface CaptureIdentity {
|
|
339
|
+
readonly kind: "archive" | "focused";
|
|
340
|
+
readonly itemPathSegments: readonly string[];
|
|
341
|
+
readonly storagePathSegments: readonly string[];
|
|
342
|
+
readonly assetKey: string;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export interface RevisionLineageIdentity {
|
|
346
|
+
readonly itemParentPathSegments: readonly string[];
|
|
347
|
+
readonly storagePathSegments: readonly string[];
|
|
348
|
+
readonly subjectAssetKey: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export type CaptureIdentityRequest =
|
|
352
|
+
| Readonly<{ mode: "archive" }>
|
|
353
|
+
| Readonly<{ mode: "audio" }>
|
|
354
|
+
| Readonly<{ mode: "video" }>
|
|
355
|
+
| Readonly<{
|
|
356
|
+
mode: "transcript";
|
|
357
|
+
transcript:
|
|
358
|
+
| Readonly<{
|
|
359
|
+
kind: "provider";
|
|
360
|
+
source: "manual" | "automatic";
|
|
361
|
+
language: string;
|
|
362
|
+
}>
|
|
363
|
+
| Readonly<{
|
|
364
|
+
kind: "local";
|
|
365
|
+
identity: MediaLocalTranscriptIdentity;
|
|
366
|
+
}>;
|
|
367
|
+
}>;
|
|
368
|
+
|
|
369
|
+
type DirectHttpCaptureRoute = DirectHttpMediaProbeRoute | DirectHttpTranscriptProbeRoute;
|
|
370
|
+
|
|
371
|
+
type LocalTranscriptionPlan =
|
|
372
|
+
| Readonly<{ kind: "not-configured" }>
|
|
373
|
+
| Readonly<{
|
|
374
|
+
kind: "ready";
|
|
375
|
+
requestedLanguage: string;
|
|
376
|
+
transcriber: ReadyTranscriber;
|
|
377
|
+
identity: MediaLocalTranscriptIdentity;
|
|
378
|
+
}>;
|
|
379
|
+
|
|
380
|
+
function normalizedLocalLanguage(language: string): string {
|
|
381
|
+
const normalized = normalizeWhisperCppLanguage(language);
|
|
382
|
+
if (normalized === null) {
|
|
383
|
+
throw new MediaArchiveError(
|
|
384
|
+
"TRANSCRIPTION_FAILED",
|
|
385
|
+
"local transcription language must be auto or a literal BCP-47-style tag",
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
return normalized;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function localTranscriberLoadOptions(
|
|
392
|
+
options: MediaArchiveOptions,
|
|
393
|
+
): LoadConfiguredTranscriberOptions {
|
|
394
|
+
return {
|
|
395
|
+
...(options.environment === undefined ? {} : { env: options.environment }),
|
|
396
|
+
...(options.homeDirectory === undefined ? {} : { homeDirectory: options.homeDirectory }),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function loadLocalTranscriptionPlan(
|
|
401
|
+
options: MediaArchiveOptions,
|
|
402
|
+
dependencies: MediaArchiveDependencies,
|
|
403
|
+
): Promise<LocalTranscriptionPlan> {
|
|
404
|
+
const requestedLanguage = normalizedLocalLanguage(options.language);
|
|
405
|
+
let configured: LoadConfiguredTranscriberResult;
|
|
406
|
+
try {
|
|
407
|
+
configured = await dependencies.loadConfiguredTranscriber(
|
|
408
|
+
localTranscriberLoadOptions(options),
|
|
409
|
+
);
|
|
410
|
+
} catch {
|
|
411
|
+
throw new MediaArchiveError(
|
|
412
|
+
"DEPENDENCY_MISSING",
|
|
413
|
+
"the local transcriber configuration could not be verified",
|
|
414
|
+
{ dependency: "local-transcriber" },
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (configured.kind === "not-configured") return configured;
|
|
418
|
+
if (configured.kind === "invalid") {
|
|
419
|
+
throw new MediaArchiveError(
|
|
420
|
+
"DEPENDENCY_MISSING",
|
|
421
|
+
configured.message,
|
|
422
|
+
{ dependency: "local-transcriber", reason: configured.reason },
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
kind: "ready",
|
|
427
|
+
requestedLanguage,
|
|
428
|
+
transcriber: configured.transcriber,
|
|
429
|
+
identity: {
|
|
430
|
+
adapter: configured.transcriber.descriptor.adapter,
|
|
431
|
+
profile: configured.transcriber.descriptor.profile,
|
|
432
|
+
executableSha256: configured.transcriber.descriptor.executableSha256,
|
|
433
|
+
runtimeProfile: configured.transcriber.descriptor.runtimeProfile,
|
|
434
|
+
runtimeSha256: configured.transcriber.descriptor.runtimeSha256,
|
|
435
|
+
runtimeDependencyCount: configured.transcriber.descriptor.runtimeDependencyCount,
|
|
436
|
+
modelSha256: configured.transcriber.descriptor.modelSha256,
|
|
437
|
+
normalizationProfile: GHOSTGET_MEDIA_PCM_NORMALIZATION_PROFILE,
|
|
438
|
+
requestedLanguage,
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function sameReadyTranscriber(left: ReadyTranscriber, right: ReadyTranscriber): boolean {
|
|
444
|
+
return left.executablePath === right.executablePath
|
|
445
|
+
&& left.modelPath === right.modelPath
|
|
446
|
+
&& left.descriptor.adapter === right.descriptor.adapter
|
|
447
|
+
&& left.descriptor.profile === right.descriptor.profile
|
|
448
|
+
&& left.descriptor.executableSha256 === right.descriptor.executableSha256
|
|
449
|
+
&& left.descriptor.runtimeProfile === right.descriptor.runtimeProfile
|
|
450
|
+
&& left.descriptor.runtimeSha256 === right.descriptor.runtimeSha256
|
|
451
|
+
&& left.descriptor.runtimeDependencyCount === right.descriptor.runtimeDependencyCount
|
|
452
|
+
&& left.descriptor.modelSha256 === right.descriptor.modelSha256
|
|
453
|
+
&& left.descriptor.modelBytes === right.descriptor.modelBytes
|
|
454
|
+
&& sameRuntimeClosureRecord(left.runtimeClosure, right.runtimeClosure);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function reverifyLocalTranscriptionPlan(
|
|
458
|
+
options: MediaArchiveOptions,
|
|
459
|
+
plan: Extract<LocalTranscriptionPlan, { kind: "ready" }>,
|
|
460
|
+
dependencies: MediaArchiveDependencies,
|
|
461
|
+
): Promise<ReadyTranscriber> {
|
|
462
|
+
let configured: LoadConfiguredTranscriberResult;
|
|
463
|
+
try {
|
|
464
|
+
configured = await dependencies.loadConfiguredTranscriber(
|
|
465
|
+
localTranscriberLoadOptions(options),
|
|
466
|
+
);
|
|
467
|
+
} catch {
|
|
468
|
+
throw new MediaArchiveError(
|
|
469
|
+
"TRANSCRIPTION_FAILED",
|
|
470
|
+
"the local transcriber could not be reverified immediately before transcription",
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
if (configured.kind !== "ready" || !sameReadyTranscriber(configured.transcriber, plan.transcriber)) {
|
|
474
|
+
throw new MediaArchiveError(
|
|
475
|
+
"TRANSCRIPTION_FAILED",
|
|
476
|
+
"the configured local transcriber changed after the capture identity was planned",
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return configured.transcriber;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function requireFocusedLocalPlan(plan: LocalTranscriptionPlan): Extract<LocalTranscriptionPlan, { kind: "ready" }> {
|
|
483
|
+
if (plan.kind === "not-configured") {
|
|
484
|
+
throw new MediaArchiveError(
|
|
485
|
+
"TRANSCRIPT_UNAVAILABLE",
|
|
486
|
+
"the provider exposes no captions and no local transcriber is configured",
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
return plan;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function boundedLanguageSegment(value: string): string {
|
|
493
|
+
const candidate = identityDirectorySegment(value.toLowerCase(), "language");
|
|
494
|
+
return candidate.length <= 64
|
|
495
|
+
? candidate
|
|
496
|
+
: identityDirectorySegment(`/${value.toLowerCase()}`, "language");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function focusedVariantSegments(
|
|
500
|
+
request: Exclude<CaptureIdentityRequest, { readonly mode: "archive" }>,
|
|
501
|
+
): readonly string[] {
|
|
502
|
+
switch (request.mode) {
|
|
503
|
+
case "audio":
|
|
504
|
+
return ["audio"];
|
|
505
|
+
case "video":
|
|
506
|
+
return ["video"];
|
|
507
|
+
case "transcript": {
|
|
508
|
+
if (request.transcript.kind === "local") {
|
|
509
|
+
return localTranscriptVariantSegments(request.transcript.identity);
|
|
510
|
+
}
|
|
511
|
+
return [
|
|
512
|
+
"transcript",
|
|
513
|
+
request.transcript.source,
|
|
514
|
+
boundedLanguageSegment(request.transcript.language),
|
|
515
|
+
];
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function assertNormalizedSourceIdentity(metadata: ProbeMetadata): void {
|
|
521
|
+
if (!isNormalizedProbeMetadata(metadata)) {
|
|
522
|
+
throw new MediaArchiveError(
|
|
523
|
+
"PROBE_FAILED",
|
|
524
|
+
"the provider returned an inconsistent normalized source identity",
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function captureIdentity(
|
|
530
|
+
metadata: ProbeMetadata,
|
|
531
|
+
request: CaptureIdentityRequest,
|
|
532
|
+
): CaptureIdentity {
|
|
533
|
+
assertNormalizedSourceIdentity(metadata);
|
|
534
|
+
if (request.mode === "archive") {
|
|
535
|
+
return {
|
|
536
|
+
kind: "archive",
|
|
537
|
+
itemPathSegments: [metadata.itemDirectory],
|
|
538
|
+
storagePathSegments: [metadata.assetKey],
|
|
539
|
+
assetKey: metadata.assetKey,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const variantSegments = focusedVariantSegments(request);
|
|
543
|
+
return {
|
|
544
|
+
kind: "focused",
|
|
545
|
+
itemPathSegments: [
|
|
546
|
+
FOCUSED_CAPTURE_NAMESPACE,
|
|
547
|
+
metadata.itemDirectory,
|
|
548
|
+
...variantSegments,
|
|
549
|
+
],
|
|
550
|
+
storagePathSegments: [
|
|
551
|
+
FOCUSED_CAPTURE_NAMESPACE,
|
|
552
|
+
metadata.assetKey,
|
|
553
|
+
...variantSegments,
|
|
554
|
+
],
|
|
555
|
+
assetKey: variantAssetKey(metadata.assetKey, variantSegments),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Stable parent and lock identity shared by every occurrence of one subject. */
|
|
560
|
+
export function revisionLineageIdentity(
|
|
561
|
+
metadata: ProbeMetadata,
|
|
562
|
+
request: CaptureIdentityRequest,
|
|
563
|
+
): RevisionLineageIdentity {
|
|
564
|
+
const subject = captureIdentity(metadata, request);
|
|
565
|
+
const variantSegments = request.mode === "archive"
|
|
566
|
+
? ["archive"]
|
|
567
|
+
: focusedVariantSegments(request);
|
|
568
|
+
return {
|
|
569
|
+
itemParentPathSegments: [
|
|
570
|
+
REVISION_CAPTURE_NAMESPACE,
|
|
571
|
+
metadata.itemDirectory,
|
|
572
|
+
...variantSegments,
|
|
573
|
+
],
|
|
574
|
+
storagePathSegments: [
|
|
575
|
+
REVISION_CAPTURE_NAMESPACE,
|
|
576
|
+
metadata.assetKey,
|
|
577
|
+
...variantSegments,
|
|
578
|
+
],
|
|
579
|
+
subjectAssetKey: subject.assetKey,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function ensurePhysicalDirectory(path: string): Promise<void> {
|
|
584
|
+
try {
|
|
585
|
+
const metadata = await lstat(path);
|
|
586
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
587
|
+
throw new MediaArchiveError("IO_ERROR", `archive path is not a physical directory: ${path}`);
|
|
588
|
+
}
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (!isErrno(error, "ENOENT")) throw error;
|
|
591
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
592
|
+
const created = await lstat(path);
|
|
593
|
+
if (!created.isDirectory() || created.isSymbolicLink()) throw new MediaArchiveError("IO_ERROR", `archive path is not a physical directory: ${path}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function ensurePhysicalChildDirectory(path: string): Promise<void> {
|
|
598
|
+
try {
|
|
599
|
+
const metadata = await lstat(path);
|
|
600
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
601
|
+
throw new MediaArchiveError("IO_ERROR", `archive path is not a physical directory: ${path}`);
|
|
602
|
+
}
|
|
603
|
+
} catch (error) {
|
|
604
|
+
if (!isErrno(error, "ENOENT")) throw error;
|
|
605
|
+
try {
|
|
606
|
+
await mkdir(path, { mode: 0o700 });
|
|
607
|
+
} catch (mkdirError) {
|
|
608
|
+
if (!isErrno(mkdirError, "EEXIST")) throw mkdirError;
|
|
609
|
+
}
|
|
610
|
+
const created = await lstat(path);
|
|
611
|
+
if (!created.isDirectory() || created.isSymbolicLink()) {
|
|
612
|
+
throw new MediaArchiveError("IO_ERROR", `archive path is not a physical directory: ${path}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function discardStagingItem(path: string): Promise<void> {
|
|
618
|
+
const quarantine = join(dirname(path), `.wrench-media-discard-${randomUUID()}`);
|
|
619
|
+
try {
|
|
620
|
+
await rename(path, quarantine);
|
|
621
|
+
} catch (error) {
|
|
622
|
+
if (isErrno(error, "ENOENT")) return;
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
const metadata = await lstat(quarantine);
|
|
626
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
627
|
+
throw new MediaArchiveError(
|
|
628
|
+
"IO_ERROR",
|
|
629
|
+
"capture staging is not a discardable physical directory",
|
|
630
|
+
{ quarantinedPath: quarantine },
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function resetStagingItem(path: string): Promise<void> {
|
|
637
|
+
await discardStagingItem(path);
|
|
638
|
+
await ensurePhysicalChildDirectory(path);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function adoptCaptureAttempt(source: string, destination: string): Promise<void> {
|
|
642
|
+
try {
|
|
643
|
+
const metadata = await lstat(destination);
|
|
644
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
645
|
+
throw new MediaArchiveError(
|
|
646
|
+
"IO_ERROR",
|
|
647
|
+
"existing capture staging is not a physical directory",
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
await rm(destination, { recursive: true, force: true });
|
|
651
|
+
} catch (error) {
|
|
652
|
+
if (!isErrno(error, "ENOENT")) throw error;
|
|
653
|
+
}
|
|
654
|
+
await rename(source, destination);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function ensurePhysicalDirectorySegments(
|
|
658
|
+
base: string,
|
|
659
|
+
segments: readonly string[],
|
|
660
|
+
): Promise<string> {
|
|
661
|
+
let current = base;
|
|
662
|
+
for (const segment of segments) {
|
|
663
|
+
if (
|
|
664
|
+
segment !== FOCUSED_CAPTURE_NAMESPACE
|
|
665
|
+
&& segment !== REVISION_CAPTURE_NAMESPACE
|
|
666
|
+
&& !isPortableIdentityDirectorySegment(segment)
|
|
667
|
+
) {
|
|
668
|
+
throw new MediaArchiveError("IO_ERROR", "archive identity contains an unsafe path segment");
|
|
669
|
+
}
|
|
670
|
+
current = join(current, segment);
|
|
671
|
+
await ensurePhysicalChildDirectory(current);
|
|
672
|
+
}
|
|
673
|
+
return current;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function splitIdentityLeaf(segments: readonly string[]): Readonly<{
|
|
677
|
+
parentSegments: readonly string[];
|
|
678
|
+
leaf: string;
|
|
679
|
+
}> {
|
|
680
|
+
const leaf = segments.at(-1);
|
|
681
|
+
if (leaf === undefined) throw new MediaArchiveError("IO_ERROR", "archive identity is empty");
|
|
682
|
+
return { parentSegments: segments.slice(0, -1), leaf };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function extension(path: string): string {
|
|
686
|
+
const name = basename(path);
|
|
687
|
+
const index = name.lastIndexOf(".");
|
|
688
|
+
return index === -1 ? "" : name.slice(index + 1).toLowerCase();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function moveSidecar(source: string, destination: string): Promise<string> {
|
|
692
|
+
await rename(source, destination);
|
|
693
|
+
return destination;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function removeRegularFileIfPresent(path: string, label: string): Promise<void> {
|
|
697
|
+
try {
|
|
698
|
+
const metadata = await lstat(path);
|
|
699
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
700
|
+
throw new MediaArchiveError("IO_ERROR", `${label} is not a regular file`);
|
|
701
|
+
}
|
|
702
|
+
await unlink(path);
|
|
703
|
+
} catch (error) {
|
|
704
|
+
if (!isErrno(error, "ENOENT")) throw error;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function writeProviderMetadata(
|
|
709
|
+
itemRoot: string,
|
|
710
|
+
metadata: ProbeMetadata,
|
|
711
|
+
): Promise<string> {
|
|
712
|
+
const metadataDirectory = join(itemRoot, "data", "metadata");
|
|
713
|
+
const path = join(metadataDirectory, "provider.json");
|
|
714
|
+
await writeFile(path, renderProviderMetadataJson(metadata), {
|
|
715
|
+
encoding: "utf8",
|
|
716
|
+
mode: 0o600,
|
|
717
|
+
});
|
|
718
|
+
return path;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export async function organizeCaptureFiles(
|
|
722
|
+
itemRoot: string,
|
|
723
|
+
expectedMediaExtension: string,
|
|
724
|
+
persistDescriptiveSidecars = true,
|
|
725
|
+
): Promise<OrganizedCapture> {
|
|
726
|
+
if (!isSafeYtDlpCaptureExtension(expectedMediaExtension)) {
|
|
727
|
+
throw new MediaArchiveError(
|
|
728
|
+
"CAPTURE_FAILED",
|
|
729
|
+
"the acquisition adapter returned an unsafe primary media extension",
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
const captureDirectory = join(itemRoot, "data", "capture");
|
|
733
|
+
const metadataDirectory = join(itemRoot, "data", "metadata");
|
|
734
|
+
const captionsDirectory = join(itemRoot, "data", "captions");
|
|
735
|
+
const entries = await readdir(captureDirectory, { withFileTypes: true });
|
|
736
|
+
for (const entry of entries) {
|
|
737
|
+
if (entry.isSymbolicLink() || !entry.isFile()) {
|
|
738
|
+
throw new MediaArchiveError("IO_ERROR", `the acquisition adapter produced an unsupported filesystem entry: ${entry.name}`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
const files = entries
|
|
742
|
+
.filter((entry) => entry.isFile())
|
|
743
|
+
.map((entry) => entry.name)
|
|
744
|
+
.toSorted(compareUtf8);
|
|
745
|
+
const rawInfoFiles = files.filter((name) => name.endsWith(".info.json"));
|
|
746
|
+
for (const name of rawInfoFiles) {
|
|
747
|
+
await removeRegularFileIfPresent(
|
|
748
|
+
join(captureDirectory, name),
|
|
749
|
+
"raw yt-dlp provider metadata",
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
const expectedMediaName = `media.${expectedMediaExtension}`;
|
|
753
|
+
const mediaFiles = files.filter((name) => name === expectedMediaName);
|
|
754
|
+
const captionFiles = files.filter((name) => name.startsWith("media.") && extension(name) === "vtt");
|
|
755
|
+
const thumbnailFiles = files.filter((name) => name.startsWith("media.") && imageExtensions.has(extension(name)));
|
|
756
|
+
if (mediaFiles.length > 1) throw new MediaArchiveError("ARCHIVE_CONFLICT", "capture staging contains more than one primary media file", { files: mediaFiles });
|
|
757
|
+
if (captionFiles.length > 1) throw new MediaArchiveError("ARCHIVE_CONFLICT", "capture staging contains more than one selected caption file", { files: captionFiles });
|
|
758
|
+
if (thumbnailFiles.length > 1) throw new MediaArchiveError("ARCHIVE_CONFLICT", "capture staging contains more than one thumbnail file", { files: thumbnailFiles });
|
|
759
|
+
|
|
760
|
+
const descriptionName = files.find((name) => name === "media.description");
|
|
761
|
+
const captionName = captionFiles[0];
|
|
762
|
+
const thumbnailName = thumbnailFiles[0];
|
|
763
|
+
const descriptionSource = descriptionName === undefined
|
|
764
|
+
? null
|
|
765
|
+
: join(captureDirectory, descriptionName);
|
|
766
|
+
const thumbnailSource = thumbnailName === undefined
|
|
767
|
+
? null
|
|
768
|
+
: join(captureDirectory, thumbnailName);
|
|
769
|
+
const claimedFiles = new Set([
|
|
770
|
+
...rawInfoFiles,
|
|
771
|
+
...mediaFiles,
|
|
772
|
+
...captionFiles,
|
|
773
|
+
...thumbnailFiles,
|
|
774
|
+
...(descriptionName === undefined ? [] : [descriptionName]),
|
|
775
|
+
]);
|
|
776
|
+
const unclaimedFiles = files.filter((name) => !claimedFiles.has(name));
|
|
777
|
+
if (unclaimedFiles.length > 0) {
|
|
778
|
+
throw new MediaArchiveError(
|
|
779
|
+
"ARCHIVE_CONFLICT",
|
|
780
|
+
"capture staging contains files that are not owned by Ghostget media's acquisition contract",
|
|
781
|
+
{ files: unclaimedFiles },
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
if (!persistDescriptiveSidecars) {
|
|
785
|
+
if (descriptionSource !== null) {
|
|
786
|
+
await removeRegularFileIfPresent(descriptionSource, "unowned provider description");
|
|
787
|
+
}
|
|
788
|
+
if (thumbnailSource !== null) {
|
|
789
|
+
await removeRegularFileIfPresent(thumbnailSource, "unowned provider thumbnail");
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
mediaPath: mediaFiles[0] === undefined ? null : join(captureDirectory, mediaFiles[0]),
|
|
794
|
+
descriptionPath: descriptionSource === null || !persistDescriptiveSidecars
|
|
795
|
+
? null
|
|
796
|
+
: await moveSidecar(descriptionSource, join(metadataDirectory, "description.txt")),
|
|
797
|
+
captionPath: captionName === undefined ? null : await moveSidecar(join(captureDirectory, captionName), join(captionsDirectory, "transcript.vtt")),
|
|
798
|
+
thumbnailPath: thumbnailSource === null || thumbnailName === undefined || !persistDescriptiveSidecars
|
|
799
|
+
? null
|
|
800
|
+
: await moveSidecar(thumbnailSource, join(metadataDirectory, `thumbnail.${extension(thumbnailName)}`)),
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async function transcriptArtifacts(
|
|
805
|
+
itemRoot: string,
|
|
806
|
+
captionPath: string | null,
|
|
807
|
+
caption: CaptionSelection | null,
|
|
808
|
+
mode: CaptureMode,
|
|
809
|
+
): Promise<{ readonly transcript: MediaTranscript; readonly artifacts: readonly MediaArtifact[] }> {
|
|
810
|
+
if (caption === null) {
|
|
811
|
+
return {
|
|
812
|
+
transcript: {
|
|
813
|
+
status: "unavailable",
|
|
814
|
+
reason: mode === "audio" || mode === "video"
|
|
815
|
+
? "not_requested"
|
|
816
|
+
: "provider_has_no_captions",
|
|
817
|
+
},
|
|
818
|
+
artifacts: [],
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
if (captionPath === null) {
|
|
822
|
+
throw new MediaArchiveError(
|
|
823
|
+
"CAPTURE_FAILED",
|
|
824
|
+
"yt-dlp completed without the selected provider transcript",
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
const metadata = await lstat(captionPath);
|
|
828
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 64 * 1024 * 1024) {
|
|
829
|
+
throw new MediaArchiveError("DERIVATION_FAILED", "provider transcript is missing, unsafe, or larger than 64 MiB");
|
|
830
|
+
}
|
|
831
|
+
const parsed = parseWebVtt(await readFile(captionPath, "utf8"));
|
|
832
|
+
if (!parsed.ok) throw new MediaArchiveError("DERIVATION_FAILED", parsed.error.message, { transcriptCode: parsed.error.code });
|
|
833
|
+
const captionsDirectory = join(itemRoot, "data", "captions");
|
|
834
|
+
const textPath = join(captionsDirectory, "transcript.txt");
|
|
835
|
+
const cuesPath = join(captionsDirectory, "transcript.json");
|
|
836
|
+
await writeFile(textPath, parsed.text, { encoding: "utf8", mode: 0o600 });
|
|
837
|
+
await writeFile(cuesPath, parsed.json, { encoding: "utf8", mode: 0o600 });
|
|
838
|
+
const timedRelative = relativeArtifactPath(itemRoot, captionPath);
|
|
839
|
+
const textRelative = relativeArtifactPath(itemRoot, textPath);
|
|
840
|
+
const cuesRelative = relativeArtifactPath(itemRoot, cuesPath);
|
|
841
|
+
return {
|
|
842
|
+
transcript: {
|
|
843
|
+
status: "available",
|
|
844
|
+
source: caption.source,
|
|
845
|
+
language: caption.language,
|
|
846
|
+
timedPath: timedRelative,
|
|
847
|
+
textPath: textRelative,
|
|
848
|
+
cuesPath: cuesRelative,
|
|
849
|
+
},
|
|
850
|
+
artifacts: [
|
|
851
|
+
await createMediaArtifact(itemRoot, timedRelative, "transcript_vtt"),
|
|
852
|
+
await createMediaArtifact(itemRoot, textRelative, "transcript_text"),
|
|
853
|
+
await createMediaArtifact(itemRoot, cuesRelative, "transcript_json"),
|
|
854
|
+
],
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function unavailableTranscript(
|
|
859
|
+
reason: "not_requested" | "transcriber_not_configured" | "audio_not_present",
|
|
860
|
+
): TranscriptArtifactsResult {
|
|
861
|
+
return { transcript: { status: "unavailable", reason }, artifacts: [] };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function localProvenanceMatchesPlan(
|
|
865
|
+
provenance: MediaLocalTranscriptProvenance,
|
|
866
|
+
plan: Extract<LocalTranscriptionPlan, { kind: "ready" }>,
|
|
867
|
+
audioArtifact: MediaArtifact,
|
|
868
|
+
): boolean {
|
|
869
|
+
return provenance.adapter === plan.identity.adapter
|
|
870
|
+
&& provenance.profile === plan.identity.profile
|
|
871
|
+
&& provenance.executableSha256 === plan.identity.executableSha256
|
|
872
|
+
&& provenance.runtimeProfile === plan.identity.runtimeProfile
|
|
873
|
+
&& provenance.runtimeSha256 === plan.identity.runtimeSha256
|
|
874
|
+
&& provenance.runtimeDependencyCount === plan.identity.runtimeDependencyCount
|
|
875
|
+
&& provenance.modelSha256 === plan.identity.modelSha256
|
|
876
|
+
&& provenance.requestedLanguage === plan.identity.requestedLanguage
|
|
877
|
+
&& provenance.input.path === audioArtifact.path
|
|
878
|
+
&& provenance.input.bytes === audioArtifact.bytes
|
|
879
|
+
&& provenance.input.sha256 === audioArtifact.sha256
|
|
880
|
+
&& provenance.input.normalized.profile === plan.identity.normalizationProfile;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function localTranscriptArtifacts(
|
|
884
|
+
itemRoot: string,
|
|
885
|
+
mode: "archive" | "transcript",
|
|
886
|
+
plan: LocalTranscriptionPlan,
|
|
887
|
+
artifacts: readonly MediaArtifact[],
|
|
888
|
+
ffmpegExecutable: string | null,
|
|
889
|
+
options: MediaArchiveOptions,
|
|
890
|
+
dependencies: MediaArchiveDependencies,
|
|
891
|
+
): Promise<TranscriptArtifactsResult> {
|
|
892
|
+
if (plan.kind === "not-configured") {
|
|
893
|
+
if (mode === "transcript") requireFocusedLocalPlan(plan);
|
|
894
|
+
return unavailableTranscript("transcriber_not_configured");
|
|
895
|
+
}
|
|
896
|
+
const audioArtifact = artifacts.find((artifact) => artifact.role === "audio");
|
|
897
|
+
if (audioArtifact === undefined) {
|
|
898
|
+
if (mode === "transcript") {
|
|
899
|
+
throw new MediaArchiveError(
|
|
900
|
+
"TRANSCRIPT_UNAVAILABLE",
|
|
901
|
+
"the captured media does not contain an audio stream to transcribe",
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
return unavailableTranscript("audio_not_present");
|
|
905
|
+
}
|
|
906
|
+
if (ffmpegExecutable === null) {
|
|
907
|
+
throw new MediaArchiveError(
|
|
908
|
+
"TRANSCRIPTION_FAILED",
|
|
909
|
+
"FFmpeg is unavailable for local transcription",
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const reverifiedTranscriber = await reverifyLocalTranscriptionPlan(
|
|
914
|
+
options,
|
|
915
|
+
plan,
|
|
916
|
+
dependencies,
|
|
917
|
+
);
|
|
918
|
+
|
|
919
|
+
const attemptDirectory = await mkdtemp(join(itemRoot, ".tmp", "transcription-attempt-"));
|
|
920
|
+
await chmod(attemptDirectory, 0o700);
|
|
921
|
+
let result: LocalTranscriptionResult;
|
|
922
|
+
try {
|
|
923
|
+
result = await dependencies.transcribeAudioLocally({
|
|
924
|
+
audioPath: join(itemRoot, audioArtifact.path),
|
|
925
|
+
audioArtifact,
|
|
926
|
+
attemptDirectory,
|
|
927
|
+
ffmpegExecutable,
|
|
928
|
+
requestedLanguage: plan.requestedLanguage,
|
|
929
|
+
transcriber: reverifiedTranscriber,
|
|
930
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
931
|
+
});
|
|
932
|
+
} catch {
|
|
933
|
+
if (isCancelled(options.signal)) {
|
|
934
|
+
throw new MediaArchiveError("CANCELLED", "local transcription was cancelled");
|
|
935
|
+
}
|
|
936
|
+
throw new MediaArchiveError(
|
|
937
|
+
"TRANSCRIPTION_FAILED",
|
|
938
|
+
"the local transcription adapter failed unexpectedly",
|
|
939
|
+
{ stage: "transcriber" },
|
|
940
|
+
);
|
|
941
|
+
} finally {
|
|
942
|
+
await rm(attemptDirectory, { recursive: true, force: true });
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (result.status === "failed") {
|
|
946
|
+
if (result.stage === "cancelled" || isCancelled(options.signal)) {
|
|
947
|
+
throw new MediaArchiveError("CANCELLED", "local transcription was cancelled");
|
|
948
|
+
}
|
|
949
|
+
throw new MediaArchiveError(
|
|
950
|
+
"TRANSCRIPTION_FAILED",
|
|
951
|
+
result.diagnostic,
|
|
952
|
+
{ stage: result.stage },
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
if (!localProvenanceMatchesPlan(result.provenance, plan, audioArtifact)) {
|
|
956
|
+
throw new MediaArchiveError(
|
|
957
|
+
"TRANSCRIPTION_FAILED",
|
|
958
|
+
"local transcription returned provenance that does not match the frozen capture identity",
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
if (result.status === "no-speech") {
|
|
962
|
+
if (mode === "transcript") {
|
|
963
|
+
throw new MediaArchiveError(
|
|
964
|
+
"TRANSCRIPT_UNAVAILABLE",
|
|
965
|
+
"local transcription found no speech in the captured audio",
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
transcript: {
|
|
970
|
+
status: "unavailable",
|
|
971
|
+
reason: "no_speech",
|
|
972
|
+
provenance: result.provenance,
|
|
973
|
+
},
|
|
974
|
+
artifacts: [],
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
return await persistLocalTranscript(itemRoot, result);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function archiveTranscriptWarnings(transcript: MediaManifest["transcript"]): readonly string[] {
|
|
981
|
+
if (transcript.status === "available") return [];
|
|
982
|
+
switch (transcript.reason) {
|
|
983
|
+
case "not_requested":
|
|
984
|
+
return [];
|
|
985
|
+
case "transcriber_not_configured":
|
|
986
|
+
return ["provider has no transcript and no local transcriber is configured; the media archive is complete"];
|
|
987
|
+
case "audio_not_present":
|
|
988
|
+
return ["provider has no transcript and the media has no audio stream; the media archive is complete"];
|
|
989
|
+
case "no_speech":
|
|
990
|
+
return ["provider has no transcript and local transcription found no speech; the media archive is complete"];
|
|
991
|
+
case "provider_has_no_captions":
|
|
992
|
+
return ["provider has no transcript; the media archive is complete"];
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
async function addArtifact(artifacts: MediaArtifact[], itemRoot: string, path: string | null, role: ArtifactRole): Promise<void> {
|
|
997
|
+
if (path !== null) artifacts.push(await createMediaArtifact(itemRoot, relativeArtifactPath(itemRoot, path), role));
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
async function hardenTree(path: string): Promise<void> {
|
|
1001
|
+
const metadata = await lstat(path);
|
|
1002
|
+
if (metadata.isSymbolicLink()) throw new MediaArchiveError("IO_ERROR", `archive contains a symbolic link: ${path}`);
|
|
1003
|
+
if (metadata.isFile()) {
|
|
1004
|
+
await chmod(path, 0o600);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (!metadata.isDirectory()) throw new MediaArchiveError("IO_ERROR", `archive contains an unsupported entry: ${path}`);
|
|
1008
|
+
await chmod(path, 0o700);
|
|
1009
|
+
const entries = await readdir(path);
|
|
1010
|
+
for (const entry of entries) await hardenTree(join(path, entry));
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function sourceManifest(metadata: ProbeMetadata): MediaManifest["source"] {
|
|
1014
|
+
return {
|
|
1015
|
+
extractor: metadata.extractor,
|
|
1016
|
+
id: metadata.id,
|
|
1017
|
+
canonicalUrl: metadata.canonicalUrl,
|
|
1018
|
+
...(metadata.title === undefined ? {} : { title: metadata.title }),
|
|
1019
|
+
...(metadata.uploader === undefined ? {} : { uploader: metadata.uploader }),
|
|
1020
|
+
...(metadata.channel === undefined ? {} : { channel: metadata.channel }),
|
|
1021
|
+
...(metadata.license === undefined ? {} : { license: metadata.license }),
|
|
1022
|
+
...(metadata.uploadDate === undefined ? {} : { uploadDate: metadata.uploadDate }),
|
|
1023
|
+
...(metadata.timestamp === undefined ? {} : { timestamp: metadata.timestamp }),
|
|
1024
|
+
...(metadata.durationSeconds === undefined ? {} : { durationSeconds: metadata.durationSeconds }),
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function ytDlpManifestIdentity(metadata: ProbeMetadata): MediaYtDlpIdentity {
|
|
1029
|
+
if (metadata.authenticatedYtDlpIdentity !== undefined) {
|
|
1030
|
+
return metadata.authenticatedYtDlpIdentity;
|
|
1031
|
+
}
|
|
1032
|
+
if (metadata.opaqueYtDlpIdentity !== undefined) {
|
|
1033
|
+
return metadata.opaqueYtDlpIdentity;
|
|
1034
|
+
}
|
|
1035
|
+
return {
|
|
1036
|
+
profile: GHOSTGET_MEDIA_YT_DLP_YOUTUBE_IDENTITY_PROFILE,
|
|
1037
|
+
providerIdentitySha256: providerIdentitySha256("Youtube", metadata.id),
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function manifestAuthenticationMatchesRequest(
|
|
1042
|
+
manifest: MediaManifest,
|
|
1043
|
+
privateAccess: Readonly<{
|
|
1044
|
+
mode: "browser" | "ambient_config";
|
|
1045
|
+
contextSha256: string;
|
|
1046
|
+
}> | undefined,
|
|
1047
|
+
): boolean {
|
|
1048
|
+
if (privateAccess === undefined) return manifest.authentication.mode === "public";
|
|
1049
|
+
return manifest.authentication.mode === privateAccess.mode
|
|
1050
|
+
&& manifest.authentication.context.profile === AUTH_CONTEXT_IDENTITY_PROFILE
|
|
1051
|
+
&& manifest.authentication.context.sha256 === privateAccess.contextSha256;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
type YtDlpArchiveHead = Readonly<{
|
|
1055
|
+
itemDirectory: string;
|
|
1056
|
+
manifest: MediaYtDlpManifest;
|
|
1057
|
+
contentSha256: string;
|
|
1058
|
+
}>;
|
|
1059
|
+
|
|
1060
|
+
function manifestMatchesYtDlpRequest(
|
|
1061
|
+
manifest: MediaManifest,
|
|
1062
|
+
metadata: ProbeMetadata,
|
|
1063
|
+
subjectAssetKey: string,
|
|
1064
|
+
mode: CaptureMode,
|
|
1065
|
+
caption: CaptionSelection | null,
|
|
1066
|
+
privateAccess: Readonly<{
|
|
1067
|
+
mode: "browser" | "ambient_config";
|
|
1068
|
+
contextSha256: string;
|
|
1069
|
+
}> | undefined,
|
|
1070
|
+
): boolean {
|
|
1071
|
+
if (
|
|
1072
|
+
!("revision" in manifest)
|
|
1073
|
+
|| manifest.acquisition.adapter !== "yt-dlp"
|
|
1074
|
+
|| manifest.revision.subjectAssetKey !== subjectAssetKey
|
|
1075
|
+
|| manifest.source.extractor !== metadata.extractor
|
|
1076
|
+
|| manifest.source.id !== metadata.id
|
|
1077
|
+
|| !manifestAuthenticationMatchesRequest(manifest, privateAccess)
|
|
1078
|
+
|| !manifestSatisfiesMode(manifest, mode)
|
|
1079
|
+
) return false;
|
|
1080
|
+
return mode !== "transcript"
|
|
1081
|
+
|| caption === null
|
|
1082
|
+
|| manifest.transcript.status !== "available"
|
|
1083
|
+
|| (
|
|
1084
|
+
manifest.transcript.source === caption.source
|
|
1085
|
+
&& manifest.transcript.language.toLowerCase() === caption.language.toLowerCase()
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async function trackedYtDlpHead(
|
|
1090
|
+
providerDirectory: string,
|
|
1091
|
+
metadata: ProbeMetadata,
|
|
1092
|
+
lineage: RevisionLineageIdentity,
|
|
1093
|
+
mode: CaptureMode,
|
|
1094
|
+
caption: CaptionSelection | null,
|
|
1095
|
+
privateAccess: Readonly<{
|
|
1096
|
+
mode: "browser" | "ambient_config";
|
|
1097
|
+
contextSha256: string;
|
|
1098
|
+
}> | undefined,
|
|
1099
|
+
): Promise<YtDlpArchiveHead | null> {
|
|
1100
|
+
const parent = await ensurePhysicalDirectorySegments(
|
|
1101
|
+
providerDirectory,
|
|
1102
|
+
lineage.itemParentPathSegments,
|
|
1103
|
+
);
|
|
1104
|
+
const entries: Dirent[] = [];
|
|
1105
|
+
const directory = await opendir(parent);
|
|
1106
|
+
for await (const entry of directory) {
|
|
1107
|
+
if (entries.length >= MAX_TRACKED_REVISION_ITEMS) {
|
|
1108
|
+
throw new MediaArchiveError(
|
|
1109
|
+
"ARCHIVE_INVALID",
|
|
1110
|
+
"the revision history exceeds Ghostget media's supported entry limit",
|
|
1111
|
+
{
|
|
1112
|
+
revisionParent: parent,
|
|
1113
|
+
maximumEntries: MAX_TRACKED_REVISION_ITEMS,
|
|
1114
|
+
},
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
entries.push(entry);
|
|
1118
|
+
}
|
|
1119
|
+
entries.sort((left, right) => compareUtf8(left.name, right.name));
|
|
1120
|
+
if (entries.length === 0) return null;
|
|
1121
|
+
|
|
1122
|
+
const revisions: Array<{
|
|
1123
|
+
readonly itemDirectory: string;
|
|
1124
|
+
readonly manifest: MediaYtDlpManifest;
|
|
1125
|
+
}> = [];
|
|
1126
|
+
for (const entry of entries) {
|
|
1127
|
+
const leaf = parseRevisionItemLeaf(entry.name);
|
|
1128
|
+
if (
|
|
1129
|
+
leaf === null
|
|
1130
|
+
|| !entry.isDirectory()
|
|
1131
|
+
|| entry.isSymbolicLink()
|
|
1132
|
+
) {
|
|
1133
|
+
throw new MediaArchiveError(
|
|
1134
|
+
"ARCHIVE_INVALID",
|
|
1135
|
+
"the revision history contains an unowned or malformed entry",
|
|
1136
|
+
{ revisionParent: parent },
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
const itemDirectory = join(parent, entry.name);
|
|
1140
|
+
const manifest = await readExistingManifest(itemDirectory);
|
|
1141
|
+
if (
|
|
1142
|
+
!("revision" in manifest)
|
|
1143
|
+
|| manifest.acquisition.adapter !== "yt-dlp"
|
|
1144
|
+
|| manifest.revision.sequence !== leaf.sequence
|
|
1145
|
+
|| manifest.assetKey !== leaf.assetKey
|
|
1146
|
+
|| !manifestMatchesYtDlpRequest(
|
|
1147
|
+
manifest,
|
|
1148
|
+
metadata,
|
|
1149
|
+
lineage.subjectAssetKey,
|
|
1150
|
+
mode,
|
|
1151
|
+
caption,
|
|
1152
|
+
privateAccess,
|
|
1153
|
+
)
|
|
1154
|
+
) {
|
|
1155
|
+
throw new MediaArchiveError(
|
|
1156
|
+
"ARCHIVE_INVALID",
|
|
1157
|
+
"the revision history contains an inconsistent item",
|
|
1158
|
+
{ itemDirectory },
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
revisions.push({ itemDirectory, manifest });
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
let previous: MediaYtDlpManifest | null = null;
|
|
1165
|
+
for (const [index, revision] of revisions.entries()) {
|
|
1166
|
+
const expectedSequence = index + 1;
|
|
1167
|
+
const predecessorIsValid = previous === null
|
|
1168
|
+
? revision.manifest.revision.previousAssetKey === undefined
|
|
1169
|
+
: revision.manifest.revision.previousAssetKey === previous.assetKey;
|
|
1170
|
+
if (
|
|
1171
|
+
revision.manifest.revision.sequence !== expectedSequence
|
|
1172
|
+
|| !predecessorIsValid
|
|
1173
|
+
) {
|
|
1174
|
+
throw new MediaArchiveError(
|
|
1175
|
+
"ARCHIVE_INVALID",
|
|
1176
|
+
"the revision history is gapped, forked, or has a broken predecessor",
|
|
1177
|
+
{ revisionParent: parent },
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
previous = revision.manifest;
|
|
1181
|
+
}
|
|
1182
|
+
const head = revisions.at(-1);
|
|
1183
|
+
if (head === undefined) return null;
|
|
1184
|
+
return {
|
|
1185
|
+
itemDirectory: head.itemDirectory,
|
|
1186
|
+
manifest: head.manifest,
|
|
1187
|
+
contentSha256: head.manifest.revision.content.sha256,
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
async function discoverYtDlpHead(
|
|
1192
|
+
providerDirectory: string,
|
|
1193
|
+
metadata: ProbeMetadata,
|
|
1194
|
+
lineage: RevisionLineageIdentity,
|
|
1195
|
+
mode: CaptureMode,
|
|
1196
|
+
caption: CaptionSelection | null,
|
|
1197
|
+
privateAccess: Readonly<{
|
|
1198
|
+
mode: "browser" | "ambient_config";
|
|
1199
|
+
contextSha256: string;
|
|
1200
|
+
}> | undefined,
|
|
1201
|
+
): Promise<YtDlpArchiveHead | null> {
|
|
1202
|
+
return await trackedYtDlpHead(
|
|
1203
|
+
providerDirectory,
|
|
1204
|
+
metadata,
|
|
1205
|
+
lineage,
|
|
1206
|
+
mode,
|
|
1207
|
+
caption,
|
|
1208
|
+
privateAccess,
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function redactedArchiveDetails(
|
|
1213
|
+
details: Readonly<Record<string, unknown>>,
|
|
1214
|
+
secrets: readonly string[],
|
|
1215
|
+
): Readonly<Record<string, unknown>> {
|
|
1216
|
+
try {
|
|
1217
|
+
const rendered = redactDiagnostic(JSON.stringify(details), { secrets });
|
|
1218
|
+
const parsed: unknown = JSON.parse(rendered);
|
|
1219
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
1220
|
+
? parsed as Readonly<Record<string, unknown>>
|
|
1221
|
+
: {};
|
|
1222
|
+
} catch {
|
|
1223
|
+
return {};
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1228
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
interface OwnedAdapterFailure {
|
|
1232
|
+
readonly code?: string;
|
|
1233
|
+
readonly message: string;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function ownedDirectProbeFailure(code: unknown): OwnedAdapterFailure {
|
|
1237
|
+
switch (code) {
|
|
1238
|
+
case "invalid-request":
|
|
1239
|
+
return { code, message: "direct HTTP probe request is invalid" };
|
|
1240
|
+
case "redirect-policy":
|
|
1241
|
+
case "too-many-redirects":
|
|
1242
|
+
return { code, message: "direct HTTP probe rejected the redirect policy" };
|
|
1243
|
+
case "timeout":
|
|
1244
|
+
return { code, message: "direct HTTP probe timed out" };
|
|
1245
|
+
case "aborted":
|
|
1246
|
+
return { code, message: "direct HTTP probe was aborted" };
|
|
1247
|
+
case "network":
|
|
1248
|
+
return { code, message: "direct HTTP probe failed on the network" };
|
|
1249
|
+
case "unsupported-content-encoding":
|
|
1250
|
+
return { code, message: "direct HTTP probe received an unsupported content encoding" };
|
|
1251
|
+
case "invalid-response-length":
|
|
1252
|
+
return { code, message: "direct HTTP probe received inconsistent response lengths" };
|
|
1253
|
+
case "invalid-content-type":
|
|
1254
|
+
return { code, message: "direct HTTP probe received a malformed media type" };
|
|
1255
|
+
case "body-too-large":
|
|
1256
|
+
return { code, message: "direct HTTP probe exceeded the body limit" };
|
|
1257
|
+
case "response-read":
|
|
1258
|
+
return { code, message: "direct HTTP probe could not read the response" };
|
|
1259
|
+
default:
|
|
1260
|
+
return { message: "direct HTTP probe adapter returned an invalid failure" };
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function ownedDirectCaptureFailure(code: unknown): OwnedAdapterFailure {
|
|
1265
|
+
switch (code) {
|
|
1266
|
+
case "invalid-request":
|
|
1267
|
+
return { code, message: "direct HTTP capture request is invalid" };
|
|
1268
|
+
case "aborted":
|
|
1269
|
+
return { code, message: "direct HTTP capture was cancelled" };
|
|
1270
|
+
case "transport":
|
|
1271
|
+
return { code, message: "direct HTTP capture transport failed" };
|
|
1272
|
+
case "http-status":
|
|
1273
|
+
return { code, message: "direct HTTP capture returned an unsupported status" };
|
|
1274
|
+
case "unsupported-content-encoding":
|
|
1275
|
+
return { code, message: "direct HTTP capture received an unsupported content encoding" };
|
|
1276
|
+
case "invalid-response-length":
|
|
1277
|
+
return { code, message: "direct HTTP capture received inconsistent response lengths" };
|
|
1278
|
+
case "body-too-large":
|
|
1279
|
+
return { code, message: "direct HTTP capture exceeded the body limit" };
|
|
1280
|
+
case "invalid-content-type":
|
|
1281
|
+
return { code, message: "direct HTTP capture received a malformed media type" };
|
|
1282
|
+
case "declared-text":
|
|
1283
|
+
return { code, message: "direct HTTP capture received a declared text response" };
|
|
1284
|
+
case "response-read":
|
|
1285
|
+
return { code, message: "direct HTTP capture could not read the response" };
|
|
1286
|
+
case "total-timeout":
|
|
1287
|
+
return { code, message: "direct HTTP capture exceeded its total timeout" };
|
|
1288
|
+
case "sink":
|
|
1289
|
+
return { code, message: "direct HTTP capture could not write its destination" };
|
|
1290
|
+
case "media-unrecognized":
|
|
1291
|
+
return { code, message: "direct HTTP capture did not contain recognized media" };
|
|
1292
|
+
case "media-changed":
|
|
1293
|
+
return { code, message: "direct HTTP media changed after its probe" };
|
|
1294
|
+
default:
|
|
1295
|
+
return { message: "direct HTTP capture adapter returned an invalid failure" };
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function parseDirectCaptureSuccess(
|
|
1300
|
+
value: unknown,
|
|
1301
|
+
route: DirectHttpCaptureRoute,
|
|
1302
|
+
ownedMedia: DirectHttpMedia,
|
|
1303
|
+
): DirectHttpCapture | null {
|
|
1304
|
+
if (!isRecord(value) || value["ok"] !== true || !isRecord(value["capture"])) return null;
|
|
1305
|
+
const capture = value["capture"];
|
|
1306
|
+
const provenance = parseMediaDirectHttpProvenance(capture["provenance"]);
|
|
1307
|
+
const media = capture["media"];
|
|
1308
|
+
const bytes = capture["bytes"];
|
|
1309
|
+
const sha256 = capture["sha256"];
|
|
1310
|
+
const attempts = capture["attempts"];
|
|
1311
|
+
const resumed = capture["resumed"];
|
|
1312
|
+
if (
|
|
1313
|
+
provenance === null
|
|
1314
|
+
|| !isRecord(media)
|
|
1315
|
+
|| media["container"] !== ownedMedia.container
|
|
1316
|
+
|| media["extension"] !== ownedMedia.extension
|
|
1317
|
+
|| media["mediaType"] !== ownedMedia.mediaType
|
|
1318
|
+
|| typeof bytes !== "number"
|
|
1319
|
+
|| !Number.isSafeInteger(bytes)
|
|
1320
|
+
|| bytes < 0
|
|
1321
|
+
|| typeof sha256 !== "string"
|
|
1322
|
+
|| provenance.requestedUrlSha256 !== route.requestUrlSha256
|
|
1323
|
+
|| provenance.container !== ownedMedia.container
|
|
1324
|
+
|| provenance.body.bytes !== bytes
|
|
1325
|
+
|| provenance.body.sha256 !== sha256
|
|
1326
|
+
|| typeof attempts !== "number"
|
|
1327
|
+
|| !Number.isSafeInteger(attempts)
|
|
1328
|
+
|| attempts < 1
|
|
1329
|
+
|| attempts > 10
|
|
1330
|
+
|| typeof resumed !== "boolean"
|
|
1331
|
+
) return null;
|
|
1332
|
+
return {
|
|
1333
|
+
bytes,
|
|
1334
|
+
sha256,
|
|
1335
|
+
media: ownedMedia,
|
|
1336
|
+
provenance,
|
|
1337
|
+
attempts,
|
|
1338
|
+
resumed,
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function isDirectHttpProbeBoundary(value: unknown): value is DirectHttpProbe {
|
|
1343
|
+
if (!isRecord(value) || !isRecord(value["media"])) return false;
|
|
1344
|
+
const media = value["media"];
|
|
1345
|
+
return value["transport"] instanceof DirectHttpProbeTransport
|
|
1346
|
+
&& typeof value["publicOrigin"] === "string"
|
|
1347
|
+
&& typeof value["requestedUrlSha256"] === "string"
|
|
1348
|
+
&& typeof value["effectiveUrlSha256"] === "string"
|
|
1349
|
+
&& typeof value["redirectCount"] === "number"
|
|
1350
|
+
&& Number.isSafeInteger(value["redirectCount"])
|
|
1351
|
+
&& value["redirectCount"] >= 0
|
|
1352
|
+
&& value["redirectCount"] <= 5
|
|
1353
|
+
&& (value["declaredMediaType"] === null || typeof value["declaredMediaType"] === "string")
|
|
1354
|
+
&& (value["lastModified"] === null || typeof value["lastModified"] === "string")
|
|
1355
|
+
&& (value["expectedBytes"] === null
|
|
1356
|
+
|| (typeof value["expectedBytes"] === "number"
|
|
1357
|
+
&& Number.isSafeInteger(value["expectedBytes"])
|
|
1358
|
+
&& value["expectedBytes"] >= 0))
|
|
1359
|
+
&& typeof media["container"] === "string"
|
|
1360
|
+
&& typeof media["extension"] === "string"
|
|
1361
|
+
&& typeof media["mediaType"] === "string";
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const DIRECT_SINK_SETTLE_TIMEOUT_MS = 5_000;
|
|
1365
|
+
|
|
1366
|
+
async function settleDirectSink(
|
|
1367
|
+
sink: DirectHttpCaptureSink,
|
|
1368
|
+
action: "abort" | "close",
|
|
1369
|
+
): Promise<"settled" | "failed" | "timeout"> {
|
|
1370
|
+
const controller = new AbortController();
|
|
1371
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1372
|
+
try {
|
|
1373
|
+
let operation: Promise<void>;
|
|
1374
|
+
try {
|
|
1375
|
+
operation = sink[action](controller.signal);
|
|
1376
|
+
} catch {
|
|
1377
|
+
return "failed";
|
|
1378
|
+
}
|
|
1379
|
+
return await Promise.race([
|
|
1380
|
+
operation.then(
|
|
1381
|
+
() => "settled" as const,
|
|
1382
|
+
() => "failed" as const,
|
|
1383
|
+
),
|
|
1384
|
+
new Promise<"timeout">((resolveTimeout) => {
|
|
1385
|
+
timer = setTimeout(() => resolveTimeout("timeout"), DIRECT_SINK_SETTLE_TIMEOUT_MS);
|
|
1386
|
+
}),
|
|
1387
|
+
]);
|
|
1388
|
+
} finally {
|
|
1389
|
+
controller.abort();
|
|
1390
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
async function createDirectCaptureFileSink(path: string): Promise<DirectHttpCaptureSink> {
|
|
1395
|
+
const handle = await open(path, "wx", 0o600);
|
|
1396
|
+
const opened = await handle.stat();
|
|
1397
|
+
let position = 0;
|
|
1398
|
+
let closed = false;
|
|
1399
|
+
|
|
1400
|
+
const closeHandle = async (): Promise<void> => {
|
|
1401
|
+
if (closed) return;
|
|
1402
|
+
await handle.close();
|
|
1403
|
+
closed = true;
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1406
|
+
return {
|
|
1407
|
+
write: async (chunk) => {
|
|
1408
|
+
if (closed) throw new Error("direct capture sink is closed");
|
|
1409
|
+
let offset = 0;
|
|
1410
|
+
while (offset < chunk.byteLength) {
|
|
1411
|
+
const written = await handle.write(
|
|
1412
|
+
chunk,
|
|
1413
|
+
offset,
|
|
1414
|
+
chunk.byteLength - offset,
|
|
1415
|
+
position,
|
|
1416
|
+
);
|
|
1417
|
+
if (written.bytesWritten <= 0) throw new Error("direct capture sink made no progress");
|
|
1418
|
+
offset += written.bytesWritten;
|
|
1419
|
+
position += written.bytesWritten;
|
|
1420
|
+
}
|
|
1421
|
+
},
|
|
1422
|
+
restart: async () => {
|
|
1423
|
+
if (closed) throw new Error("direct capture sink is closed");
|
|
1424
|
+
await handle.truncate(0);
|
|
1425
|
+
position = 0;
|
|
1426
|
+
},
|
|
1427
|
+
close: async () => {
|
|
1428
|
+
if (closed) return;
|
|
1429
|
+
await handle.sync();
|
|
1430
|
+
await closeHandle();
|
|
1431
|
+
},
|
|
1432
|
+
abort: async () => {
|
|
1433
|
+
try {
|
|
1434
|
+
await closeHandle();
|
|
1435
|
+
} catch {
|
|
1436
|
+
// The pathname identity check below still prevents deleting a swap.
|
|
1437
|
+
}
|
|
1438
|
+
try {
|
|
1439
|
+
const current = await lstat(path);
|
|
1440
|
+
if (
|
|
1441
|
+
current.isFile()
|
|
1442
|
+
&& !current.isSymbolicLink()
|
|
1443
|
+
&& current.dev === opened.dev
|
|
1444
|
+
&& current.ino === opened.ino
|
|
1445
|
+
) {
|
|
1446
|
+
await unlink(path);
|
|
1447
|
+
}
|
|
1448
|
+
} catch (error) {
|
|
1449
|
+
if (!isErrno(error, "ENOENT")) throw error;
|
|
1450
|
+
}
|
|
1451
|
+
},
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function directManifestMatches(
|
|
1456
|
+
manifest: MediaManifest,
|
|
1457
|
+
metadata: ProbeMetadata,
|
|
1458
|
+
request: CaptureIdentityRequest,
|
|
1459
|
+
probe: DirectHttpProbe,
|
|
1460
|
+
captureArtifact: MediaArtifact,
|
|
1461
|
+
): boolean {
|
|
1462
|
+
const recordedCapture = manifest.artifacts.find((artifact) => artifact.role === "capture");
|
|
1463
|
+
return manifest.acquisition.adapter === "direct-http"
|
|
1464
|
+
&& manifest.authentication.mode === "public"
|
|
1465
|
+
&& manifest.assetKey === captureIdentity(metadata, request).assetKey
|
|
1466
|
+
&& manifest.source.extractor === metadata.extractor
|
|
1467
|
+
&& manifest.source.id === metadata.id
|
|
1468
|
+
&& manifest.source.canonicalUrl === metadata.canonicalUrl
|
|
1469
|
+
&& manifest.acquisition.provenance.requestedUrlSha256 === probe.requestedUrlSha256
|
|
1470
|
+
&& manifest.acquisition.provenance.body.bytes === captureArtifact.bytes
|
|
1471
|
+
&& manifest.acquisition.provenance.body.sha256 === captureArtifact.sha256
|
|
1472
|
+
&& recordedCapture?.bytes === captureArtifact.bytes
|
|
1473
|
+
&& recordedCapture.sha256 === captureArtifact.sha256
|
|
1474
|
+
&& manifestSatisfiesMode(manifest, request.mode);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function directProbeMedia(probe: DirectHttpProbe): DirectHttpMedia | null {
|
|
1478
|
+
try {
|
|
1479
|
+
const owned = directHttpMediaForContainer(probe.media.container);
|
|
1480
|
+
return owned !== undefined
|
|
1481
|
+
&& probe.media.extension === owned.extension
|
|
1482
|
+
&& probe.media.mediaType === owned.mediaType
|
|
1483
|
+
? owned
|
|
1484
|
+
: null;
|
|
1485
|
+
} catch {
|
|
1486
|
+
return null;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
async function mediaDirectHttp(
|
|
1491
|
+
options: MediaArchiveOptions,
|
|
1492
|
+
route: DirectHttpCaptureRoute,
|
|
1493
|
+
probe: DirectHttpProbe,
|
|
1494
|
+
dependencies: MediaArchiveDependencies,
|
|
1495
|
+
): Promise<MediaArchiveResult> {
|
|
1496
|
+
const probeRequestUrl = probe.transport instanceof DirectHttpProbeTransport
|
|
1497
|
+
? probe.transport.requestUrl()
|
|
1498
|
+
: null;
|
|
1499
|
+
const ownedMedia = directProbeMedia(probe);
|
|
1500
|
+
if (
|
|
1501
|
+
probeRequestUrl !== route.requestUrl
|
|
1502
|
+
|| probe.requestedUrlSha256 !== route.requestUrlSha256
|
|
1503
|
+
|| probe.publicOrigin !== `${new URL(route.requestUrl).origin}/`
|
|
1504
|
+
|| ownedMedia === null
|
|
1505
|
+
) {
|
|
1506
|
+
throw new MediaArchiveError(
|
|
1507
|
+
"PROBE_FAILED",
|
|
1508
|
+
"direct HTTP probe returned an inconsistent request identity",
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
let localPlan: LocalTranscriptionPlan | null = route.mode === "transcript"
|
|
1512
|
+
? requireFocusedLocalPlan(await loadLocalTranscriptionPlan(options, dependencies))
|
|
1513
|
+
: null;
|
|
1514
|
+
const requestedRoot = libraryDirectory(options);
|
|
1515
|
+
await ensurePhysicalDirectory(requestedRoot);
|
|
1516
|
+
const root = await realpath(requestedRoot);
|
|
1517
|
+
const stagingRoot = join(root, ".wrench-media-staging");
|
|
1518
|
+
const lockRoot = join(root, ".wrench-media-locks");
|
|
1519
|
+
await ensurePhysicalChildDirectory(stagingRoot);
|
|
1520
|
+
await ensurePhysicalChildDirectory(lockRoot);
|
|
1521
|
+
const provisionalSegments = [
|
|
1522
|
+
DIRECT_HTTP_CAPTURE_NAMESPACE,
|
|
1523
|
+
route.requestUrlSha256,
|
|
1524
|
+
] as const;
|
|
1525
|
+
const provisionalIdentity = splitIdentityLeaf(provisionalSegments);
|
|
1526
|
+
const stagingParent = await ensurePhysicalDirectorySegments(
|
|
1527
|
+
stagingRoot,
|
|
1528
|
+
provisionalIdentity.parentSegments,
|
|
1529
|
+
);
|
|
1530
|
+
const lockParent = await ensurePhysicalDirectorySegments(
|
|
1531
|
+
lockRoot,
|
|
1532
|
+
provisionalIdentity.parentSegments,
|
|
1533
|
+
);
|
|
1534
|
+
const stagingItem = join(stagingParent, provisionalIdentity.leaf);
|
|
1535
|
+
const lockPath = join(lockParent, `${provisionalIdentity.leaf}.lock`);
|
|
1536
|
+
let itemLock: ItemLock;
|
|
1537
|
+
try {
|
|
1538
|
+
itemLock = await acquireItemLock(lockPath);
|
|
1539
|
+
} catch (error) {
|
|
1540
|
+
if (error instanceof ItemLockBusyError) {
|
|
1541
|
+
throw new MediaArchiveError("BUSY", error.message, { lockPath });
|
|
1542
|
+
}
|
|
1543
|
+
throw error;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
const sourceDiagnosticSecrets = [
|
|
1547
|
+
options.url,
|
|
1548
|
+
route.requestUrl,
|
|
1549
|
+
...urlDerivedRedactions(options.url),
|
|
1550
|
+
...urlDerivedRedactions(route.requestUrl),
|
|
1551
|
+
];
|
|
1552
|
+
try {
|
|
1553
|
+
await resetStagingItem(stagingItem);
|
|
1554
|
+
for (const directory of [
|
|
1555
|
+
join(stagingItem, "data"),
|
|
1556
|
+
join(stagingItem, "data", "capture"),
|
|
1557
|
+
join(stagingItem, "data", "derivatives"),
|
|
1558
|
+
join(stagingItem, "data", "captions"),
|
|
1559
|
+
join(stagingItem, "data", "metadata"),
|
|
1560
|
+
join(stagingItem, ".tmp"),
|
|
1561
|
+
]) await ensurePhysicalDirectory(directory);
|
|
1562
|
+
|
|
1563
|
+
const capturePath = join(
|
|
1564
|
+
stagingItem,
|
|
1565
|
+
"data",
|
|
1566
|
+
"capture",
|
|
1567
|
+
`media.${ownedMedia.extension}`,
|
|
1568
|
+
);
|
|
1569
|
+
const sink = await createDirectCaptureFileSink(capturePath);
|
|
1570
|
+
let directValue: unknown;
|
|
1571
|
+
try {
|
|
1572
|
+
directValue = await dependencies.captureDirectHttp(
|
|
1573
|
+
probe,
|
|
1574
|
+
sink,
|
|
1575
|
+
options.signal === undefined ? {} : { signal: options.signal },
|
|
1576
|
+
);
|
|
1577
|
+
} catch {
|
|
1578
|
+
await settleDirectSink(sink, "abort");
|
|
1579
|
+
if (isCancelled(options.signal)) {
|
|
1580
|
+
throw new MediaArchiveError("CANCELLED", "direct HTTP capture was cancelled");
|
|
1581
|
+
}
|
|
1582
|
+
throw new MediaArchiveError(
|
|
1583
|
+
"CAPTURE_FAILED",
|
|
1584
|
+
"direct HTTP capture adapter failed unexpectedly",
|
|
1585
|
+
{ stagingDirectory: stagingItem },
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
if (isRecord(directValue) && directValue["ok"] === false) {
|
|
1589
|
+
await settleDirectSink(sink, "abort");
|
|
1590
|
+
const error = isRecord(directValue["error"])
|
|
1591
|
+
? ownedDirectCaptureFailure(directValue["error"]["code"])
|
|
1592
|
+
: ownedDirectCaptureFailure(undefined);
|
|
1593
|
+
if (error.code === "aborted") {
|
|
1594
|
+
throw new MediaArchiveError("CANCELLED", error.message);
|
|
1595
|
+
}
|
|
1596
|
+
throw new MediaArchiveError(
|
|
1597
|
+
"CAPTURE_FAILED",
|
|
1598
|
+
error.message,
|
|
1599
|
+
{
|
|
1600
|
+
...(error.code === undefined ? {} : { captureCode: error.code }),
|
|
1601
|
+
stagingDirectory: stagingItem,
|
|
1602
|
+
},
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
const direct = parseDirectCaptureSuccess(directValue, route, ownedMedia);
|
|
1606
|
+
if (direct === null) {
|
|
1607
|
+
await settleDirectSink(sink, "abort");
|
|
1608
|
+
throw new MediaArchiveError(
|
|
1609
|
+
"CAPTURE_FAILED",
|
|
1610
|
+
"direct HTTP capture returned inconsistent provenance",
|
|
1611
|
+
{ stagingDirectory: stagingItem },
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
if (await settleDirectSink(sink, "close") !== "settled") {
|
|
1615
|
+
await settleDirectSink(sink, "abort");
|
|
1616
|
+
throw new MediaArchiveError(
|
|
1617
|
+
"CAPTURE_FAILED",
|
|
1618
|
+
"direct HTTP capture could not finalize its destination",
|
|
1619
|
+
{ stagingDirectory: stagingItem },
|
|
1620
|
+
);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
const hashedCaptureArtifact = await createMediaArtifact(
|
|
1624
|
+
stagingItem,
|
|
1625
|
+
relativeArtifactPath(stagingItem, capturePath),
|
|
1626
|
+
"capture",
|
|
1627
|
+
);
|
|
1628
|
+
const captureArtifact: MediaArtifact = {
|
|
1629
|
+
...hashedCaptureArtifact,
|
|
1630
|
+
mediaType: ownedMedia.mediaType,
|
|
1631
|
+
};
|
|
1632
|
+
if (
|
|
1633
|
+
captureArtifact.bytes !== direct.bytes
|
|
1634
|
+
|| captureArtifact.sha256 !== direct.sha256
|
|
1635
|
+
) {
|
|
1636
|
+
await settleDirectSink(sink, "abort");
|
|
1637
|
+
throw new MediaArchiveError(
|
|
1638
|
+
"CAPTURE_FAILED",
|
|
1639
|
+
"direct HTTP capture bytes disagree with the completed file",
|
|
1640
|
+
{ stagingDirectory: stagingItem },
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
const metadata = createDirectHttpMetadata({
|
|
1644
|
+
requestedOrigin: probe.publicOrigin,
|
|
1645
|
+
requestedUrlSha256: route.requestUrlSha256,
|
|
1646
|
+
bodySha256: captureArtifact.sha256,
|
|
1647
|
+
});
|
|
1648
|
+
const identityRequest: CaptureIdentityRequest = route.mode === "transcript"
|
|
1649
|
+
? {
|
|
1650
|
+
mode: "transcript",
|
|
1651
|
+
transcript: {
|
|
1652
|
+
kind: "local",
|
|
1653
|
+
identity: requireFocusedLocalPlan(localPlan ?? { kind: "not-configured" }).identity,
|
|
1654
|
+
},
|
|
1655
|
+
}
|
|
1656
|
+
: { mode: route.mode };
|
|
1657
|
+
const identity = captureIdentity(metadata, identityRequest);
|
|
1658
|
+
const providerDirectory = join(root, metadata.extractorDirectory);
|
|
1659
|
+
await ensurePhysicalChildDirectory(providerDirectory);
|
|
1660
|
+
const itemIdentity = splitIdentityLeaf(identity.itemPathSegments);
|
|
1661
|
+
const itemParentDirectory = await ensurePhysicalDirectorySegments(
|
|
1662
|
+
providerDirectory,
|
|
1663
|
+
itemIdentity.parentSegments,
|
|
1664
|
+
);
|
|
1665
|
+
const itemDirectory = join(itemParentDirectory, itemIdentity.leaf);
|
|
1666
|
+
try {
|
|
1667
|
+
const existing = await lstat(itemDirectory);
|
|
1668
|
+
if (!existing.isDirectory() || existing.isSymbolicLink()) {
|
|
1669
|
+
throw new MediaArchiveError(
|
|
1670
|
+
"ARCHIVE_CONFLICT",
|
|
1671
|
+
"the direct item path already exists but is not a physical directory",
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
const manifest = await readExistingManifest(itemDirectory);
|
|
1675
|
+
if (!directManifestMatches(manifest, metadata, identityRequest, probe, captureArtifact)) {
|
|
1676
|
+
throw new MediaArchiveError(
|
|
1677
|
+
"ARCHIVE_CONFLICT",
|
|
1678
|
+
"the existing direct item belongs to a different source or capture identity",
|
|
1679
|
+
{ itemDirectory },
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
await discardStagingItem(stagingItem);
|
|
1683
|
+
return { status: "existing", itemDirectory, manifest, warnings: [] };
|
|
1684
|
+
} catch (error) {
|
|
1685
|
+
if (!isErrno(error, "ENOENT")) throw error;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
if (route.mode === "archive") {
|
|
1689
|
+
localPlan = await loadLocalTranscriptionPlan(options, dependencies);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
const ffmpegExecutable = await requireExecutable("ffmpeg", options, dependencies);
|
|
1693
|
+
const ffprobeExecutable = await requireExecutable("ffprobe", options, dependencies);
|
|
1694
|
+
const providerMetadataPath = await writeProviderMetadata(stagingItem, metadata);
|
|
1695
|
+
const artifacts: MediaArtifact[] = [captureArtifact];
|
|
1696
|
+
await addArtifact(artifacts, stagingItem, providerMetadataPath, "provider_metadata");
|
|
1697
|
+
const derivatives = await dependencies.derive({
|
|
1698
|
+
capturePath,
|
|
1699
|
+
derivativesDirectory: join(stagingItem, "data", "derivatives"),
|
|
1700
|
+
roles: route.mode === "transcript"
|
|
1701
|
+
? ["audio"]
|
|
1702
|
+
: requestedDerivativeRoles(route.mode),
|
|
1703
|
+
ffmpegExecutable,
|
|
1704
|
+
ffprobeExecutable,
|
|
1705
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
1706
|
+
});
|
|
1707
|
+
if (isCancelled(options.signal)) {
|
|
1708
|
+
throw new MediaArchiveError("CANCELLED", "media derivation was cancelled");
|
|
1709
|
+
}
|
|
1710
|
+
const failed = [derivatives.video, derivatives.audio]
|
|
1711
|
+
.filter((result) => result.status === "failed");
|
|
1712
|
+
if (failed.length > 0) {
|
|
1713
|
+
throw new MediaArchiveError(
|
|
1714
|
+
"DERIVATION_FAILED",
|
|
1715
|
+
"FFmpeg could not create every available direct-media derivative",
|
|
1716
|
+
{
|
|
1717
|
+
failures: failed.map((result) => result.status === "failed"
|
|
1718
|
+
? { role: result.role, stage: result.stage, diagnostic: result.diagnostic }
|
|
1719
|
+
: null),
|
|
1720
|
+
stagingDirectory: stagingItem,
|
|
1721
|
+
},
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
if (derivatives.video.status === "created" || derivatives.video.status === "exists") {
|
|
1725
|
+
await addArtifact(artifacts, stagingItem, derivatives.video.path, "video");
|
|
1726
|
+
}
|
|
1727
|
+
if (derivatives.audio.status === "created" || derivatives.audio.status === "exists") {
|
|
1728
|
+
await addArtifact(artifacts, stagingItem, derivatives.audio.path, "audio");
|
|
1729
|
+
}
|
|
1730
|
+
const transcript = route.mode === "archive" || route.mode === "transcript"
|
|
1731
|
+
? await localTranscriptArtifacts(
|
|
1732
|
+
stagingItem,
|
|
1733
|
+
route.mode,
|
|
1734
|
+
localPlan ?? { kind: "not-configured" },
|
|
1735
|
+
artifacts,
|
|
1736
|
+
ffmpegExecutable,
|
|
1737
|
+
options,
|
|
1738
|
+
dependencies,
|
|
1739
|
+
)
|
|
1740
|
+
: unavailableTranscript("not_requested");
|
|
1741
|
+
artifacts.push(...transcript.artifacts);
|
|
1742
|
+
const [ffmpegVersion, ffprobeVersion] = await Promise.all([
|
|
1743
|
+
dependencies.ffmpegVersion(ffmpegExecutable),
|
|
1744
|
+
dependencies.ffmpegVersion(ffprobeExecutable),
|
|
1745
|
+
]);
|
|
1746
|
+
const manifest: MediaManifest = {
|
|
1747
|
+
schemaVersion: GHOSTGET_MEDIA_SCHEMA_VERSION,
|
|
1748
|
+
wrenchVersion: GHOSTGET_MEDIA_VERSION,
|
|
1749
|
+
assetKey: identity.assetKey,
|
|
1750
|
+
capturedAt: dependencies.now().toISOString(),
|
|
1751
|
+
mode: route.mode,
|
|
1752
|
+
source: sourceManifest(metadata),
|
|
1753
|
+
authentication: { mode: "public" },
|
|
1754
|
+
acquisition: {
|
|
1755
|
+
adapter: "direct-http",
|
|
1756
|
+
provenance: direct.provenance,
|
|
1757
|
+
},
|
|
1758
|
+
tools: { ffmpeg: ffmpegVersion, ffprobe: ffprobeVersion },
|
|
1759
|
+
artifacts,
|
|
1760
|
+
transcript: transcript.transcript,
|
|
1761
|
+
};
|
|
1762
|
+
assertManifestSatisfiesMode(manifest, route.mode);
|
|
1763
|
+
await rm(join(stagingItem, ".tmp"), { recursive: true, force: true });
|
|
1764
|
+
const written = await writeMediaManifest(stagingItem, manifest);
|
|
1765
|
+
await hardenTree(stagingItem);
|
|
1766
|
+
const stagedVerification = await verifyMediaItem(stagingItem);
|
|
1767
|
+
if (!stagedVerification.ok) {
|
|
1768
|
+
throw new MediaArchiveError(
|
|
1769
|
+
"ARCHIVE_INVALID",
|
|
1770
|
+
"the staged direct item failed its closed integrity verification",
|
|
1771
|
+
{ failures: stagedVerification.failures, stagingDirectory: stagingItem },
|
|
1772
|
+
);
|
|
1773
|
+
}
|
|
1774
|
+
try {
|
|
1775
|
+
await itemLock.assertOwned();
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
if (error instanceof ItemLockLostError) {
|
|
1778
|
+
throw new MediaArchiveError("BUSY", error.message, { lockPath });
|
|
1779
|
+
}
|
|
1780
|
+
throw error;
|
|
1781
|
+
}
|
|
1782
|
+
const promotionParentDirectory = await ensurePhysicalDirectorySegments(
|
|
1783
|
+
providerDirectory,
|
|
1784
|
+
itemIdentity.parentSegments,
|
|
1785
|
+
);
|
|
1786
|
+
if (promotionParentDirectory !== itemParentDirectory) {
|
|
1787
|
+
throw new MediaArchiveError("IO_ERROR", "direct archive promotion parent changed unexpectedly");
|
|
1788
|
+
}
|
|
1789
|
+
try {
|
|
1790
|
+
await lstat(itemDirectory);
|
|
1791
|
+
throw new MediaArchiveError(
|
|
1792
|
+
"ARCHIVE_CONFLICT",
|
|
1793
|
+
"the completed direct item path appeared during capture",
|
|
1794
|
+
);
|
|
1795
|
+
} catch (error) {
|
|
1796
|
+
if (!isErrno(error, "ENOENT")) throw error;
|
|
1797
|
+
}
|
|
1798
|
+
await rename(stagingItem, itemDirectory);
|
|
1799
|
+
return {
|
|
1800
|
+
status: "created",
|
|
1801
|
+
itemDirectory,
|
|
1802
|
+
manifest: written,
|
|
1803
|
+
warnings: route.mode === "archive"
|
|
1804
|
+
? archiveTranscriptWarnings(written.transcript)
|
|
1805
|
+
: [],
|
|
1806
|
+
};
|
|
1807
|
+
} catch (error) {
|
|
1808
|
+
try {
|
|
1809
|
+
await itemLock.assertOwned();
|
|
1810
|
+
await discardStagingItem(stagingItem);
|
|
1811
|
+
} catch {
|
|
1812
|
+
// Preserve the primary failure. A lost lock means another owner controls
|
|
1813
|
+
// this staging identity; a failed discard is retried on the next run.
|
|
1814
|
+
}
|
|
1815
|
+
if (error instanceof MediaArchiveError) {
|
|
1816
|
+
throw new MediaArchiveError(
|
|
1817
|
+
error.code,
|
|
1818
|
+
redactDiagnostic(error.message, { secrets: sourceDiagnosticSecrets }),
|
|
1819
|
+
redactedArchiveDetails(error.details, sourceDiagnosticSecrets),
|
|
1820
|
+
);
|
|
1821
|
+
}
|
|
1822
|
+
throw new MediaArchiveError(
|
|
1823
|
+
"IO_ERROR",
|
|
1824
|
+
redactDiagnostic(
|
|
1825
|
+
error instanceof Error ? error.message : "direct archive pipeline failed",
|
|
1826
|
+
{ secrets: sourceDiagnosticSecrets },
|
|
1827
|
+
),
|
|
1828
|
+
{ stagingDirectory: stagingItem },
|
|
1829
|
+
);
|
|
1830
|
+
} finally {
|
|
1831
|
+
try {
|
|
1832
|
+
await itemLock.release();
|
|
1833
|
+
} catch {
|
|
1834
|
+
// A dead owner is reclaimed safely on the next run.
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
async function mediaWithYtDlp(
|
|
1840
|
+
options: MediaArchiveOptions,
|
|
1841
|
+
dependencies: MediaArchiveDependencies,
|
|
1842
|
+
): Promise<MediaArchiveResult> {
|
|
1843
|
+
const requestedRoot = libraryDirectory(options);
|
|
1844
|
+
await ensurePhysicalDirectory(requestedRoot);
|
|
1845
|
+
const root = await realpath(requestedRoot);
|
|
1846
|
+
const ytDlpExecutable = await requireExecutable("yt-dlp", options, dependencies);
|
|
1847
|
+
const privateAccess = options.authContext === undefined
|
|
1848
|
+
? undefined
|
|
1849
|
+
: {
|
|
1850
|
+
mode: options.browser === undefined ? "ambient_config" as const : "browser" as const,
|
|
1851
|
+
contextSha256: authContextSha256(options.authContext),
|
|
1852
|
+
};
|
|
1853
|
+
let probe: YtDlpProbeResult;
|
|
1854
|
+
try {
|
|
1855
|
+
probe = await dependencies.probe({
|
|
1856
|
+
executable: ytDlpExecutable,
|
|
1857
|
+
url: options.url,
|
|
1858
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
1859
|
+
...(options.browser === undefined ? {} : { browser: options.browser }),
|
|
1860
|
+
...(privateAccess === undefined
|
|
1861
|
+
? {}
|
|
1862
|
+
: { authContextSha256: privateAccess.contextSha256 }),
|
|
1863
|
+
inheritConfig: options.inheritYtDlpConfig,
|
|
1864
|
+
});
|
|
1865
|
+
} catch {
|
|
1866
|
+
if (isCancelled(options.signal)) {
|
|
1867
|
+
throw new MediaArchiveError("CANCELLED", "yt-dlp probe was cancelled");
|
|
1868
|
+
}
|
|
1869
|
+
throw new MediaArchiveError("PROBE_FAILED", "yt-dlp probe adapter failed unexpectedly");
|
|
1870
|
+
}
|
|
1871
|
+
if (!probe.ok) {
|
|
1872
|
+
if ("kind" in probe) {
|
|
1873
|
+
throw new MediaArchiveError("UNSUPPORTED_SOURCE", probe.message, {
|
|
1874
|
+
reason: probe.reason,
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
if ("processReason" in probe && probe.processReason === "aborted") {
|
|
1878
|
+
throw new MediaArchiveError("CANCELLED", "yt-dlp probe was cancelled");
|
|
1879
|
+
}
|
|
1880
|
+
throw new MediaArchiveError("PROBE_FAILED", probe.diagnostic);
|
|
1881
|
+
}
|
|
1882
|
+
const metadata = probe.metadata;
|
|
1883
|
+
const sourceDiagnosticSecrets = [
|
|
1884
|
+
options.url,
|
|
1885
|
+
...urlDerivedRedactions(options.url),
|
|
1886
|
+
...(options.browser === undefined ? [] : [options.browser]),
|
|
1887
|
+
...(options.authContext === undefined ? [] : [options.authContext]),
|
|
1888
|
+
...metadata.diagnosticRedactions,
|
|
1889
|
+
];
|
|
1890
|
+
const caption = options.mode === "audio" || options.mode === "video"
|
|
1891
|
+
? null
|
|
1892
|
+
: selectCaption(metadata, options.language);
|
|
1893
|
+
let localPlan: LocalTranscriptionPlan | null = options.mode === "transcript" && caption === null
|
|
1894
|
+
? requireFocusedLocalPlan(await loadLocalTranscriptionPlan(options, dependencies))
|
|
1895
|
+
: null;
|
|
1896
|
+
const identityRequest: CaptureIdentityRequest = options.mode === "transcript"
|
|
1897
|
+
? caption === null
|
|
1898
|
+
? {
|
|
1899
|
+
mode: "transcript",
|
|
1900
|
+
transcript: {
|
|
1901
|
+
kind: "local",
|
|
1902
|
+
identity: requireFocusedLocalPlan(localPlan ?? { kind: "not-configured" }).identity,
|
|
1903
|
+
},
|
|
1904
|
+
}
|
|
1905
|
+
: {
|
|
1906
|
+
mode: "transcript",
|
|
1907
|
+
transcript: {
|
|
1908
|
+
kind: "provider",
|
|
1909
|
+
source: caption.source,
|
|
1910
|
+
language: caption.language,
|
|
1911
|
+
},
|
|
1912
|
+
}
|
|
1913
|
+
: { mode: options.mode };
|
|
1914
|
+
const lineage = revisionLineageIdentity(metadata, identityRequest);
|
|
1915
|
+
const providerDirectory = join(root, metadata.extractorDirectory);
|
|
1916
|
+
await ensurePhysicalChildDirectory(providerDirectory);
|
|
1917
|
+
const initialHead = await discoverYtDlpHead(
|
|
1918
|
+
providerDirectory,
|
|
1919
|
+
metadata,
|
|
1920
|
+
lineage,
|
|
1921
|
+
options.mode,
|
|
1922
|
+
caption,
|
|
1923
|
+
privateAccess,
|
|
1924
|
+
);
|
|
1925
|
+
if (initialHead !== null && options.refresh !== true) {
|
|
1926
|
+
return {
|
|
1927
|
+
status: "existing",
|
|
1928
|
+
itemDirectory: initialHead.itemDirectory,
|
|
1929
|
+
manifest: initialHead.manifest,
|
|
1930
|
+
warnings: [],
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
if (options.mode === "archive" && caption === null) {
|
|
1935
|
+
localPlan = await loadLocalTranscriptionPlan(options, dependencies);
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
const requiresMedia = options.mode !== "transcript" || localPlan?.kind === "ready";
|
|
1939
|
+
const ffmpegExecutable = requiresMedia ? await requireExecutable("ffmpeg", options, dependencies) : null;
|
|
1940
|
+
const ffprobeExecutable = requiresMedia ? await requireExecutable("ffprobe", options, dependencies) : null;
|
|
1941
|
+
const stagingRoot = join(root, ".wrench-media-staging");
|
|
1942
|
+
const lockRoot = join(root, ".wrench-media-locks");
|
|
1943
|
+
await ensurePhysicalChildDirectory(stagingRoot);
|
|
1944
|
+
await ensurePhysicalChildDirectory(lockRoot);
|
|
1945
|
+
const storageIdentity = splitIdentityLeaf(lineage.storagePathSegments);
|
|
1946
|
+
const stagingParentDirectory = await ensurePhysicalDirectorySegments(
|
|
1947
|
+
stagingRoot,
|
|
1948
|
+
storageIdentity.parentSegments,
|
|
1949
|
+
);
|
|
1950
|
+
const lockParentDirectory = await ensurePhysicalDirectorySegments(
|
|
1951
|
+
lockRoot,
|
|
1952
|
+
storageIdentity.parentSegments,
|
|
1953
|
+
);
|
|
1954
|
+
const stagingItem = join(stagingParentDirectory, storageIdentity.leaf);
|
|
1955
|
+
const lockPath = join(lockParentDirectory, `${storageIdentity.leaf}.lock`);
|
|
1956
|
+
let itemLock: ItemLock;
|
|
1957
|
+
try {
|
|
1958
|
+
itemLock = await acquireItemLock(lockPath);
|
|
1959
|
+
} catch (error) {
|
|
1960
|
+
if (error instanceof ItemLockBusyError) {
|
|
1961
|
+
throw new MediaArchiveError("BUSY", error.message, { lockPath });
|
|
1962
|
+
}
|
|
1963
|
+
throw error;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
try {
|
|
1967
|
+
// A cache miss can race a completed promotion. Refresh also re-reads the
|
|
1968
|
+
// exact chain head while holding the stable subject lock before capture.
|
|
1969
|
+
const currentHead = await discoverYtDlpHead(
|
|
1970
|
+
providerDirectory,
|
|
1971
|
+
metadata,
|
|
1972
|
+
lineage,
|
|
1973
|
+
options.mode,
|
|
1974
|
+
caption,
|
|
1975
|
+
privateAccess,
|
|
1976
|
+
);
|
|
1977
|
+
if (currentHead !== null && options.refresh !== true) {
|
|
1978
|
+
return {
|
|
1979
|
+
status: "existing",
|
|
1980
|
+
itemDirectory: currentHead.itemDirectory,
|
|
1981
|
+
manifest: currentHead.manifest,
|
|
1982
|
+
warnings: [],
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
// A previous process may have died before its post-download identity was
|
|
1986
|
+
// validated. Safe restart deliberately replaces the exact lock-owned leaf
|
|
1987
|
+
// rather than resuming bytes whose source provenance is unproven.
|
|
1988
|
+
await resetStagingItem(stagingItem);
|
|
1989
|
+
for (const directory of [
|
|
1990
|
+
join(stagingItem, "data"),
|
|
1991
|
+
join(stagingItem, "data", "derivatives"),
|
|
1992
|
+
join(stagingItem, "data", "captions"),
|
|
1993
|
+
join(stagingItem, "data", "metadata"),
|
|
1994
|
+
join(stagingItem, ".tmp"),
|
|
1995
|
+
]) await ensurePhysicalDirectory(directory);
|
|
1996
|
+
|
|
1997
|
+
// yt-dlp writes only into a fresh attempt directory. Its --no-overwrites
|
|
1998
|
+
// behavior must never mix an unverified prior process with
|
|
1999
|
+
// the identity emitted by this process.
|
|
2000
|
+
const captureAttempt = await mkdtemp(join(stagingItem, ".tmp", "capture-attempt-"));
|
|
2001
|
+
await chmod(captureAttempt, 0o700);
|
|
2002
|
+
const captureAttemptDirectory = join(captureAttempt, "capture");
|
|
2003
|
+
const captureAttemptTemporaryDirectory = join(captureAttempt, "temporary");
|
|
2004
|
+
await ensurePhysicalChildDirectory(captureAttemptDirectory);
|
|
2005
|
+
await ensurePhysicalChildDirectory(captureAttemptTemporaryDirectory);
|
|
2006
|
+
let capture: YtDlpCaptureResult;
|
|
2007
|
+
try {
|
|
2008
|
+
capture = await dependencies.capture({
|
|
2009
|
+
executable: ytDlpExecutable,
|
|
2010
|
+
url: options.url,
|
|
2011
|
+
mode: options.mode === "transcript" && localPlan?.kind === "ready"
|
|
2012
|
+
? "audio"
|
|
2013
|
+
: options.mode,
|
|
2014
|
+
captureDirectory: captureAttemptDirectory,
|
|
2015
|
+
temporaryDirectory: captureAttemptTemporaryDirectory,
|
|
2016
|
+
caption,
|
|
2017
|
+
persistDescriptiveMetadata: metadata.projection === "youtube",
|
|
2018
|
+
privateRedactions: metadata.diagnosticRedactions,
|
|
2019
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
2020
|
+
...(options.browser === undefined ? {} : { browser: options.browser }),
|
|
2021
|
+
...(privateAccess === undefined
|
|
2022
|
+
? {}
|
|
2023
|
+
: { authContextSha256: privateAccess.contextSha256 }),
|
|
2024
|
+
inheritConfig: options.inheritYtDlpConfig,
|
|
2025
|
+
});
|
|
2026
|
+
} catch (error) {
|
|
2027
|
+
await rm(captureAttempt, { recursive: true, force: true });
|
|
2028
|
+
if (isCancelled(options.signal)) {
|
|
2029
|
+
throw new MediaArchiveError("CANCELLED", "yt-dlp capture was cancelled");
|
|
2030
|
+
}
|
|
2031
|
+
throw error;
|
|
2032
|
+
}
|
|
2033
|
+
if (!capture.ok) {
|
|
2034
|
+
await rm(captureAttempt, { recursive: true, force: true });
|
|
2035
|
+
if ("processReason" in capture && capture.processReason === "aborted") {
|
|
2036
|
+
throw new MediaArchiveError("CANCELLED", "yt-dlp capture was cancelled");
|
|
2037
|
+
}
|
|
2038
|
+
throw new MediaArchiveError("CAPTURE_FAILED", capture.diagnostic, { stagingDirectory: stagingItem });
|
|
2039
|
+
}
|
|
2040
|
+
if (
|
|
2041
|
+
capture.identity.extractor !== metadata.acquisitionIdentity.extractor
|
|
2042
|
+
|| capture.identity.id !== metadata.acquisitionIdentity.id
|
|
2043
|
+
) {
|
|
2044
|
+
// yt-dlp uses --no-overwrites. Keeping bytes written by
|
|
2045
|
+
// source B under source A's staging identity could therefore let a later
|
|
2046
|
+
// A/A run bless and promote B. This exact lock-owned leaf is discarded.
|
|
2047
|
+
await discardStagingItem(stagingItem);
|
|
2048
|
+
throw new MediaArchiveError(
|
|
2049
|
+
"CAPTURE_FAILED",
|
|
2050
|
+
"the completed download belongs to a different provider source than the probe",
|
|
2051
|
+
{ stagingDirectory: stagingItem },
|
|
2052
|
+
);
|
|
2053
|
+
}
|
|
2054
|
+
await adoptCaptureAttempt(
|
|
2055
|
+
captureAttemptDirectory,
|
|
2056
|
+
join(stagingItem, "data", "capture"),
|
|
2057
|
+
);
|
|
2058
|
+
await rm(captureAttempt, { recursive: true, force: true });
|
|
2059
|
+
const organized = await organizeCaptureFiles(
|
|
2060
|
+
stagingItem,
|
|
2061
|
+
capture.identity.ext,
|
|
2062
|
+
metadata.projection === "youtube",
|
|
2063
|
+
);
|
|
2064
|
+
if (requiresMedia && organized.mediaPath === null) throw new MediaArchiveError("CAPTURE_FAILED", "yt-dlp completed without a primary media file", { stagingDirectory: stagingItem });
|
|
2065
|
+
const providerMetadataPath = await writeProviderMetadata(stagingItem, metadata);
|
|
2066
|
+
|
|
2067
|
+
const artifacts: MediaArtifact[] = [];
|
|
2068
|
+
await addArtifact(artifacts, stagingItem, organized.mediaPath, "capture");
|
|
2069
|
+
await addArtifact(artifacts, stagingItem, providerMetadataPath, "provider_metadata");
|
|
2070
|
+
await addArtifact(artifacts, stagingItem, organized.descriptionPath, "description");
|
|
2071
|
+
await addArtifact(artifacts, stagingItem, organized.thumbnailPath, "thumbnail");
|
|
2072
|
+
|
|
2073
|
+
if (organized.mediaPath !== null && ffmpegExecutable !== null && ffprobeExecutable !== null) {
|
|
2074
|
+
const derivatives = await dependencies.derive({
|
|
2075
|
+
capturePath: organized.mediaPath,
|
|
2076
|
+
derivativesDirectory: join(stagingItem, "data", "derivatives"),
|
|
2077
|
+
roles: options.mode === "transcript" && localPlan?.kind === "ready"
|
|
2078
|
+
? ["audio"]
|
|
2079
|
+
: requestedDerivativeRoles(options.mode),
|
|
2080
|
+
ffmpegExecutable,
|
|
2081
|
+
ffprobeExecutable,
|
|
2082
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
2083
|
+
});
|
|
2084
|
+
if (isCancelled(options.signal)) {
|
|
2085
|
+
throw new MediaArchiveError("CANCELLED", "media derivation was cancelled");
|
|
2086
|
+
}
|
|
2087
|
+
const failed = [derivatives.video, derivatives.audio].filter((result) => result.status === "failed");
|
|
2088
|
+
if (failed.length > 0) {
|
|
2089
|
+
throw new MediaArchiveError("DERIVATION_FAILED", "FFmpeg could not create every available stream-copy derivative", {
|
|
2090
|
+
failures: failed.map((result) => result.status === "failed" ? { role: result.role, stage: result.stage, diagnostic: result.diagnostic } : null),
|
|
2091
|
+
stagingDirectory: stagingItem,
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
if (derivatives.video.status === "created" || derivatives.video.status === "exists") await addArtifact(artifacts, stagingItem, derivatives.video.path, "video");
|
|
2095
|
+
if (derivatives.audio.status === "created" || derivatives.audio.status === "exists") await addArtifact(artifacts, stagingItem, derivatives.audio.path, "audio");
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
const transcript = caption !== null
|
|
2099
|
+
? await transcriptArtifacts(
|
|
2100
|
+
stagingItem,
|
|
2101
|
+
organized.captionPath,
|
|
2102
|
+
caption,
|
|
2103
|
+
options.mode,
|
|
2104
|
+
)
|
|
2105
|
+
: options.mode === "archive" || options.mode === "transcript"
|
|
2106
|
+
? await localTranscriptArtifacts(
|
|
2107
|
+
stagingItem,
|
|
2108
|
+
options.mode,
|
|
2109
|
+
localPlan ?? { kind: "not-configured" },
|
|
2110
|
+
artifacts,
|
|
2111
|
+
ffmpegExecutable,
|
|
2112
|
+
options,
|
|
2113
|
+
dependencies,
|
|
2114
|
+
)
|
|
2115
|
+
: unavailableTranscript("not_requested");
|
|
2116
|
+
artifacts.push(...transcript.artifacts);
|
|
2117
|
+
const contentSha256 = revisionContentSha256(artifacts);
|
|
2118
|
+
if (currentHead !== null && currentHead.contentSha256 === contentSha256) {
|
|
2119
|
+
await discardStagingItem(stagingItem);
|
|
2120
|
+
return {
|
|
2121
|
+
status: "existing",
|
|
2122
|
+
itemDirectory: currentHead.itemDirectory,
|
|
2123
|
+
manifest: currentHead.manifest,
|
|
2124
|
+
warnings: [],
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
const revisionSequence = currentHead === null
|
|
2128
|
+
? 1
|
|
2129
|
+
: currentHead.manifest.revision.sequence + 1;
|
|
2130
|
+
if (
|
|
2131
|
+
revisionSequence > MAX_REVISION_SEQUENCE
|
|
2132
|
+
|| revisionSequence > MAX_TRACKED_REVISION_ITEMS
|
|
2133
|
+
) {
|
|
2134
|
+
throw new MediaArchiveError(
|
|
2135
|
+
"ARCHIVE_CONFLICT",
|
|
2136
|
+
"the revision history has exhausted Ghostget media's supported lineage capacity",
|
|
2137
|
+
{ maximumEntries: MAX_TRACKED_REVISION_ITEMS },
|
|
2138
|
+
);
|
|
2139
|
+
}
|
|
2140
|
+
const revision: MediaTrackedRevision = {
|
|
2141
|
+
profile: GHOSTGET_MEDIA_TRACKED_REVISION_PROFILE,
|
|
2142
|
+
sequence: revisionSequence,
|
|
2143
|
+
subjectAssetKey: lineage.subjectAssetKey,
|
|
2144
|
+
...(currentHead === null
|
|
2145
|
+
? {}
|
|
2146
|
+
: { previousAssetKey: currentHead.manifest.assetKey }),
|
|
2147
|
+
content: {
|
|
2148
|
+
profile: GHOSTGET_MEDIA_REVISION_CONTENT_PROFILE,
|
|
2149
|
+
sha256: contentSha256,
|
|
2150
|
+
},
|
|
2151
|
+
};
|
|
2152
|
+
const revisionAssetKey = trackedRevisionAssetKey(revision);
|
|
2153
|
+
const revisionLeaf = revisionItemLeaf(revisionSequence, revisionAssetKey);
|
|
2154
|
+
const ytVersion = await dependencies.ytDlpVersion(ytDlpExecutable);
|
|
2155
|
+
const ffmpegVersion = ffmpegExecutable === null ? undefined : await dependencies.ffmpegVersion(ffmpegExecutable);
|
|
2156
|
+
const ffprobeVersion = ffprobeExecutable === null ? undefined : await dependencies.ffmpegVersion(ffprobeExecutable);
|
|
2157
|
+
const manifest: MediaManifest = {
|
|
2158
|
+
schemaVersion: GHOSTGET_MEDIA_SCHEMA_VERSION,
|
|
2159
|
+
wrenchVersion: GHOSTGET_MEDIA_VERSION,
|
|
2160
|
+
assetKey: revisionAssetKey,
|
|
2161
|
+
capturedAt: dependencies.now().toISOString(),
|
|
2162
|
+
mode: options.mode,
|
|
2163
|
+
source: sourceManifest(metadata),
|
|
2164
|
+
authentication: privateAccess === undefined
|
|
2165
|
+
? { mode: "public" }
|
|
2166
|
+
: {
|
|
2167
|
+
mode: privateAccess.mode,
|
|
2168
|
+
context: {
|
|
2169
|
+
profile: AUTH_CONTEXT_IDENTITY_PROFILE,
|
|
2170
|
+
sha256: privateAccess.contextSha256,
|
|
2171
|
+
},
|
|
2172
|
+
},
|
|
2173
|
+
acquisition: {
|
|
2174
|
+
adapter: "yt-dlp",
|
|
2175
|
+
version: ytVersion,
|
|
2176
|
+
identity: ytDlpManifestIdentity(metadata),
|
|
2177
|
+
},
|
|
2178
|
+
revision,
|
|
2179
|
+
tools: {
|
|
2180
|
+
...(ffmpegVersion === undefined ? {} : { ffmpeg: ffmpegVersion }),
|
|
2181
|
+
...(ffprobeVersion === undefined ? {} : { ffprobe: ffprobeVersion }),
|
|
2182
|
+
},
|
|
2183
|
+
artifacts,
|
|
2184
|
+
transcript: transcript.transcript,
|
|
2185
|
+
};
|
|
2186
|
+
assertManifestSatisfiesMode(manifest, options.mode);
|
|
2187
|
+
await rm(join(stagingItem, ".tmp"), { recursive: true, force: true });
|
|
2188
|
+
const written = await writeMediaManifest(stagingItem, manifest);
|
|
2189
|
+
await hardenTree(stagingItem);
|
|
2190
|
+
const stagedVerification = await verifyMediaItem(stagingItem);
|
|
2191
|
+
if (!stagedVerification.ok) {
|
|
2192
|
+
throw new MediaArchiveError(
|
|
2193
|
+
"ARCHIVE_INVALID",
|
|
2194
|
+
"the staged item failed its closed integrity verification",
|
|
2195
|
+
{ failures: stagedVerification.failures, stagingDirectory: stagingItem },
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2198
|
+
try {
|
|
2199
|
+
await itemLock.assertOwned();
|
|
2200
|
+
} catch (error) {
|
|
2201
|
+
if (error instanceof ItemLockLostError) {
|
|
2202
|
+
throw new MediaArchiveError("BUSY", error.message, { lockPath });
|
|
2203
|
+
}
|
|
2204
|
+
throw error;
|
|
2205
|
+
}
|
|
2206
|
+
await ensurePhysicalChildDirectory(providerDirectory);
|
|
2207
|
+
const promotionParentDirectory = await ensurePhysicalDirectorySegments(
|
|
2208
|
+
providerDirectory,
|
|
2209
|
+
lineage.itemParentPathSegments,
|
|
2210
|
+
);
|
|
2211
|
+
const itemDirectory = join(promotionParentDirectory, revisionLeaf);
|
|
2212
|
+
try { await lstat(itemDirectory); throw new MediaArchiveError("ARCHIVE_CONFLICT", "the completed item path appeared during capture"); } catch (error) { if (!isErrno(error, "ENOENT")) throw error; }
|
|
2213
|
+
await rename(stagingItem, itemDirectory);
|
|
2214
|
+
return {
|
|
2215
|
+
status: "created",
|
|
2216
|
+
itemDirectory,
|
|
2217
|
+
manifest: written,
|
|
2218
|
+
warnings: options.mode === "archive"
|
|
2219
|
+
? archiveTranscriptWarnings(written.transcript)
|
|
2220
|
+
: [],
|
|
2221
|
+
};
|
|
2222
|
+
} catch (error) {
|
|
2223
|
+
if (error instanceof MediaArchiveError && error.code === "CANCELLED") {
|
|
2224
|
+
try {
|
|
2225
|
+
await itemLock.assertOwned();
|
|
2226
|
+
await discardStagingItem(stagingItem);
|
|
2227
|
+
} catch {
|
|
2228
|
+
// Preserve cancellation. A lost lock means another owner controls the
|
|
2229
|
+
// staging identity; a failed discard is retried on the next run.
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
if (error instanceof MediaArchiveError) {
|
|
2233
|
+
throw new MediaArchiveError(
|
|
2234
|
+
error.code,
|
|
2235
|
+
redactDiagnostic(error.message, { secrets: sourceDiagnosticSecrets }),
|
|
2236
|
+
redactedArchiveDetails(error.details, sourceDiagnosticSecrets),
|
|
2237
|
+
);
|
|
2238
|
+
}
|
|
2239
|
+
throw new MediaArchiveError(
|
|
2240
|
+
"IO_ERROR",
|
|
2241
|
+
redactDiagnostic(
|
|
2242
|
+
error instanceof Error ? error.message : "archive pipeline failed",
|
|
2243
|
+
{ secrets: sourceDiagnosticSecrets },
|
|
2244
|
+
),
|
|
2245
|
+
{ stagingDirectory: stagingItem },
|
|
2246
|
+
);
|
|
2247
|
+
} finally {
|
|
2248
|
+
try { await itemLock.release(); } catch { /* A dead owner is reclaimed safely on the next run. */ }
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
/** Selects the acquisition boundary before entering an adapter-owned pipeline. */
|
|
2253
|
+
export async function mediaUrl(
|
|
2254
|
+
options: MediaArchiveOptions,
|
|
2255
|
+
dependencies: MediaArchiveDependencies = defaultDependencies,
|
|
2256
|
+
): Promise<MediaArchiveResult> {
|
|
2257
|
+
if (isCancelled(options.signal)) {
|
|
2258
|
+
throw new MediaArchiveError("CANCELLED", "capture was cancelled before it started");
|
|
2259
|
+
}
|
|
2260
|
+
const route = routeSource(options);
|
|
2261
|
+
if (route.kind === "reject") {
|
|
2262
|
+
throw new MediaArchiveError(
|
|
2263
|
+
"PROBE_FAILED",
|
|
2264
|
+
`source URL was rejected by the acquisition router: ${route.reason}`,
|
|
2265
|
+
);
|
|
2266
|
+
}
|
|
2267
|
+
const routedOptions = route.kind === "yt-dlp"
|
|
2268
|
+
? { ...options, url: route.requestUrl, authContext: route.authContext }
|
|
2269
|
+
: { ...options, url: route.requestUrl };
|
|
2270
|
+
if (route.kind === "yt-dlp") {
|
|
2271
|
+
return await mediaWithYtDlp(routedOptions, dependencies);
|
|
2272
|
+
}
|
|
2273
|
+
let directProbeValue: unknown;
|
|
2274
|
+
try {
|
|
2275
|
+
directProbeValue = await dependencies.probeDirectHttp(
|
|
2276
|
+
route.requestUrl,
|
|
2277
|
+
options.signal === undefined ? {} : { signal: options.signal },
|
|
2278
|
+
);
|
|
2279
|
+
} catch {
|
|
2280
|
+
if (isCancelled(options.signal)) {
|
|
2281
|
+
throw new MediaArchiveError("CANCELLED", "direct HTTP probe was cancelled");
|
|
2282
|
+
}
|
|
2283
|
+
throw new MediaArchiveError(
|
|
2284
|
+
"PROBE_FAILED",
|
|
2285
|
+
"direct HTTP probe adapter failed unexpectedly",
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
if (!isRecord(directProbeValue) || typeof directProbeValue["ok"] !== "boolean") {
|
|
2289
|
+
throw new MediaArchiveError("PROBE_FAILED", "direct HTTP probe adapter returned an invalid result");
|
|
2290
|
+
}
|
|
2291
|
+
if (directProbeValue["ok"] === false) {
|
|
2292
|
+
if (
|
|
2293
|
+
directProbeValue["kind"] === "not-applicable"
|
|
2294
|
+
&& (
|
|
2295
|
+
directProbeValue["reason"] === "http-status"
|
|
2296
|
+
|| directProbeValue["reason"] === "empty-response"
|
|
2297
|
+
|| directProbeValue["reason"] === "declared-text"
|
|
2298
|
+
|| directProbeValue["reason"] === "unrecognized-media"
|
|
2299
|
+
)
|
|
2300
|
+
) {
|
|
2301
|
+
return await mediaWithYtDlp(routedOptions, dependencies);
|
|
2302
|
+
}
|
|
2303
|
+
const failure = directProbeValue["kind"] === "error" && isRecord(directProbeValue["error"])
|
|
2304
|
+
? ownedDirectProbeFailure(directProbeValue["error"]["code"])
|
|
2305
|
+
: ownedDirectProbeFailure(undefined);
|
|
2306
|
+
if (failure.code === "aborted") {
|
|
2307
|
+
throw new MediaArchiveError("CANCELLED", "direct HTTP probe was cancelled");
|
|
2308
|
+
}
|
|
2309
|
+
throw new MediaArchiveError("PROBE_FAILED", failure.message, {
|
|
2310
|
+
...(failure.code === undefined ? {} : { probeCode: failure.code }),
|
|
2311
|
+
});
|
|
2312
|
+
}
|
|
2313
|
+
if (!isDirectHttpProbeBoundary(directProbeValue["probe"])) {
|
|
2314
|
+
throw new MediaArchiveError("PROBE_FAILED", "direct HTTP probe adapter returned an invalid success");
|
|
2315
|
+
}
|
|
2316
|
+
const directProbe = directProbeValue["probe"];
|
|
2317
|
+
if (route.intent === "transcript-probe-only") {
|
|
2318
|
+
return await mediaDirectHttp(routedOptions, route, directProbe, dependencies);
|
|
2319
|
+
}
|
|
2320
|
+
const resolution = resolveDirectMediaProbe(route, { kind: "applicable" });
|
|
2321
|
+
if (resolution.kind !== "direct-http-capture") {
|
|
2322
|
+
return await mediaWithYtDlp(routedOptions, dependencies);
|
|
2323
|
+
}
|
|
2324
|
+
return await mediaDirectHttp(routedOptions, route, directProbe, dependencies);
|
|
2325
|
+
}
|