@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,1712 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open, opendir, readdir, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
+
import type { CaptureMode } from "./args";
|
|
6
|
+
import { directHttpMediaForContainer } from "./http";
|
|
7
|
+
import {
|
|
8
|
+
AUTH_CONTEXT_IDENTITY_PROFILE,
|
|
9
|
+
authenticatedYtDlpSourceAssetKey,
|
|
10
|
+
authenticatedYtDlpSourceId,
|
|
11
|
+
createDirectHttpMetadata,
|
|
12
|
+
identityDirectorySegment,
|
|
13
|
+
opaqueYtDlpSourceAssetKey,
|
|
14
|
+
opaqueYtDlpSourceId,
|
|
15
|
+
providerIdentitySha256,
|
|
16
|
+
sourceAssetKey,
|
|
17
|
+
variantAssetKey,
|
|
18
|
+
YT_DLP_AUTH_IDENTITY_PROFILE,
|
|
19
|
+
YT_DLP_OPAQUE_IDENTITY_PROFILE,
|
|
20
|
+
type AuthenticatedYtDlpIdentity,
|
|
21
|
+
type OpaqueYtDlpIdentity,
|
|
22
|
+
} from "./metadata";
|
|
23
|
+
import {
|
|
24
|
+
isConcreteWhisperCppLanguage,
|
|
25
|
+
normalizeWhisperCppLanguage,
|
|
26
|
+
whisperCppLanguageArgument,
|
|
27
|
+
} from "./whisper-language";
|
|
28
|
+
import {
|
|
29
|
+
MAX_RUNTIME_DEPENDENCIES,
|
|
30
|
+
RUNTIME_CLOSURE_PROFILE,
|
|
31
|
+
} from "./runtime-closure";
|
|
32
|
+
import {
|
|
33
|
+
GHOSTGET_MEDIA_REVISION_CONTENT_PROFILE,
|
|
34
|
+
GHOSTGET_MEDIA_TRACKED_REVISION_PROFILE,
|
|
35
|
+
revisionContentSha256,
|
|
36
|
+
trackedRevisionAssetKey,
|
|
37
|
+
type MediaTrackedRevision,
|
|
38
|
+
} from "./revision";
|
|
39
|
+
import { compareUtf8 } from "./utf8-order";
|
|
40
|
+
import { GHOSTGET_VERSION } from "../version";
|
|
41
|
+
|
|
42
|
+
export const GHOSTGET_MEDIA_SCHEMA_VERSION = 1 as const;
|
|
43
|
+
export const GHOSTGET_MEDIA_VERSION = GHOSTGET_VERSION;
|
|
44
|
+
export const GHOSTGET_MEDIA_MANIFEST_FILE = "wrench-media.json" as const;
|
|
45
|
+
export const GHOSTGET_MEDIA_CHECKSUM_FILE = "manifest-sha256.txt" as const;
|
|
46
|
+
const MAX_ITEM_ENTRIES = 4_096;
|
|
47
|
+
const MAX_ITEM_DEPTH = 32;
|
|
48
|
+
const MAX_DIRECT_HTTP_BODY_BYTES = 64 * 1024 * 1024 * 1024;
|
|
49
|
+
const MAX_DIRECT_HTTP_REDIRECTS = 5;
|
|
50
|
+
const OWNED_CONTROL_TEMP_PATTERN = /^(?:wrench-media\.json|manifest-sha256\.txt)\.tmp-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
51
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
|
52
|
+
const NORMALIZED_MEDIA_TYPE_PATTERN = /^[a-z0-9!#$%&'*+.^_`|~-]{1,127}\/[a-z0-9!#$%&'*+.^_`|~-]{1,127}$/u;
|
|
53
|
+
const OWNED_YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/u;
|
|
54
|
+
const LOCAL_TRANSCRIBER_IDENTITY_DOMAIN = "wrench-media-local-transcriber-identity-v1\0";
|
|
55
|
+
const LOCAL_TRANSCRIBER_IDENTITY_VERSION = 1 as const;
|
|
56
|
+
const MAX_NORMALIZED_PCM_BYTES = (4 * 1024 * 1024 * 1024) - 1;
|
|
57
|
+
|
|
58
|
+
export const GHOSTGET_MEDIA_WHISPER_CPP_PROFILE = "wrench-media-whisper-cpp-v1" as const;
|
|
59
|
+
export const GHOSTGET_MEDIA_RUNTIME_CLOSURE_PROFILE = RUNTIME_CLOSURE_PROFILE;
|
|
60
|
+
export const GHOSTGET_MEDIA_PCM_NORMALIZATION_PROFILE = "pcm-s16le-16000hz-mono-v1" as const;
|
|
61
|
+
export const GHOSTGET_MEDIA_YT_DLP_YOUTUBE_IDENTITY_PROFILE = "yt-dlp-owned-youtube-v1" as const;
|
|
62
|
+
|
|
63
|
+
export type ArtifactRole =
|
|
64
|
+
| "capture"
|
|
65
|
+
| "video"
|
|
66
|
+
| "audio"
|
|
67
|
+
| "transcript_vtt"
|
|
68
|
+
| "transcript_text"
|
|
69
|
+
| "transcript_json"
|
|
70
|
+
| "provider_metadata"
|
|
71
|
+
| "description"
|
|
72
|
+
| "thumbnail";
|
|
73
|
+
|
|
74
|
+
export interface MediaArtifact {
|
|
75
|
+
readonly role: ArtifactRole;
|
|
76
|
+
readonly path: string;
|
|
77
|
+
readonly bytes: number;
|
|
78
|
+
readonly sha256: string;
|
|
79
|
+
readonly mediaType: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface MediaArtifactDependencies {
|
|
83
|
+
/** Test and platform seam between path inspection and the no-follow open. */
|
|
84
|
+
readonly beforeOpen?: (path: string) => Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface MediaControlFileDependencies {
|
|
88
|
+
/** Test seam immediately before a no-follow control-file open. */
|
|
89
|
+
readonly beforeOpen?: (path: string) => Promise<void>;
|
|
90
|
+
/** Test seam after bytes are read but before fd/path stability is rechecked. */
|
|
91
|
+
readonly afterRead?: (path: string) => Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface MediaLocalTranscriptProvenance {
|
|
95
|
+
readonly adapter: "whisper-cpp";
|
|
96
|
+
readonly profile: typeof GHOSTGET_MEDIA_WHISPER_CPP_PROFILE;
|
|
97
|
+
readonly executableSha256: string;
|
|
98
|
+
readonly runtimeProfile: typeof GHOSTGET_MEDIA_RUNTIME_CLOSURE_PROFILE;
|
|
99
|
+
readonly runtimeSha256: string;
|
|
100
|
+
readonly runtimeDependencyCount: number;
|
|
101
|
+
readonly modelSha256: string;
|
|
102
|
+
readonly requestedLanguage: string;
|
|
103
|
+
readonly input: {
|
|
104
|
+
readonly path: string;
|
|
105
|
+
readonly bytes: number;
|
|
106
|
+
readonly sha256: string;
|
|
107
|
+
readonly normalized: {
|
|
108
|
+
readonly profile: typeof GHOSTGET_MEDIA_PCM_NORMALIZATION_PROFILE;
|
|
109
|
+
readonly bytes: number;
|
|
110
|
+
readonly sha256: string;
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type MediaTranscript =
|
|
116
|
+
| {
|
|
117
|
+
readonly status: "available";
|
|
118
|
+
readonly source: "manual" | "automatic";
|
|
119
|
+
readonly language: string;
|
|
120
|
+
readonly timedPath: string;
|
|
121
|
+
readonly textPath: string;
|
|
122
|
+
readonly cuesPath: string;
|
|
123
|
+
}
|
|
124
|
+
| {
|
|
125
|
+
readonly status: "available";
|
|
126
|
+
readonly source: "local";
|
|
127
|
+
readonly language: string;
|
|
128
|
+
readonly timedPath: string;
|
|
129
|
+
readonly textPath: string;
|
|
130
|
+
readonly cuesPath: string;
|
|
131
|
+
readonly provenance: MediaLocalTranscriptProvenance;
|
|
132
|
+
}
|
|
133
|
+
| {
|
|
134
|
+
readonly status: "unavailable";
|
|
135
|
+
readonly reason:
|
|
136
|
+
| "provider_has_no_captions"
|
|
137
|
+
| "not_requested"
|
|
138
|
+
| "transcriber_not_configured"
|
|
139
|
+
| "audio_not_present";
|
|
140
|
+
}
|
|
141
|
+
| {
|
|
142
|
+
readonly status: "unavailable";
|
|
143
|
+
readonly reason: "no_speech";
|
|
144
|
+
readonly provenance: MediaLocalTranscriptProvenance;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Public, path-free inputs that define one immutable local-transcriber variant. */
|
|
148
|
+
export interface MediaLocalTranscriptIdentity {
|
|
149
|
+
readonly adapter: "whisper-cpp";
|
|
150
|
+
readonly profile: typeof GHOSTGET_MEDIA_WHISPER_CPP_PROFILE;
|
|
151
|
+
readonly executableSha256: string;
|
|
152
|
+
readonly runtimeProfile: typeof GHOSTGET_MEDIA_RUNTIME_CLOSURE_PROFILE;
|
|
153
|
+
readonly runtimeSha256: string;
|
|
154
|
+
readonly runtimeDependencyCount: number;
|
|
155
|
+
readonly modelSha256: string;
|
|
156
|
+
readonly normalizationProfile: typeof GHOSTGET_MEDIA_PCM_NORMALIZATION_PROFILE;
|
|
157
|
+
readonly requestedLanguage: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isNormalizedTranscriptionLanguage(value: unknown): value is string {
|
|
161
|
+
return typeof value === "string"
|
|
162
|
+
&& value.length <= 64
|
|
163
|
+
&& normalizeWhisperCppLanguage(value) === value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function localTranscriptLanguagesMatch(requested: string, actual: string): boolean {
|
|
167
|
+
if (requested === "auto") return true;
|
|
168
|
+
const requestedToolLanguage = whisperCppLanguageArgument(requested);
|
|
169
|
+
const actualToolLanguage = whisperCppLanguageArgument(actual);
|
|
170
|
+
return requestedToolLanguage !== null
|
|
171
|
+
&& requestedToolLanguage !== "auto"
|
|
172
|
+
&& requestedToolLanguage === actualToolLanguage;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function boundedTranscriptLanguageSegment(value: string): string {
|
|
176
|
+
const lower = value.toLowerCase();
|
|
177
|
+
const candidate = identityDirectorySegment(lower, "language");
|
|
178
|
+
return candidate.length <= 64
|
|
179
|
+
? candidate
|
|
180
|
+
: identityDirectorySegment(`/${lower}`, "language");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function localTranscriptIdentityDigest(identity: MediaLocalTranscriptIdentity): string {
|
|
184
|
+
if (
|
|
185
|
+
identity.adapter !== "whisper-cpp"
|
|
186
|
+
|| identity.profile !== GHOSTGET_MEDIA_WHISPER_CPP_PROFILE
|
|
187
|
+
|| identity.runtimeProfile !== GHOSTGET_MEDIA_RUNTIME_CLOSURE_PROFILE
|
|
188
|
+
|| identity.normalizationProfile !== GHOSTGET_MEDIA_PCM_NORMALIZATION_PROFILE
|
|
189
|
+
|| !SHA256_PATTERN.test(identity.executableSha256)
|
|
190
|
+
|| !SHA256_PATTERN.test(identity.runtimeSha256)
|
|
191
|
+
|| !Number.isSafeInteger(identity.runtimeDependencyCount)
|
|
192
|
+
|| identity.runtimeDependencyCount < 0
|
|
193
|
+
|| identity.runtimeDependencyCount > MAX_RUNTIME_DEPENDENCIES
|
|
194
|
+
|| !SHA256_PATTERN.test(identity.modelSha256)
|
|
195
|
+
|| !isNormalizedTranscriptionLanguage(identity.requestedLanguage)
|
|
196
|
+
) {
|
|
197
|
+
throw new TypeError("local transcript identity is malformed");
|
|
198
|
+
}
|
|
199
|
+
const hash = createHash("sha256");
|
|
200
|
+
hash.update(LOCAL_TRANSCRIBER_IDENTITY_DOMAIN, "utf8");
|
|
201
|
+
for (const component of [
|
|
202
|
+
identity.adapter,
|
|
203
|
+
identity.profile,
|
|
204
|
+
identity.executableSha256,
|
|
205
|
+
identity.runtimeProfile,
|
|
206
|
+
identity.runtimeSha256,
|
|
207
|
+
String(identity.runtimeDependencyCount),
|
|
208
|
+
identity.modelSha256,
|
|
209
|
+
identity.normalizationProfile,
|
|
210
|
+
identity.requestedLanguage.toLowerCase(),
|
|
211
|
+
]) {
|
|
212
|
+
const bytes = new TextEncoder().encode(component);
|
|
213
|
+
const length = new Uint8Array(8);
|
|
214
|
+
new DataView(length.buffer).setBigUint64(0, BigInt(bytes.byteLength), false);
|
|
215
|
+
hash.update(length);
|
|
216
|
+
hash.update(bytes);
|
|
217
|
+
}
|
|
218
|
+
return hash.digest("hex");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Canonical path and asset-key identity for one focused local transcript. */
|
|
222
|
+
export function localTranscriptVariantSegments(
|
|
223
|
+
identity: MediaLocalTranscriptIdentity,
|
|
224
|
+
): readonly ["transcript", "local", string, string] {
|
|
225
|
+
return [
|
|
226
|
+
"transcript",
|
|
227
|
+
"local",
|
|
228
|
+
boundedTranscriptLanguageSegment(identity.requestedLanguage),
|
|
229
|
+
`transcriber-v${String(LOCAL_TRANSCRIBER_IDENTITY_VERSION)}-${localTranscriptIdentityDigest(identity)}`,
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function localTranscriptVariantAssetKey(
|
|
234
|
+
sourceAssetKey: string,
|
|
235
|
+
identity: MediaLocalTranscriptIdentity,
|
|
236
|
+
): string {
|
|
237
|
+
return variantAssetKey(sourceAssetKey, localTranscriptVariantSegments(identity));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export type MediaAuthentication =
|
|
241
|
+
| { readonly mode: "public" }
|
|
242
|
+
| {
|
|
243
|
+
readonly mode: "browser" | "ambient_config";
|
|
244
|
+
readonly context: {
|
|
245
|
+
readonly profile: typeof AUTH_CONTEXT_IDENTITY_PROFILE;
|
|
246
|
+
readonly sha256: string;
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export interface MediaManifestSource {
|
|
251
|
+
readonly extractor: string;
|
|
252
|
+
readonly id: string;
|
|
253
|
+
readonly canonicalUrl: string;
|
|
254
|
+
readonly title?: string;
|
|
255
|
+
readonly uploader?: string;
|
|
256
|
+
readonly channel?: string;
|
|
257
|
+
readonly license?: string;
|
|
258
|
+
readonly uploadDate?: string;
|
|
259
|
+
readonly timestamp?: number;
|
|
260
|
+
readonly durationSeconds?: number;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface MediaManifestTools {
|
|
264
|
+
readonly ffmpeg?: string;
|
|
265
|
+
readonly ffprobe?: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export type MediaDirectHttpContainer =
|
|
269
|
+
| "iso-bmff"
|
|
270
|
+
| "matroska"
|
|
271
|
+
| "webm"
|
|
272
|
+
| "ogg"
|
|
273
|
+
| "flac"
|
|
274
|
+
| "wave"
|
|
275
|
+
| "mp3"
|
|
276
|
+
| "mpeg-ts";
|
|
277
|
+
|
|
278
|
+
export type MediaDirectHttpValidator =
|
|
279
|
+
| { readonly strength: "absent" }
|
|
280
|
+
| { readonly strength: "weak" | "strong"; readonly sha256: string };
|
|
281
|
+
|
|
282
|
+
export interface MediaDirectHttpProvenance {
|
|
283
|
+
readonly requestedUrlSha256: string;
|
|
284
|
+
readonly effectiveUrlSha256: string;
|
|
285
|
+
readonly validator: MediaDirectHttpValidator;
|
|
286
|
+
readonly lastModified: string | null;
|
|
287
|
+
readonly declaredMediaType: string | null;
|
|
288
|
+
readonly container: MediaDirectHttpContainer;
|
|
289
|
+
readonly body: {
|
|
290
|
+
readonly bytes: number;
|
|
291
|
+
readonly sha256: string;
|
|
292
|
+
};
|
|
293
|
+
readonly redirectCount: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export interface MediaYtDlpYouTubeIdentity {
|
|
297
|
+
readonly profile: typeof GHOSTGET_MEDIA_YT_DLP_YOUTUBE_IDENTITY_PROFILE;
|
|
298
|
+
readonly providerIdentitySha256: string;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Public, path-free inputs that reproduce one current yt-dlp source key. */
|
|
302
|
+
export type MediaYtDlpIdentity =
|
|
303
|
+
| MediaYtDlpYouTubeIdentity
|
|
304
|
+
| OpaqueYtDlpIdentity
|
|
305
|
+
| AuthenticatedYtDlpIdentity;
|
|
306
|
+
|
|
307
|
+
export interface MediaYtDlpAcquisition {
|
|
308
|
+
readonly adapter: "yt-dlp";
|
|
309
|
+
readonly version: string;
|
|
310
|
+
readonly identity: MediaYtDlpIdentity;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface MediaDirectHttpAcquisition {
|
|
314
|
+
readonly adapter: "direct-http";
|
|
315
|
+
readonly provenance: MediaDirectHttpProvenance;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
interface MediaManifestBase {
|
|
319
|
+
readonly schemaVersion: typeof GHOSTGET_MEDIA_SCHEMA_VERSION;
|
|
320
|
+
readonly wrenchVersion: string;
|
|
321
|
+
readonly assetKey: string;
|
|
322
|
+
readonly capturedAt: string;
|
|
323
|
+
readonly mode: CaptureMode;
|
|
324
|
+
readonly source: MediaManifestSource;
|
|
325
|
+
readonly authentication: MediaAuthentication;
|
|
326
|
+
readonly tools: MediaManifestTools;
|
|
327
|
+
readonly artifacts: readonly MediaArtifact[];
|
|
328
|
+
readonly transcript: MediaTranscript;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export type MediaYtDlpManifest = MediaManifestBase & {
|
|
332
|
+
readonly acquisition: MediaYtDlpAcquisition;
|
|
333
|
+
readonly revision: MediaTrackedRevision;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
export type MediaDirectHttpManifest = MediaManifestBase & {
|
|
337
|
+
readonly authentication: { readonly mode: "public" };
|
|
338
|
+
readonly acquisition: MediaDirectHttpAcquisition;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
export type MediaManifest = MediaYtDlpManifest | MediaDirectHttpManifest;
|
|
342
|
+
|
|
343
|
+
export type ParseManifestResult =
|
|
344
|
+
| { readonly ok: true; readonly manifest: MediaManifest }
|
|
345
|
+
| { readonly ok: false; readonly message: string };
|
|
346
|
+
|
|
347
|
+
export interface VerifyItemResult {
|
|
348
|
+
readonly ok: boolean;
|
|
349
|
+
readonly itemDirectory: string;
|
|
350
|
+
readonly assetKey?: string;
|
|
351
|
+
readonly checkedArtifacts: number;
|
|
352
|
+
readonly failures: readonly string[];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const artifactRoles = new Set<ArtifactRole>([
|
|
356
|
+
"capture",
|
|
357
|
+
"video",
|
|
358
|
+
"audio",
|
|
359
|
+
"transcript_vtt",
|
|
360
|
+
"transcript_text",
|
|
361
|
+
"transcript_json",
|
|
362
|
+
"provider_metadata",
|
|
363
|
+
"description",
|
|
364
|
+
"thumbnail",
|
|
365
|
+
]);
|
|
366
|
+
|
|
367
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
368
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function hasExactKeys(
|
|
372
|
+
record: Readonly<Record<string, unknown>>,
|
|
373
|
+
required: readonly string[],
|
|
374
|
+
optional: readonly string[] = [],
|
|
375
|
+
): boolean {
|
|
376
|
+
const allowed = new Set([...required, ...optional]);
|
|
377
|
+
return required.every((key) => Object.hasOwn(record, key))
|
|
378
|
+
&& Object.keys(record).every((key) => allowed.has(key));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function stringValue(record: Readonly<Record<string, unknown>>, key: string, maximum: number): string | undefined {
|
|
382
|
+
const value = record[key];
|
|
383
|
+
return typeof value === "string" && value.length > 0 && value.length <= maximum && !value.includes("\0")
|
|
384
|
+
? value
|
|
385
|
+
: undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function safeRelativePath(value: unknown): value is string {
|
|
389
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 4_096 || isAbsolute(value) || value.includes("\\")) return false;
|
|
390
|
+
const segments = value.split("/");
|
|
391
|
+
return segments.every((segment) => segment.length > 0 && segment !== "." && segment !== ".." && !segment.includes("\0"));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function parseArtifact(value: unknown): MediaArtifact | null {
|
|
395
|
+
if (!isRecord(value)) return null;
|
|
396
|
+
if (!hasExactKeys(value, ["role", "path", "bytes", "sha256", "mediaType"])) return null;
|
|
397
|
+
const role = value["role"];
|
|
398
|
+
const path = value["path"];
|
|
399
|
+
const bytes = value["bytes"];
|
|
400
|
+
const sha256 = value["sha256"];
|
|
401
|
+
const mediaType = value["mediaType"];
|
|
402
|
+
if (
|
|
403
|
+
typeof role !== "string" || !artifactRoles.has(role as ArtifactRole) ||
|
|
404
|
+
!safeRelativePath(path) ||
|
|
405
|
+
typeof bytes !== "number" || !Number.isSafeInteger(bytes) || bytes < 0 ||
|
|
406
|
+
typeof sha256 !== "string" || !SHA256_PATTERN.test(sha256) ||
|
|
407
|
+
typeof mediaType !== "string" || mediaType.length === 0 || mediaType.length > 256
|
|
408
|
+
) return null;
|
|
409
|
+
return { role: role as ArtifactRole, path, bytes, sha256, mediaType };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
interface ParsedLocalTranscriptInput {
|
|
413
|
+
readonly path: string;
|
|
414
|
+
readonly bytes: number;
|
|
415
|
+
readonly sha256: string;
|
|
416
|
+
readonly normalized: {
|
|
417
|
+
readonly profile: typeof GHOSTGET_MEDIA_PCM_NORMALIZATION_PROFILE;
|
|
418
|
+
readonly bytes: number;
|
|
419
|
+
readonly sha256: string;
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function parseLocalTranscriptInput(value: unknown): ParsedLocalTranscriptInput | null {
|
|
424
|
+
const input = value;
|
|
425
|
+
if (!isRecord(input) || !hasExactKeys(input, [
|
|
426
|
+
"path",
|
|
427
|
+
"bytes",
|
|
428
|
+
"sha256",
|
|
429
|
+
"normalized",
|
|
430
|
+
])) return null;
|
|
431
|
+
const normalized = input["normalized"];
|
|
432
|
+
if (!isRecord(normalized) || !hasExactKeys(normalized, [
|
|
433
|
+
"profile",
|
|
434
|
+
"bytes",
|
|
435
|
+
"sha256",
|
|
436
|
+
])) return null;
|
|
437
|
+
const inputBytes = input["bytes"];
|
|
438
|
+
const normalizedBytes = normalized["bytes"];
|
|
439
|
+
const inputPath = input["path"];
|
|
440
|
+
const inputSha256 = parseSha256(input["sha256"]);
|
|
441
|
+
const normalizedSha256 = parseSha256(normalized["sha256"]);
|
|
442
|
+
if (
|
|
443
|
+
!safeRelativePath(inputPath)
|
|
444
|
+
|| typeof inputBytes !== "number"
|
|
445
|
+
|| !Number.isSafeInteger(inputBytes)
|
|
446
|
+
|| inputBytes <= 0
|
|
447
|
+
|| inputSha256 === null
|
|
448
|
+
|| normalized["profile"] !== GHOSTGET_MEDIA_PCM_NORMALIZATION_PROFILE
|
|
449
|
+
|| typeof normalizedBytes !== "number"
|
|
450
|
+
|| !Number.isSafeInteger(normalizedBytes)
|
|
451
|
+
|| normalizedBytes <= 0
|
|
452
|
+
|| normalizedBytes > MAX_NORMALIZED_PCM_BYTES
|
|
453
|
+
|| normalizedSha256 === null
|
|
454
|
+
) return null;
|
|
455
|
+
return {
|
|
456
|
+
path: inputPath,
|
|
457
|
+
bytes: inputBytes,
|
|
458
|
+
sha256: inputSha256,
|
|
459
|
+
normalized: {
|
|
460
|
+
profile: GHOSTGET_MEDIA_PCM_NORMALIZATION_PROFILE,
|
|
461
|
+
bytes: normalizedBytes,
|
|
462
|
+
sha256: normalizedSha256,
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function parseLocalTranscriptProvenance(
|
|
468
|
+
value: unknown,
|
|
469
|
+
): MediaLocalTranscriptProvenance | null {
|
|
470
|
+
if (!isRecord(value) || !hasExactKeys(value, [
|
|
471
|
+
"adapter",
|
|
472
|
+
"profile",
|
|
473
|
+
"executableSha256",
|
|
474
|
+
"runtimeProfile",
|
|
475
|
+
"runtimeSha256",
|
|
476
|
+
"runtimeDependencyCount",
|
|
477
|
+
"modelSha256",
|
|
478
|
+
"requestedLanguage",
|
|
479
|
+
"input",
|
|
480
|
+
])) return null;
|
|
481
|
+
const executableSha256 = parseSha256(value["executableSha256"]);
|
|
482
|
+
const runtimeSha256 = parseSha256(value["runtimeSha256"]);
|
|
483
|
+
const runtimeDependencyCount = value["runtimeDependencyCount"];
|
|
484
|
+
const modelSha256 = parseSha256(value["modelSha256"]);
|
|
485
|
+
const requestedLanguage = value["requestedLanguage"];
|
|
486
|
+
const input = parseLocalTranscriptInput(value["input"]);
|
|
487
|
+
if (
|
|
488
|
+
value["adapter"] !== "whisper-cpp"
|
|
489
|
+
|| value["profile"] !== GHOSTGET_MEDIA_WHISPER_CPP_PROFILE
|
|
490
|
+
|| executableSha256 === null
|
|
491
|
+
|| value["runtimeProfile"] !== GHOSTGET_MEDIA_RUNTIME_CLOSURE_PROFILE
|
|
492
|
+
|| runtimeSha256 === null
|
|
493
|
+
|| typeof runtimeDependencyCount !== "number"
|
|
494
|
+
|| !Number.isSafeInteger(runtimeDependencyCount)
|
|
495
|
+
|| runtimeDependencyCount < 0
|
|
496
|
+
|| runtimeDependencyCount > MAX_RUNTIME_DEPENDENCIES
|
|
497
|
+
|| modelSha256 === null
|
|
498
|
+
|| !isNormalizedTranscriptionLanguage(requestedLanguage)
|
|
499
|
+
|| input === null
|
|
500
|
+
) return null;
|
|
501
|
+
return {
|
|
502
|
+
adapter: "whisper-cpp",
|
|
503
|
+
profile: GHOSTGET_MEDIA_WHISPER_CPP_PROFILE,
|
|
504
|
+
executableSha256,
|
|
505
|
+
runtimeProfile: GHOSTGET_MEDIA_RUNTIME_CLOSURE_PROFILE,
|
|
506
|
+
runtimeSha256,
|
|
507
|
+
runtimeDependencyCount,
|
|
508
|
+
modelSha256,
|
|
509
|
+
requestedLanguage,
|
|
510
|
+
input,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function parseTranscript(value: unknown): MediaTranscript | null {
|
|
515
|
+
if (!isRecord(value)) return null;
|
|
516
|
+
if (value["status"] === "unavailable") {
|
|
517
|
+
const reason = value["reason"];
|
|
518
|
+
if (reason === "no_speech") {
|
|
519
|
+
if (!hasExactKeys(value, ["status", "reason", "provenance"])) return null;
|
|
520
|
+
const provenance = parseLocalTranscriptProvenance(value["provenance"]);
|
|
521
|
+
return provenance === null
|
|
522
|
+
? null
|
|
523
|
+
: { status: "unavailable", reason, provenance };
|
|
524
|
+
}
|
|
525
|
+
if (!hasExactKeys(value, ["status", "reason"])) return null;
|
|
526
|
+
return reason === "provider_has_no_captions"
|
|
527
|
+
|| reason === "not_requested"
|
|
528
|
+
|| reason === "transcriber_not_configured"
|
|
529
|
+
|| reason === "audio_not_present"
|
|
530
|
+
? { status: "unavailable", reason }
|
|
531
|
+
: null;
|
|
532
|
+
}
|
|
533
|
+
if (value["status"] !== "available") return null;
|
|
534
|
+
const source = value["source"];
|
|
535
|
+
const local = source === "local";
|
|
536
|
+
if (!hasExactKeys(
|
|
537
|
+
value,
|
|
538
|
+
[
|
|
539
|
+
"status",
|
|
540
|
+
"source",
|
|
541
|
+
"language",
|
|
542
|
+
"timedPath",
|
|
543
|
+
"textPath",
|
|
544
|
+
"cuesPath",
|
|
545
|
+
...(local ? ["provenance"] : []),
|
|
546
|
+
],
|
|
547
|
+
)) return null;
|
|
548
|
+
const language = value["language"];
|
|
549
|
+
const timedPath = value["timedPath"];
|
|
550
|
+
const textPath = value["textPath"];
|
|
551
|
+
const cuesPath = value["cuesPath"];
|
|
552
|
+
if (
|
|
553
|
+
(source !== "manual" && source !== "automatic" && source !== "local")
|
|
554
|
+
|| typeof language !== "string"
|
|
555
|
+
|| language.length === 0
|
|
556
|
+
|| language.length > 128
|
|
557
|
+
|| !safeRelativePath(timedPath)
|
|
558
|
+
|| !safeRelativePath(textPath)
|
|
559
|
+
|| !safeRelativePath(cuesPath)
|
|
560
|
+
|| (local && !isConcreteWhisperCppLanguage(language))
|
|
561
|
+
) return null;
|
|
562
|
+
if (!local) {
|
|
563
|
+
return { status: "available", source, language, timedPath, textPath, cuesPath };
|
|
564
|
+
}
|
|
565
|
+
const provenance = parseLocalTranscriptProvenance(value["provenance"]);
|
|
566
|
+
return provenance === null || !localTranscriptLanguagesMatch(provenance.requestedLanguage, language)
|
|
567
|
+
? null
|
|
568
|
+
: {
|
|
569
|
+
status: "available",
|
|
570
|
+
source,
|
|
571
|
+
language,
|
|
572
|
+
timedPath,
|
|
573
|
+
textPath,
|
|
574
|
+
cuesPath,
|
|
575
|
+
provenance,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function parseSource(value: unknown): MediaManifestSource | null {
|
|
580
|
+
if (!isRecord(value)) return null;
|
|
581
|
+
if (!hasExactKeys(
|
|
582
|
+
value,
|
|
583
|
+
["extractor", "id", "canonicalUrl"],
|
|
584
|
+
["title", "uploader", "channel", "license", "uploadDate", "timestamp", "durationSeconds"],
|
|
585
|
+
)) return null;
|
|
586
|
+
const extractor = stringValue(value, "extractor", 512);
|
|
587
|
+
const id = stringValue(value, "id", 512);
|
|
588
|
+
const canonicalUrl = stringValue(value, "canonicalUrl", 8_192);
|
|
589
|
+
if (extractor === undefined || id === undefined || canonicalUrl === undefined) return null;
|
|
590
|
+
try {
|
|
591
|
+
const parsed = new URL(canonicalUrl);
|
|
592
|
+
if ((parsed.protocol !== "https:" && parsed.protocol !== "http:") || parsed.username !== "" || parsed.password !== "") return null;
|
|
593
|
+
} catch {
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
const optionalString = (key: string, maximum: number): string | undefined => stringValue(value, key, maximum);
|
|
597
|
+
const optionalNumber = (key: string): number | undefined => {
|
|
598
|
+
const candidate = value[key];
|
|
599
|
+
return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : undefined;
|
|
600
|
+
};
|
|
601
|
+
const title = optionalString("title", 2_048);
|
|
602
|
+
const uploader = optionalString("uploader", 1_024);
|
|
603
|
+
const channel = optionalString("channel", 1_024);
|
|
604
|
+
const license = optionalString("license", 1_024);
|
|
605
|
+
const uploadDate = optionalString("uploadDate", 32);
|
|
606
|
+
const timestamp = optionalNumber("timestamp");
|
|
607
|
+
const durationSeconds = optionalNumber("durationSeconds");
|
|
608
|
+
const optionalStrings = { title, uploader, channel, license, uploadDate } as const;
|
|
609
|
+
for (const [key, parsed] of Object.entries(optionalStrings)) {
|
|
610
|
+
if (Object.hasOwn(value, key) && parsed === undefined) return null;
|
|
611
|
+
}
|
|
612
|
+
if (Object.hasOwn(value, "timestamp") && timestamp === undefined) return null;
|
|
613
|
+
if (Object.hasOwn(value, "durationSeconds") && durationSeconds === undefined) return null;
|
|
614
|
+
return {
|
|
615
|
+
extractor,
|
|
616
|
+
id,
|
|
617
|
+
canonicalUrl,
|
|
618
|
+
...(title === undefined ? {} : { title }),
|
|
619
|
+
...(uploader === undefined ? {} : { uploader }),
|
|
620
|
+
...(channel === undefined ? {} : { channel }),
|
|
621
|
+
...(license === undefined ? {} : { license }),
|
|
622
|
+
...(uploadDate === undefined ? {} : { uploadDate }),
|
|
623
|
+
...(timestamp === undefined ? {} : { timestamp }),
|
|
624
|
+
...(durationSeconds === undefined ? {} : { durationSeconds }),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const directHttpContainers: ReadonlySet<string> = new Set([
|
|
629
|
+
"iso-bmff",
|
|
630
|
+
"matroska",
|
|
631
|
+
"webm",
|
|
632
|
+
"ogg",
|
|
633
|
+
"flac",
|
|
634
|
+
"wave",
|
|
635
|
+
"mp3",
|
|
636
|
+
"mpeg-ts",
|
|
637
|
+
]);
|
|
638
|
+
|
|
639
|
+
function parseSha256(value: unknown): string | null {
|
|
640
|
+
return typeof value === "string" && SHA256_PATTERN.test(value) ? value : null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function parseDirectHttpValidator(value: unknown): MediaDirectHttpValidator | null {
|
|
644
|
+
if (!isRecord(value)) return null;
|
|
645
|
+
if (value["strength"] === "absent") {
|
|
646
|
+
return hasExactKeys(value, ["strength"]) ? { strength: "absent" } : null;
|
|
647
|
+
}
|
|
648
|
+
if (value["strength"] !== "weak" && value["strength"] !== "strong") return null;
|
|
649
|
+
if (!hasExactKeys(value, ["strength", "sha256"])) return null;
|
|
650
|
+
const sha256 = parseSha256(value["sha256"]);
|
|
651
|
+
return sha256 === null ? null : { strength: value["strength"], sha256 };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function isNormalizedLastModified(value: string): boolean {
|
|
655
|
+
if (value.length !== 29 || !/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), [0-9]{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} [0-9]{2}:[0-9]{2}:[0-9]{2} GMT$/u.test(value)) {
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
const parsed = new Date(value);
|
|
659
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toUTCString() === value;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function isNormalizedMediaType(value: string): boolean {
|
|
663
|
+
return value.length <= 256 && NORMALIZED_MEDIA_TYPE_PATTERN.test(value);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function isDirectHttpContainer(value: unknown): value is MediaDirectHttpContainer {
|
|
667
|
+
return typeof value === "string" && directHttpContainers.has(value);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function parseMediaDirectHttpProvenance(value: unknown): MediaDirectHttpProvenance | null {
|
|
671
|
+
if (!isRecord(value) || !hasExactKeys(value, [
|
|
672
|
+
"requestedUrlSha256",
|
|
673
|
+
"effectiveUrlSha256",
|
|
674
|
+
"validator",
|
|
675
|
+
"lastModified",
|
|
676
|
+
"declaredMediaType",
|
|
677
|
+
"container",
|
|
678
|
+
"body",
|
|
679
|
+
"redirectCount",
|
|
680
|
+
])) return null;
|
|
681
|
+
const requestedUrlSha256 = parseSha256(value["requestedUrlSha256"]);
|
|
682
|
+
const effectiveUrlSha256 = parseSha256(value["effectiveUrlSha256"]);
|
|
683
|
+
const validator = parseDirectHttpValidator(value["validator"]);
|
|
684
|
+
const lastModified = value["lastModified"];
|
|
685
|
+
const declaredMediaType = value["declaredMediaType"];
|
|
686
|
+
const container = value["container"];
|
|
687
|
+
const body = value["body"];
|
|
688
|
+
const redirectCount = value["redirectCount"];
|
|
689
|
+
const bodyBytes = isRecord(body) ? body["bytes"] : undefined;
|
|
690
|
+
const bodySha256 = isRecord(body) ? parseSha256(body["sha256"]) : null;
|
|
691
|
+
if (
|
|
692
|
+
requestedUrlSha256 === null
|
|
693
|
+
|| effectiveUrlSha256 === null
|
|
694
|
+
|| validator === null
|
|
695
|
+
|| (lastModified !== null && (typeof lastModified !== "string" || !isNormalizedLastModified(lastModified)))
|
|
696
|
+
|| (declaredMediaType !== null && (typeof declaredMediaType !== "string" || !isNormalizedMediaType(declaredMediaType)))
|
|
697
|
+
|| !isDirectHttpContainer(container)
|
|
698
|
+
|| !isRecord(body)
|
|
699
|
+
|| !hasExactKeys(body, ["bytes", "sha256"])
|
|
700
|
+
|| typeof bodyBytes !== "number"
|
|
701
|
+
|| !Number.isSafeInteger(bodyBytes)
|
|
702
|
+
|| bodyBytes < 0
|
|
703
|
+
|| bodyBytes > MAX_DIRECT_HTTP_BODY_BYTES
|
|
704
|
+
|| bodySha256 === null
|
|
705
|
+
|| typeof redirectCount !== "number"
|
|
706
|
+
|| !Number.isSafeInteger(redirectCount)
|
|
707
|
+
|| redirectCount < 0
|
|
708
|
+
|| redirectCount > MAX_DIRECT_HTTP_REDIRECTS
|
|
709
|
+
) return null;
|
|
710
|
+
return {
|
|
711
|
+
requestedUrlSha256,
|
|
712
|
+
effectiveUrlSha256,
|
|
713
|
+
validator,
|
|
714
|
+
lastModified,
|
|
715
|
+
declaredMediaType,
|
|
716
|
+
container,
|
|
717
|
+
body: { bytes: bodyBytes, sha256: bodySha256 },
|
|
718
|
+
redirectCount,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function parsePublicYtDlpIdentity(value: unknown): MediaYtDlpYouTubeIdentity | OpaqueYtDlpIdentity | null {
|
|
723
|
+
if (!isRecord(value)) return null;
|
|
724
|
+
const profile = value["profile"];
|
|
725
|
+
const providerDigest = parseSha256(value["providerIdentitySha256"]);
|
|
726
|
+
if (
|
|
727
|
+
profile === GHOSTGET_MEDIA_YT_DLP_YOUTUBE_IDENTITY_PROFILE
|
|
728
|
+
&& hasExactKeys(value, ["profile", "providerIdentitySha256"])
|
|
729
|
+
) {
|
|
730
|
+
return providerDigest === null
|
|
731
|
+
? null
|
|
732
|
+
: {
|
|
733
|
+
profile: GHOSTGET_MEDIA_YT_DLP_YOUTUBE_IDENTITY_PROFILE,
|
|
734
|
+
providerIdentitySha256: providerDigest,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
if (
|
|
738
|
+
profile !== YT_DLP_OPAQUE_IDENTITY_PROFILE
|
|
739
|
+
|| !hasExactKeys(value, [
|
|
740
|
+
"profile",
|
|
741
|
+
"providerIdentitySha256",
|
|
742
|
+
"requestedUrlSha256",
|
|
743
|
+
])
|
|
744
|
+
) return null;
|
|
745
|
+
const requestedUrlDigest = parseSha256(value["requestedUrlSha256"]);
|
|
746
|
+
return providerDigest === null || requestedUrlDigest === null
|
|
747
|
+
? null
|
|
748
|
+
: {
|
|
749
|
+
profile: YT_DLP_OPAQUE_IDENTITY_PROFILE,
|
|
750
|
+
providerIdentitySha256: providerDigest,
|
|
751
|
+
requestedUrlSha256: requestedUrlDigest,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function parseAuthenticatedYtDlpIdentity(value: unknown): AuthenticatedYtDlpIdentity | null {
|
|
756
|
+
if (
|
|
757
|
+
!isRecord(value)
|
|
758
|
+
|| value["profile"] !== YT_DLP_AUTH_IDENTITY_PROFILE
|
|
759
|
+
|| !hasExactKeys(value, [
|
|
760
|
+
"profile",
|
|
761
|
+
"providerIdentitySha256",
|
|
762
|
+
"requestedUrlSha256",
|
|
763
|
+
"accessMode",
|
|
764
|
+
"authContext",
|
|
765
|
+
])
|
|
766
|
+
) return null;
|
|
767
|
+
const providerDigest = parseSha256(value["providerIdentitySha256"]);
|
|
768
|
+
const requestedUrlDigest = parseSha256(value["requestedUrlSha256"]);
|
|
769
|
+
const accessMode = value["accessMode"];
|
|
770
|
+
const authContext = value["authContext"];
|
|
771
|
+
if (
|
|
772
|
+
providerDigest === null
|
|
773
|
+
|| requestedUrlDigest === null
|
|
774
|
+
|| (accessMode !== "browser" && accessMode !== "ambient_config")
|
|
775
|
+
|| !isRecord(authContext)
|
|
776
|
+
|| !hasExactKeys(authContext, ["profile", "sha256"])
|
|
777
|
+
|| authContext["profile"] !== AUTH_CONTEXT_IDENTITY_PROFILE
|
|
778
|
+
) return null;
|
|
779
|
+
const authContextDigest = parseSha256(authContext["sha256"]);
|
|
780
|
+
return authContextDigest === null
|
|
781
|
+
? null
|
|
782
|
+
: {
|
|
783
|
+
profile: YT_DLP_AUTH_IDENTITY_PROFILE,
|
|
784
|
+
providerIdentitySha256: providerDigest,
|
|
785
|
+
requestedUrlSha256: requestedUrlDigest,
|
|
786
|
+
accessMode,
|
|
787
|
+
authContext: {
|
|
788
|
+
profile: AUTH_CONTEXT_IDENTITY_PROFILE,
|
|
789
|
+
sha256: authContextDigest,
|
|
790
|
+
},
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function parseCurrentYtDlpIdentity(value: unknown): MediaYtDlpIdentity | null {
|
|
795
|
+
return parseAuthenticatedYtDlpIdentity(value) ?? parsePublicYtDlpIdentity(value);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function parseAcquisition(value: unknown): MediaYtDlpAcquisition | MediaDirectHttpAcquisition | null {
|
|
799
|
+
if (!isRecord(value)) return null;
|
|
800
|
+
if (value["adapter"] === "yt-dlp") {
|
|
801
|
+
if (!hasExactKeys(value, ["adapter", "version", "identity"])) return null;
|
|
802
|
+
const version = stringValue(value, "version", 256);
|
|
803
|
+
const identity = parseCurrentYtDlpIdentity(value["identity"]);
|
|
804
|
+
return version === undefined || identity === null
|
|
805
|
+
? null
|
|
806
|
+
: { adapter: "yt-dlp", version, identity };
|
|
807
|
+
}
|
|
808
|
+
if (value["adapter"] !== "direct-http" || !hasExactKeys(value, ["adapter", "provenance"])) return null;
|
|
809
|
+
const provenance = parseMediaDirectHttpProvenance(value["provenance"]);
|
|
810
|
+
return provenance === null ? null : { adapter: "direct-http", provenance };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function parseAuthentication(value: unknown): MediaAuthentication | null {
|
|
814
|
+
if (!isRecord(value)) return null;
|
|
815
|
+
if (value["mode"] === "public") {
|
|
816
|
+
return hasExactKeys(value, ["mode"]) ? { mode: "public" } : null;
|
|
817
|
+
}
|
|
818
|
+
const mode = value["mode"];
|
|
819
|
+
const context = value["context"];
|
|
820
|
+
if (
|
|
821
|
+
(mode !== "browser" && mode !== "ambient_config")
|
|
822
|
+
|| !hasExactKeys(value, ["mode", "context"])
|
|
823
|
+
|| !isRecord(context)
|
|
824
|
+
|| !hasExactKeys(context, ["profile", "sha256"])
|
|
825
|
+
|| context["profile"] !== AUTH_CONTEXT_IDENTITY_PROFILE
|
|
826
|
+
) return null;
|
|
827
|
+
const sha256 = parseSha256(context["sha256"]);
|
|
828
|
+
return sha256 === null
|
|
829
|
+
? null
|
|
830
|
+
: {
|
|
831
|
+
mode,
|
|
832
|
+
context: { profile: AUTH_CONTEXT_IDENTITY_PROFILE, sha256 },
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function parseTrackedRevision(value: unknown): MediaTrackedRevision | null {
|
|
837
|
+
if (
|
|
838
|
+
!isRecord(value)
|
|
839
|
+
|| value["profile"] !== GHOSTGET_MEDIA_TRACKED_REVISION_PROFILE
|
|
840
|
+
|| !hasExactKeys(
|
|
841
|
+
value,
|
|
842
|
+
["profile", "sequence", "subjectAssetKey", "content"],
|
|
843
|
+
["previousAssetKey"],
|
|
844
|
+
)
|
|
845
|
+
) return null;
|
|
846
|
+
const sequence = value["sequence"];
|
|
847
|
+
const subjectAssetKey = stringValue(value, "subjectAssetKey", 256);
|
|
848
|
+
const previousAssetKey = stringValue(value, "previousAssetKey", 256);
|
|
849
|
+
const content = value["content"];
|
|
850
|
+
if (
|
|
851
|
+
typeof sequence !== "number"
|
|
852
|
+
|| !Number.isSafeInteger(sequence)
|
|
853
|
+
|| sequence < 1
|
|
854
|
+
|| subjectAssetKey === undefined
|
|
855
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u.test(subjectAssetKey)
|
|
856
|
+
|| (Object.hasOwn(value, "previousAssetKey")
|
|
857
|
+
&& (
|
|
858
|
+
previousAssetKey === undefined
|
|
859
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u.test(previousAssetKey)
|
|
860
|
+
))
|
|
861
|
+
|| !isRecord(content)
|
|
862
|
+
|| !hasExactKeys(content, ["profile", "sha256"])
|
|
863
|
+
|| content["profile"] !== GHOSTGET_MEDIA_REVISION_CONTENT_PROFILE
|
|
864
|
+
) return null;
|
|
865
|
+
const contentSha256 = parseSha256(content["sha256"]);
|
|
866
|
+
if (contentSha256 === null) return null;
|
|
867
|
+
const revision: MediaTrackedRevision = {
|
|
868
|
+
profile: GHOSTGET_MEDIA_TRACKED_REVISION_PROFILE,
|
|
869
|
+
sequence,
|
|
870
|
+
subjectAssetKey,
|
|
871
|
+
...(previousAssetKey === undefined ? {} : { previousAssetKey }),
|
|
872
|
+
content: {
|
|
873
|
+
profile: GHOSTGET_MEDIA_REVISION_CONTENT_PROFILE,
|
|
874
|
+
sha256: contentSha256,
|
|
875
|
+
},
|
|
876
|
+
};
|
|
877
|
+
try {
|
|
878
|
+
trackedRevisionAssetKey(revision);
|
|
879
|
+
return revision;
|
|
880
|
+
} catch {
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function isOriginOnlyUrl(value: string): boolean {
|
|
886
|
+
try {
|
|
887
|
+
const parsed = new URL(value);
|
|
888
|
+
return (parsed.protocol === "https:" || parsed.protocol === "http:")
|
|
889
|
+
&& parsed.username === ""
|
|
890
|
+
&& parsed.password === ""
|
|
891
|
+
&& parsed.pathname === "/"
|
|
892
|
+
&& parsed.search === ""
|
|
893
|
+
&& parsed.hash === ""
|
|
894
|
+
&& parsed.href === `${parsed.origin}/`;
|
|
895
|
+
} catch {
|
|
896
|
+
return false;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function localTranscriptProvenance(
|
|
901
|
+
transcript: MediaTranscript,
|
|
902
|
+
): MediaLocalTranscriptProvenance | null {
|
|
903
|
+
if (transcript.status === "available") {
|
|
904
|
+
return transcript.source === "local" ? transcript.provenance : null;
|
|
905
|
+
}
|
|
906
|
+
return transcript.reason === "no_speech" ? transcript.provenance : null;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function localTranscriptIdentity(
|
|
910
|
+
provenance: MediaLocalTranscriptProvenance,
|
|
911
|
+
): MediaLocalTranscriptIdentity {
|
|
912
|
+
return {
|
|
913
|
+
adapter: provenance.adapter,
|
|
914
|
+
profile: provenance.profile,
|
|
915
|
+
executableSha256: provenance.executableSha256,
|
|
916
|
+
runtimeProfile: provenance.runtimeProfile,
|
|
917
|
+
runtimeSha256: provenance.runtimeSha256,
|
|
918
|
+
runtimeDependencyCount: provenance.runtimeDependencyCount,
|
|
919
|
+
modelSha256: provenance.modelSha256,
|
|
920
|
+
normalizationProfile: provenance.input.normalized.profile,
|
|
921
|
+
requestedLanguage: provenance.requestedLanguage,
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function projectedLocalTranscriptAssetKey(
|
|
926
|
+
baseSourceAssetKey: string,
|
|
927
|
+
provenance: MediaLocalTranscriptProvenance | null,
|
|
928
|
+
): string | null {
|
|
929
|
+
return provenance === null
|
|
930
|
+
? null
|
|
931
|
+
: localTranscriptVariantAssetKey(
|
|
932
|
+
baseSourceAssetKey,
|
|
933
|
+
localTranscriptIdentity(provenance),
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function youtubeCanonicalUrl(videoId: string): string {
|
|
938
|
+
const url = new URL("https://www.youtube.com/watch");
|
|
939
|
+
url.searchParams.set("v", videoId);
|
|
940
|
+
return url.href;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function currentYtDlpSourceAssetKey(
|
|
944
|
+
source: MediaManifestSource,
|
|
945
|
+
identity: MediaYtDlpIdentity,
|
|
946
|
+
): string | null {
|
|
947
|
+
if (identity.profile === GHOSTGET_MEDIA_YT_DLP_YOUTUBE_IDENTITY_PROFILE) {
|
|
948
|
+
if (
|
|
949
|
+
source.extractor !== "Youtube"
|
|
950
|
+
|| !OWNED_YOUTUBE_ID_PATTERN.test(source.id)
|
|
951
|
+
|| source.canonicalUrl !== youtubeCanonicalUrl(source.id)
|
|
952
|
+
|| identity.providerIdentitySha256 !== providerIdentitySha256("Youtube", source.id)
|
|
953
|
+
) return null;
|
|
954
|
+
return sourceAssetKey("Youtube", source.id);
|
|
955
|
+
}
|
|
956
|
+
if (identity.profile === YT_DLP_OPAQUE_IDENTITY_PROFILE) {
|
|
957
|
+
let baseSourceAssetKey: string;
|
|
958
|
+
try {
|
|
959
|
+
baseSourceAssetKey = opaqueYtDlpSourceAssetKey(identity);
|
|
960
|
+
} catch {
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
return source.extractor === "External"
|
|
964
|
+
&& source.id === opaqueYtDlpSourceId(baseSourceAssetKey)
|
|
965
|
+
&& isOriginOnlyUrl(source.canonicalUrl)
|
|
966
|
+
&& !sourceHasDescription(source)
|
|
967
|
+
? baseSourceAssetKey
|
|
968
|
+
: null;
|
|
969
|
+
}
|
|
970
|
+
let baseSourceAssetKey: string;
|
|
971
|
+
try {
|
|
972
|
+
baseSourceAssetKey = authenticatedYtDlpSourceAssetKey(identity);
|
|
973
|
+
} catch {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
return source.extractor === "External"
|
|
977
|
+
&& source.id === authenticatedYtDlpSourceId(baseSourceAssetKey)
|
|
978
|
+
&& isOriginOnlyUrl(source.canonicalUrl)
|
|
979
|
+
&& !sourceHasDescription(source)
|
|
980
|
+
? baseSourceAssetKey
|
|
981
|
+
: null;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function currentAuthenticationMatchesIdentity(
|
|
985
|
+
authentication: MediaAuthentication,
|
|
986
|
+
identity: MediaYtDlpIdentity,
|
|
987
|
+
): boolean {
|
|
988
|
+
if (identity.profile !== YT_DLP_AUTH_IDENTITY_PROFILE) {
|
|
989
|
+
return authentication.mode === "public";
|
|
990
|
+
}
|
|
991
|
+
return authentication.mode === identity.accessMode
|
|
992
|
+
&& authentication.context.profile === identity.authContext.profile
|
|
993
|
+
&& authentication.context.sha256 === identity.authContext.sha256;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function currentYtDlpAssetKey(
|
|
997
|
+
baseSourceAssetKey: string,
|
|
998
|
+
mode: CaptureMode,
|
|
999
|
+
transcript: MediaTranscript,
|
|
1000
|
+
): string | null {
|
|
1001
|
+
if (mode === "archive") return baseSourceAssetKey;
|
|
1002
|
+
if (mode === "audio" || mode === "video") {
|
|
1003
|
+
return variantAssetKey(baseSourceAssetKey, [mode]);
|
|
1004
|
+
}
|
|
1005
|
+
if (transcript.status !== "available") return null;
|
|
1006
|
+
return transcript.source === "local"
|
|
1007
|
+
? localTranscriptVariantAssetKey(
|
|
1008
|
+
baseSourceAssetKey,
|
|
1009
|
+
localTranscriptIdentity(transcript.provenance),
|
|
1010
|
+
)
|
|
1011
|
+
: variantAssetKey(baseSourceAssetKey, [
|
|
1012
|
+
"transcript",
|
|
1013
|
+
transcript.source,
|
|
1014
|
+
boundedTranscriptLanguageSegment(transcript.language),
|
|
1015
|
+
]);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function transcriptModeIsValid(
|
|
1019
|
+
mode: CaptureMode,
|
|
1020
|
+
transcript: MediaTranscript,
|
|
1021
|
+
captureCount: number,
|
|
1022
|
+
videoCount: number,
|
|
1023
|
+
audioCount: number,
|
|
1024
|
+
): boolean {
|
|
1025
|
+
if (mode === "audio" || mode === "video") {
|
|
1026
|
+
return captureCount === 1
|
|
1027
|
+
&& audioCount === (mode === "audio" ? 1 : 0)
|
|
1028
|
+
&& videoCount === (mode === "video" ? 1 : 0)
|
|
1029
|
+
&& transcript.status === "unavailable"
|
|
1030
|
+
&& transcript.reason === "not_requested";
|
|
1031
|
+
}
|
|
1032
|
+
if (mode === "transcript") {
|
|
1033
|
+
if (transcript.status !== "available") return false;
|
|
1034
|
+
return transcript.source === "local"
|
|
1035
|
+
? captureCount === 1 && audioCount === 1 && videoCount === 0
|
|
1036
|
+
: captureCount === 0 && audioCount === 0 && videoCount === 0;
|
|
1037
|
+
}
|
|
1038
|
+
if (captureCount !== 1 || transcript.status === "unavailable" && transcript.reason === "not_requested") {
|
|
1039
|
+
return false;
|
|
1040
|
+
}
|
|
1041
|
+
if (transcript.status === "available" && transcript.source === "local") {
|
|
1042
|
+
return audioCount === 1;
|
|
1043
|
+
}
|
|
1044
|
+
if (transcript.status === "unavailable") {
|
|
1045
|
+
if (transcript.reason === "no_speech") return audioCount === 1;
|
|
1046
|
+
if (transcript.reason === "audio_not_present") {
|
|
1047
|
+
return audioCount === 0 && videoCount === 1;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return videoCount === 1 || audioCount === 1;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function sourceHasDescription(source: MediaManifestSource): boolean {
|
|
1054
|
+
return source.title !== undefined
|
|
1055
|
+
|| source.uploader !== undefined
|
|
1056
|
+
|| source.channel !== undefined
|
|
1057
|
+
|| source.license !== undefined
|
|
1058
|
+
|| source.uploadDate !== undefined
|
|
1059
|
+
|| source.timestamp !== undefined
|
|
1060
|
+
|| source.durationSeconds !== undefined;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
export function parseMediaManifest(value: unknown): ParseManifestResult {
|
|
1064
|
+
if (!isRecord(value) || value["schemaVersion"] !== GHOSTGET_MEDIA_SCHEMA_VERSION) {
|
|
1065
|
+
return { ok: false, message: "unsupported or missing Ghostget media manifest schema" };
|
|
1066
|
+
}
|
|
1067
|
+
const acquisition = parseAcquisition(value["acquisition"]);
|
|
1068
|
+
const ytDlpAcquisition = acquisition?.adapter === "yt-dlp" ? acquisition : null;
|
|
1069
|
+
const directHttpAcquisition = acquisition?.adapter === "direct-http" ? acquisition : null;
|
|
1070
|
+
if (!hasExactKeys(
|
|
1071
|
+
value,
|
|
1072
|
+
[
|
|
1073
|
+
"schemaVersion",
|
|
1074
|
+
"wrenchVersion",
|
|
1075
|
+
"assetKey",
|
|
1076
|
+
"capturedAt",
|
|
1077
|
+
"mode",
|
|
1078
|
+
"source",
|
|
1079
|
+
"authentication",
|
|
1080
|
+
"acquisition",
|
|
1081
|
+
"tools",
|
|
1082
|
+
"artifacts",
|
|
1083
|
+
"transcript",
|
|
1084
|
+
...(ytDlpAcquisition === null ? [] : ["revision"]),
|
|
1085
|
+
],
|
|
1086
|
+
)) return { ok: false, message: "Ghostget media manifest has an invalid top-level contract" };
|
|
1087
|
+
const wrenchVersion = stringValue(value, "wrenchVersion", 64);
|
|
1088
|
+
const assetKey = stringValue(value, "assetKey", 256);
|
|
1089
|
+
const capturedAt = stringValue(value, "capturedAt", 64);
|
|
1090
|
+
const mode = value["mode"];
|
|
1091
|
+
const source = parseSource(value["source"]);
|
|
1092
|
+
const authentication = parseAuthentication(value["authentication"]);
|
|
1093
|
+
const tools = value["tools"];
|
|
1094
|
+
const transcript = parseTranscript(value["transcript"]);
|
|
1095
|
+
const trackedRevision = ytDlpAcquisition === null
|
|
1096
|
+
? null
|
|
1097
|
+
: parseTrackedRevision(value["revision"]);
|
|
1098
|
+
const artifactValues = value["artifacts"];
|
|
1099
|
+
if (
|
|
1100
|
+
wrenchVersion === undefined
|
|
1101
|
+
|| assetKey === undefined
|
|
1102
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u.test(assetKey)
|
|
1103
|
+
|| capturedAt === undefined
|
|
1104
|
+
|| Number.isNaN(Date.parse(capturedAt))
|
|
1105
|
+
|| (mode !== "archive" && mode !== "audio" && mode !== "video" && mode !== "transcript")
|
|
1106
|
+
|| source === null
|
|
1107
|
+
|| authentication === null
|
|
1108
|
+
|| acquisition === null
|
|
1109
|
+
|| !isRecord(tools)
|
|
1110
|
+
|| transcript === null
|
|
1111
|
+
|| !Array.isArray(artifactValues)
|
|
1112
|
+
|| artifactValues.length > 128
|
|
1113
|
+
) return { ok: false, message: "Ghostget media manifest has an invalid top-level contract" };
|
|
1114
|
+
const ffmpeg = stringValue(tools, "ffmpeg", 256);
|
|
1115
|
+
const ffprobe = stringValue(tools, "ffprobe", 256);
|
|
1116
|
+
if (
|
|
1117
|
+
!hasExactKeys(tools, [], ["ffmpeg", "ffprobe"])
|
|
1118
|
+
|| (Object.hasOwn(tools, "ffmpeg") && ffmpeg === undefined)
|
|
1119
|
+
|| (Object.hasOwn(tools, "ffprobe") && ffprobe === undefined)
|
|
1120
|
+
|| (ytDlpAcquisition !== null && trackedRevision === null)
|
|
1121
|
+
) {
|
|
1122
|
+
return { ok: false, message: "Ghostget media manifest has an invalid acquisition contract" };
|
|
1123
|
+
}
|
|
1124
|
+
if (directHttpAcquisition !== null && authentication.mode !== "public") {
|
|
1125
|
+
return { ok: false, message: "Ghostget media direct HTTP acquisition must use public authentication" };
|
|
1126
|
+
}
|
|
1127
|
+
if (directHttpAcquisition !== null && !isOriginOnlyUrl(source.canonicalUrl)) {
|
|
1128
|
+
return { ok: false, message: "Ghostget media direct HTTP source URL must be an origin-only public projection" };
|
|
1129
|
+
}
|
|
1130
|
+
const artifacts: MediaArtifact[] = [];
|
|
1131
|
+
const paths = new Set<string>();
|
|
1132
|
+
for (const artifactValue of artifactValues) {
|
|
1133
|
+
const artifact = parseArtifact(artifactValue);
|
|
1134
|
+
if (artifact === null || paths.has(artifact.path)) return { ok: false, message: "Ghostget media manifest has an invalid or duplicate artifact" };
|
|
1135
|
+
paths.add(artifact.path);
|
|
1136
|
+
artifacts.push(artifact);
|
|
1137
|
+
}
|
|
1138
|
+
const artifactsByPath = new Map(artifacts.map((artifact) => [artifact.path, artifact] as const));
|
|
1139
|
+
const roleCount = (role: ArtifactRole): number => artifacts.filter((artifact) => artifact.role === role).length;
|
|
1140
|
+
for (const role of artifactRoles) {
|
|
1141
|
+
if (roleCount(role) > 1) {
|
|
1142
|
+
return { ok: false, message: "Ghostget media manifest has duplicate singleton artifact roles" };
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
if (roleCount("provider_metadata") !== 1) {
|
|
1146
|
+
return { ok: false, message: "Ghostget media manifest must contain exactly one provider metadata artifact" };
|
|
1147
|
+
}
|
|
1148
|
+
if (transcript.status === "available") {
|
|
1149
|
+
const references = [transcript.timedPath, transcript.textPath, transcript.cuesPath] as const;
|
|
1150
|
+
if (!references.every((path) => paths.has(path))) {
|
|
1151
|
+
return { ok: false, message: "Ghostget media transcript references an unrecorded artifact" };
|
|
1152
|
+
}
|
|
1153
|
+
if (
|
|
1154
|
+
new Set(references).size !== references.length
|
|
1155
|
+
|| artifactsByPath.get(transcript.timedPath)?.role !== "transcript_vtt"
|
|
1156
|
+
|| artifactsByPath.get(transcript.textPath)?.role !== "transcript_text"
|
|
1157
|
+
|| artifactsByPath.get(transcript.cuesPath)?.role !== "transcript_json"
|
|
1158
|
+
) {
|
|
1159
|
+
return { ok: false, message: "Ghostget media transcript paths do not map to their exact artifact roles" };
|
|
1160
|
+
}
|
|
1161
|
+
} else if (
|
|
1162
|
+
roleCount("transcript_vtt") !== 0
|
|
1163
|
+
|| roleCount("transcript_text") !== 0
|
|
1164
|
+
|| roleCount("transcript_json") !== 0
|
|
1165
|
+
) {
|
|
1166
|
+
return { ok: false, message: "Ghostget media unavailable transcript must not have transcript artifacts" };
|
|
1167
|
+
}
|
|
1168
|
+
const captureCount = roleCount("capture");
|
|
1169
|
+
const videoCount = roleCount("video");
|
|
1170
|
+
const audioCount = roleCount("audio");
|
|
1171
|
+
if (!transcriptModeIsValid(mode, transcript, captureCount, videoCount, audioCount)) {
|
|
1172
|
+
return { ok: false, message: "Ghostget media manifest artifacts do not satisfy its capture mode" };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const localProvenance = localTranscriptProvenance(transcript);
|
|
1176
|
+
if (localProvenance !== null) {
|
|
1177
|
+
const inputArtifact = artifactsByPath.get(localProvenance.input.path);
|
|
1178
|
+
if (
|
|
1179
|
+
inputArtifact?.role !== "audio"
|
|
1180
|
+
|| inputArtifact.bytes !== localProvenance.input.bytes
|
|
1181
|
+
|| inputArtifact.sha256 !== localProvenance.input.sha256
|
|
1182
|
+
) {
|
|
1183
|
+
return {
|
|
1184
|
+
ok: false,
|
|
1185
|
+
message: "Ghostget media local transcript provenance does not match its audio artifact",
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
const localAttempt = localProvenance !== null
|
|
1190
|
+
|| transcript.status === "unavailable" && transcript.reason === "audio_not_present";
|
|
1191
|
+
if (localAttempt && (ffmpeg === undefined || ffprobe === undefined)) {
|
|
1192
|
+
return {
|
|
1193
|
+
ok: false,
|
|
1194
|
+
message: "Ghostget media local transcript requires FFmpeg and ffprobe provenance",
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
if (ytDlpAcquisition !== null && trackedRevision !== null) {
|
|
1198
|
+
const baseSourceAssetKey = currentAuthenticationMatchesIdentity(
|
|
1199
|
+
authentication,
|
|
1200
|
+
ytDlpAcquisition.identity,
|
|
1201
|
+
)
|
|
1202
|
+
? currentYtDlpSourceAssetKey(source, ytDlpAcquisition.identity)
|
|
1203
|
+
: null;
|
|
1204
|
+
let subjectAssetKey: string | null = null;
|
|
1205
|
+
if (baseSourceAssetKey !== null) {
|
|
1206
|
+
try {
|
|
1207
|
+
subjectAssetKey = currentYtDlpAssetKey(baseSourceAssetKey, mode, transcript);
|
|
1208
|
+
} catch {
|
|
1209
|
+
subjectAssetKey = null;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
let contentSha256: string | null = null;
|
|
1213
|
+
try {
|
|
1214
|
+
contentSha256 = revisionContentSha256(artifacts);
|
|
1215
|
+
} catch {
|
|
1216
|
+
contentSha256 = null;
|
|
1217
|
+
}
|
|
1218
|
+
const predecessorIsValid = trackedRevision.sequence === 1
|
|
1219
|
+
? trackedRevision.previousAssetKey === undefined
|
|
1220
|
+
: trackedRevision.previousAssetKey !== undefined
|
|
1221
|
+
&& /^revision-v1-[0-9a-f]{64}$/u.test(trackedRevision.previousAssetKey);
|
|
1222
|
+
let expectedRevisionAssetKey: string | null = null;
|
|
1223
|
+
try {
|
|
1224
|
+
expectedRevisionAssetKey = trackedRevisionAssetKey(trackedRevision);
|
|
1225
|
+
} catch {
|
|
1226
|
+
expectedRevisionAssetKey = null;
|
|
1227
|
+
}
|
|
1228
|
+
if (
|
|
1229
|
+
subjectAssetKey === null
|
|
1230
|
+
|| trackedRevision.subjectAssetKey !== subjectAssetKey
|
|
1231
|
+
|| trackedRevision.content.sha256 !== contentSha256
|
|
1232
|
+
|| !predecessorIsValid
|
|
1233
|
+
|| assetKey !== expectedRevisionAssetKey
|
|
1234
|
+
) {
|
|
1235
|
+
return {
|
|
1236
|
+
ok: false,
|
|
1237
|
+
message: "Ghostget media tracked revision identity projection is inconsistent",
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
if (directHttpAcquisition !== null) {
|
|
1243
|
+
const captureArtifact = artifacts.find((artifact) => artifact.role === "capture");
|
|
1244
|
+
if (
|
|
1245
|
+
captureArtifact === undefined
|
|
1246
|
+
|| directHttpAcquisition.provenance.body.bytes !== captureArtifact.bytes
|
|
1247
|
+
|| directHttpAcquisition.provenance.body.sha256 !== captureArtifact.sha256
|
|
1248
|
+
) {
|
|
1249
|
+
return {
|
|
1250
|
+
ok: false,
|
|
1251
|
+
message: "Ghostget media direct HTTP provenance does not match its capture artifact",
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
let expectedMetadata;
|
|
1255
|
+
try {
|
|
1256
|
+
expectedMetadata = createDirectHttpMetadata({
|
|
1257
|
+
requestedOrigin: source.canonicalUrl,
|
|
1258
|
+
requestedUrlSha256: directHttpAcquisition.provenance.requestedUrlSha256,
|
|
1259
|
+
bodySha256: directHttpAcquisition.provenance.body.sha256,
|
|
1260
|
+
});
|
|
1261
|
+
} catch {
|
|
1262
|
+
return { ok: false, message: "Ghostget media direct HTTP identity projection is invalid" };
|
|
1263
|
+
}
|
|
1264
|
+
let expectedAssetKey: string | null;
|
|
1265
|
+
try {
|
|
1266
|
+
expectedAssetKey = mode === "archive"
|
|
1267
|
+
? expectedMetadata.assetKey
|
|
1268
|
+
: mode === "transcript" && localProvenance !== null
|
|
1269
|
+
? projectedLocalTranscriptAssetKey(
|
|
1270
|
+
expectedMetadata.assetKey,
|
|
1271
|
+
localProvenance,
|
|
1272
|
+
)
|
|
1273
|
+
: variantAssetKey(expectedMetadata.assetKey, [mode]);
|
|
1274
|
+
} catch {
|
|
1275
|
+
return { ok: false, message: "Ghostget media direct HTTP identity projection is invalid" };
|
|
1276
|
+
}
|
|
1277
|
+
const expectedMedia = directHttpMediaForContainer(directHttpAcquisition.provenance.container);
|
|
1278
|
+
const transcriptIsValid = mode === "archive"
|
|
1279
|
+
? transcript.status === "unavailable" || transcript.source === "local"
|
|
1280
|
+
: mode === "transcript"
|
|
1281
|
+
? transcript.status === "available" && transcript.source === "local"
|
|
1282
|
+
: transcript.status === "unavailable" && transcript.reason === "not_requested";
|
|
1283
|
+
if (
|
|
1284
|
+
assetKey !== expectedAssetKey
|
|
1285
|
+
|| source.extractor !== expectedMetadata.extractor
|
|
1286
|
+
|| source.id !== expectedMetadata.id
|
|
1287
|
+
|| sourceHasDescription(source)
|
|
1288
|
+
|| captureArtifact.path !== `data/capture/media.${expectedMedia.extension}`
|
|
1289
|
+
|| captureArtifact.mediaType !== expectedMedia.mediaType
|
|
1290
|
+
|| roleCount("description") !== 0
|
|
1291
|
+
|| roleCount("thumbnail") !== 0
|
|
1292
|
+
|| ffmpeg === undefined
|
|
1293
|
+
|| ffprobe === undefined
|
|
1294
|
+
|| !transcriptIsValid
|
|
1295
|
+
) {
|
|
1296
|
+
return { ok: false, message: "Ghostget media direct HTTP identity or media projection is inconsistent" };
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const sortedArtifacts = artifacts.toSorted((left, right) => compareUtf8(left.path, right.path));
|
|
1301
|
+
const parsedTools: MediaManifestTools = {
|
|
1302
|
+
...(ffmpeg === undefined ? {} : { ffmpeg }),
|
|
1303
|
+
...(ffprobe === undefined ? {} : { ffprobe }),
|
|
1304
|
+
};
|
|
1305
|
+
const common = {
|
|
1306
|
+
schemaVersion: GHOSTGET_MEDIA_SCHEMA_VERSION,
|
|
1307
|
+
wrenchVersion,
|
|
1308
|
+
assetKey,
|
|
1309
|
+
capturedAt,
|
|
1310
|
+
mode,
|
|
1311
|
+
source,
|
|
1312
|
+
artifacts: sortedArtifacts,
|
|
1313
|
+
transcript,
|
|
1314
|
+
tools: parsedTools,
|
|
1315
|
+
} as const;
|
|
1316
|
+
return directHttpAcquisition !== null
|
|
1317
|
+
? {
|
|
1318
|
+
ok: true,
|
|
1319
|
+
manifest: {
|
|
1320
|
+
...common,
|
|
1321
|
+
authentication: { mode: "public" },
|
|
1322
|
+
acquisition: directHttpAcquisition,
|
|
1323
|
+
},
|
|
1324
|
+
}
|
|
1325
|
+
: ytDlpAcquisition !== null && trackedRevision !== null
|
|
1326
|
+
? {
|
|
1327
|
+
ok: true,
|
|
1328
|
+
manifest: {
|
|
1329
|
+
...common,
|
|
1330
|
+
authentication,
|
|
1331
|
+
acquisition: ytDlpAcquisition,
|
|
1332
|
+
revision: trackedRevision,
|
|
1333
|
+
},
|
|
1334
|
+
}
|
|
1335
|
+
: { ok: false, message: "Ghostget media manifest has an invalid acquisition contract" };
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function containedPath(root: string, relativePath: string): string {
|
|
1339
|
+
if (!safeRelativePath(relativePath)) throw new Error("unsafe artifact path");
|
|
1340
|
+
const path = resolve(root, ...relativePath.split("/"));
|
|
1341
|
+
if (path === root || !path.startsWith(`${root}${sep}`)) throw new Error(`artifact escapes item directory: ${relativePath}`);
|
|
1342
|
+
return path;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
async function hashRegularFile(
|
|
1346
|
+
itemRoot: string,
|
|
1347
|
+
relativePath: string,
|
|
1348
|
+
dependencies: MediaArtifactDependencies = {},
|
|
1349
|
+
): Promise<{ readonly bytes: number; readonly sha256: string }> {
|
|
1350
|
+
const path = containedPath(itemRoot, relativePath);
|
|
1351
|
+
const metadata = await lstat(path);
|
|
1352
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error(`artifact is not a regular file: ${relativePath}`);
|
|
1353
|
+
const physicalParent = await realpath(dirname(path));
|
|
1354
|
+
if (physicalParent !== dirname(path)) throw new Error(`artifact parent traverses a symbolic link: ${relativePath}`);
|
|
1355
|
+
await dependencies.beforeOpen?.(path);
|
|
1356
|
+
const handle = await open(
|
|
1357
|
+
path,
|
|
1358
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
1359
|
+
);
|
|
1360
|
+
try {
|
|
1361
|
+
const opened = await handle.stat();
|
|
1362
|
+
if (
|
|
1363
|
+
!opened.isFile()
|
|
1364
|
+
|| opened.dev !== metadata.dev
|
|
1365
|
+
|| opened.ino !== metadata.ino
|
|
1366
|
+
|| opened.size !== metadata.size
|
|
1367
|
+
) {
|
|
1368
|
+
throw new Error(`artifact changed before hashing: ${relativePath}`);
|
|
1369
|
+
}
|
|
1370
|
+
const hash = createHash("sha256");
|
|
1371
|
+
const buffer = new Uint8Array(1024 * 1024);
|
|
1372
|
+
let offset = 0;
|
|
1373
|
+
while (offset < opened.size) {
|
|
1374
|
+
const result = await handle.read(buffer, 0, Math.min(buffer.length, opened.size - offset), offset);
|
|
1375
|
+
if (result.bytesRead === 0) throw new Error(`artifact ended while hashing: ${relativePath}`);
|
|
1376
|
+
hash.update(buffer.subarray(0, result.bytesRead));
|
|
1377
|
+
offset += result.bytesRead;
|
|
1378
|
+
}
|
|
1379
|
+
const overflow = new Uint8Array(1);
|
|
1380
|
+
if ((await handle.read(overflow, 0, 1, opened.size)).bytesRead !== 0) {
|
|
1381
|
+
throw new Error(`artifact grew while hashing: ${relativePath}`);
|
|
1382
|
+
}
|
|
1383
|
+
const [finished, finalPath] = await Promise.all([
|
|
1384
|
+
handle.stat(),
|
|
1385
|
+
lstat(path),
|
|
1386
|
+
]);
|
|
1387
|
+
if (
|
|
1388
|
+
!finalPath.isFile()
|
|
1389
|
+
|| finalPath.isSymbolicLink()
|
|
1390
|
+
|| finalPath.dev !== opened.dev
|
|
1391
|
+
|| finalPath.ino !== opened.ino
|
|
1392
|
+
|| finalPath.size !== opened.size
|
|
1393
|
+
|| finished.size !== opened.size
|
|
1394
|
+
|| finished.mtimeMs !== opened.mtimeMs
|
|
1395
|
+
|| finished.ctimeMs !== opened.ctimeMs
|
|
1396
|
+
) {
|
|
1397
|
+
throw new Error(`artifact changed while hashing: ${relativePath}`);
|
|
1398
|
+
}
|
|
1399
|
+
return { bytes: opened.size, sha256: hash.digest("hex") };
|
|
1400
|
+
} finally {
|
|
1401
|
+
await handle.close();
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
async function readBoundedRegularFile(
|
|
1406
|
+
itemRoot: string,
|
|
1407
|
+
relativePath: string,
|
|
1408
|
+
maximumBytes: number,
|
|
1409
|
+
dependencies: MediaControlFileDependencies = {},
|
|
1410
|
+
): Promise<string> {
|
|
1411
|
+
const path = containedPath(itemRoot, relativePath);
|
|
1412
|
+
const metadata = await lstat(path);
|
|
1413
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
1414
|
+
throw new Error(`${relativePath} is not a regular file`);
|
|
1415
|
+
}
|
|
1416
|
+
if (metadata.size > maximumBytes) {
|
|
1417
|
+
throw new Error(`${relativePath} exceeds the ${String(maximumBytes)} byte verification bound`);
|
|
1418
|
+
}
|
|
1419
|
+
const physicalParent = await realpath(dirname(path));
|
|
1420
|
+
if (physicalParent !== dirname(path)) {
|
|
1421
|
+
throw new Error(`${relativePath} parent traverses a symbolic link`);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
await dependencies.beforeOpen?.(path);
|
|
1425
|
+
const handle = await open(
|
|
1426
|
+
path,
|
|
1427
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
1428
|
+
);
|
|
1429
|
+
try {
|
|
1430
|
+
const opened = await handle.stat();
|
|
1431
|
+
if (
|
|
1432
|
+
!opened.isFile()
|
|
1433
|
+
|| opened.dev !== metadata.dev
|
|
1434
|
+
|| opened.ino !== metadata.ino
|
|
1435
|
+
|| opened.size !== metadata.size
|
|
1436
|
+
|| opened.size > maximumBytes
|
|
1437
|
+
) {
|
|
1438
|
+
throw new Error(`${relativePath} changed or is not a bounded regular file`);
|
|
1439
|
+
}
|
|
1440
|
+
const bytes = new Uint8Array(opened.size);
|
|
1441
|
+
let offset = 0;
|
|
1442
|
+
while (offset < bytes.byteLength) {
|
|
1443
|
+
const result = await handle.read(
|
|
1444
|
+
bytes,
|
|
1445
|
+
offset,
|
|
1446
|
+
bytes.byteLength - offset,
|
|
1447
|
+
offset,
|
|
1448
|
+
);
|
|
1449
|
+
if (result.bytesRead === 0) {
|
|
1450
|
+
throw new Error(`${relativePath} ended while reading`);
|
|
1451
|
+
}
|
|
1452
|
+
offset += result.bytesRead;
|
|
1453
|
+
}
|
|
1454
|
+
const overflow = new Uint8Array(1);
|
|
1455
|
+
if ((await handle.read(overflow, 0, 1, bytes.byteLength)).bytesRead !== 0) {
|
|
1456
|
+
throw new Error(`${relativePath} grew while reading`);
|
|
1457
|
+
}
|
|
1458
|
+
await dependencies.afterRead?.(path);
|
|
1459
|
+
const [finished, finalPath] = await Promise.all([handle.stat(), lstat(path)]);
|
|
1460
|
+
if (
|
|
1461
|
+
!finalPath.isFile()
|
|
1462
|
+
|| finalPath.isSymbolicLink()
|
|
1463
|
+
|| finalPath.dev !== opened.dev
|
|
1464
|
+
|| finalPath.ino !== opened.ino
|
|
1465
|
+
|| finalPath.size !== opened.size
|
|
1466
|
+
|| finished.size !== opened.size
|
|
1467
|
+
|| finished.mtimeMs !== opened.mtimeMs
|
|
1468
|
+
|| finished.ctimeMs !== opened.ctimeMs
|
|
1469
|
+
) {
|
|
1470
|
+
throw new Error(`${relativePath} changed while reading`);
|
|
1471
|
+
}
|
|
1472
|
+
try {
|
|
1473
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1474
|
+
} catch {
|
|
1475
|
+
throw new Error(`${relativePath} is not valid UTF-8`);
|
|
1476
|
+
}
|
|
1477
|
+
} finally {
|
|
1478
|
+
await handle.close();
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function parseManifestSource(manifestSource: string): MediaManifest {
|
|
1483
|
+
let value: unknown;
|
|
1484
|
+
try {
|
|
1485
|
+
value = JSON.parse(manifestSource);
|
|
1486
|
+
} catch {
|
|
1487
|
+
throw new Error("wrench-media.json is not valid JSON");
|
|
1488
|
+
}
|
|
1489
|
+
const parsed = parseMediaManifest(value);
|
|
1490
|
+
if (!parsed.ok) throw new Error(parsed.message);
|
|
1491
|
+
return parsed.manifest;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
export async function readMediaManifest(
|
|
1495
|
+
itemDirectoryInput: string,
|
|
1496
|
+
dependencies: MediaControlFileDependencies = {},
|
|
1497
|
+
): Promise<MediaManifest> {
|
|
1498
|
+
const requestedItemDirectory = resolve(itemDirectoryInput);
|
|
1499
|
+
const rootMetadata = await lstat(requestedItemDirectory);
|
|
1500
|
+
if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) {
|
|
1501
|
+
throw new Error("item directory must be a real directory, not a symbolic link");
|
|
1502
|
+
}
|
|
1503
|
+
const itemDirectory = await realpath(requestedItemDirectory);
|
|
1504
|
+
return parseManifestSource(await readBoundedRegularFile(
|
|
1505
|
+
itemDirectory,
|
|
1506
|
+
GHOSTGET_MEDIA_MANIFEST_FILE,
|
|
1507
|
+
1024 * 1024,
|
|
1508
|
+
dependencies,
|
|
1509
|
+
));
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function mediaTypeForPath(path: string): string {
|
|
1513
|
+
const extension = path.toLowerCase().split(".").pop();
|
|
1514
|
+
const types: Readonly<Record<string, string>> = {
|
|
1515
|
+
mp4: "video/mp4", webm: "video/webm", mkv: "video/x-matroska", mka: "audio/x-matroska",
|
|
1516
|
+
mp3: "audio/mpeg", m4a: "audio/mp4", opus: "audio/ogg", ogg: "audio/ogg", wav: "audio/wav", flac: "audio/flac",
|
|
1517
|
+
vtt: "text/vtt", txt: "text/plain; charset=utf-8", json: "application/json", jpg: "image/jpeg", jpeg: "image/jpeg",
|
|
1518
|
+
png: "image/png", webp: "image/webp", avif: "image/avif",
|
|
1519
|
+
};
|
|
1520
|
+
return extension === undefined ? "application/octet-stream" : (types[extension] ?? "application/octet-stream");
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
export async function createMediaArtifact(
|
|
1524
|
+
itemRootInput: string,
|
|
1525
|
+
path: string,
|
|
1526
|
+
role: ArtifactRole,
|
|
1527
|
+
dependencies: MediaArtifactDependencies = {},
|
|
1528
|
+
): Promise<MediaArtifact> {
|
|
1529
|
+
const itemRoot = await realpath(resolve(itemRootInput));
|
|
1530
|
+
const hashed = await hashRegularFile(itemRoot, path, dependencies);
|
|
1531
|
+
return { role, path, ...hashed, mediaType: mediaTypeForPath(path) };
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function checksumLine(digest: string, path: string): string {
|
|
1535
|
+
return `${digest} ${path}`;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
async function atomicWrite(path: string, contents: string): Promise<void> {
|
|
1539
|
+
const temporary = `${path}.tmp-${crypto.randomUUID()}`;
|
|
1540
|
+
await writeFile(temporary, contents, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
1541
|
+
await rename(temporary, path);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
async function removeStaleControlTemps(itemRoot: string): Promise<void> {
|
|
1545
|
+
const entries = await readdir(itemRoot, { withFileTypes: true });
|
|
1546
|
+
for (const entry of entries) {
|
|
1547
|
+
if (!OWNED_CONTROL_TEMP_PATTERN.test(entry.name)) continue;
|
|
1548
|
+
const path = resolve(itemRoot, entry.name);
|
|
1549
|
+
const metadata = await lstat(path);
|
|
1550
|
+
if (
|
|
1551
|
+
entry.isSymbolicLink()
|
|
1552
|
+
|| !entry.isFile()
|
|
1553
|
+
|| metadata.isSymbolicLink()
|
|
1554
|
+
|| !metadata.isFile()
|
|
1555
|
+
) {
|
|
1556
|
+
throw new Error(`stale control temp is not a regular file: ${entry.name}`);
|
|
1557
|
+
}
|
|
1558
|
+
await unlink(path);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
export async function writeMediaManifest(itemRootInput: string, manifestInput: MediaManifest): Promise<MediaManifest> {
|
|
1563
|
+
const itemRoot = await realpath(resolve(itemRootInput));
|
|
1564
|
+
const parsed = parseMediaManifest(manifestInput);
|
|
1565
|
+
if (!parsed.ok) throw new Error(parsed.message);
|
|
1566
|
+
const manifest = {
|
|
1567
|
+
...parsed.manifest,
|
|
1568
|
+
artifacts: parsed.manifest.artifacts.toSorted((left, right) => compareUtf8(left.path, right.path)),
|
|
1569
|
+
};
|
|
1570
|
+
const manifestSource = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
1571
|
+
await removeStaleControlTemps(itemRoot);
|
|
1572
|
+
await atomicWrite(resolve(itemRoot, GHOSTGET_MEDIA_MANIFEST_FILE), manifestSource);
|
|
1573
|
+
const manifestDigest = createHash("sha256").update(manifestSource, "utf8").digest("hex");
|
|
1574
|
+
const lines = [
|
|
1575
|
+
...manifest.artifacts.map((artifact) => checksumLine(artifact.sha256, artifact.path)),
|
|
1576
|
+
checksumLine(manifestDigest, GHOSTGET_MEDIA_MANIFEST_FILE),
|
|
1577
|
+
].toSorted(compareUtf8);
|
|
1578
|
+
await atomicWrite(resolve(itemRoot, GHOSTGET_MEDIA_CHECKSUM_FILE), `${lines.join("\n")}\n`);
|
|
1579
|
+
return manifest;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function parseChecksumSource(source: string): ReadonlyMap<string, string> | null {
|
|
1583
|
+
if (source.length === 0 || source.length > 64 * 1_024 || !source.endsWith("\n")) return null;
|
|
1584
|
+
const entries = new Map<string, string>();
|
|
1585
|
+
for (const line of source.slice(0, -1).split("\n")) {
|
|
1586
|
+
const match = /^([0-9a-f]{64}) {2}(.+)$/u.exec(line);
|
|
1587
|
+
const digest = match?.[1];
|
|
1588
|
+
const path = match?.[2];
|
|
1589
|
+
if (digest === undefined || path === undefined || (!safeRelativePath(path) && path !== GHOSTGET_MEDIA_MANIFEST_FILE) || entries.has(path)) return null;
|
|
1590
|
+
entries.set(path, digest);
|
|
1591
|
+
}
|
|
1592
|
+
return entries;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
async function itemFileSet(itemDirectory: string): Promise<ReadonlySet<string>> {
|
|
1596
|
+
const files = new Set<string>();
|
|
1597
|
+
let entries = 0;
|
|
1598
|
+
const walk = async (directory: string, depth: number): Promise<void> => {
|
|
1599
|
+
if (depth > MAX_ITEM_DEPTH) throw new Error("item directory exceeds the verification depth bound");
|
|
1600
|
+
if (await realpath(directory) !== directory) {
|
|
1601
|
+
throw new Error("item directory traverses a symbolic-link ancestor");
|
|
1602
|
+
}
|
|
1603
|
+
const handle = await opendir(directory);
|
|
1604
|
+
try {
|
|
1605
|
+
for await (const entry of handle) {
|
|
1606
|
+
entries += 1;
|
|
1607
|
+
if (entries > MAX_ITEM_ENTRIES) {
|
|
1608
|
+
throw new Error("item directory exceeds the verification entry bound");
|
|
1609
|
+
}
|
|
1610
|
+
const path = resolve(directory, entry.name);
|
|
1611
|
+
const metadata = await lstat(path);
|
|
1612
|
+
if (metadata.isSymbolicLink() || entry.isSymbolicLink()) {
|
|
1613
|
+
throw new Error(`item contains a symbolic link: ${entry.name}`);
|
|
1614
|
+
}
|
|
1615
|
+
if (metadata.isDirectory() && entry.isDirectory()) {
|
|
1616
|
+
await walk(path, depth + 1);
|
|
1617
|
+
continue;
|
|
1618
|
+
}
|
|
1619
|
+
if (!metadata.isFile() || !entry.isFile()) {
|
|
1620
|
+
throw new Error(`item contains an unsupported filesystem entry: ${entry.name}`);
|
|
1621
|
+
}
|
|
1622
|
+
const itemPath = relative(itemDirectory, path).split(sep).join("/");
|
|
1623
|
+
if (!safeRelativePath(itemPath)) throw new Error("item contains an unsafe file path");
|
|
1624
|
+
files.add(itemPath);
|
|
1625
|
+
}
|
|
1626
|
+
} finally {
|
|
1627
|
+
try {
|
|
1628
|
+
await handle.close();
|
|
1629
|
+
} catch {
|
|
1630
|
+
// `for await` closes the directory on normal exhaustion in Bun.
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
await walk(itemDirectory, 0);
|
|
1635
|
+
return files;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
export async function verifyMediaItem(
|
|
1639
|
+
itemDirectoryInput: string,
|
|
1640
|
+
dependencies: MediaControlFileDependencies = {},
|
|
1641
|
+
): Promise<VerifyItemResult> {
|
|
1642
|
+
const requestedItemDirectory = resolve(itemDirectoryInput);
|
|
1643
|
+
const failures: string[] = [];
|
|
1644
|
+
try {
|
|
1645
|
+
const rootMetadata = await lstat(requestedItemDirectory);
|
|
1646
|
+
if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) {
|
|
1647
|
+
throw new Error("item directory must be a real directory, not a symbolic link");
|
|
1648
|
+
}
|
|
1649
|
+
const itemDirectory = await realpath(requestedItemDirectory);
|
|
1650
|
+
const manifestSource = await readBoundedRegularFile(
|
|
1651
|
+
itemDirectory,
|
|
1652
|
+
GHOSTGET_MEDIA_MANIFEST_FILE,
|
|
1653
|
+
1024 * 1024,
|
|
1654
|
+
dependencies,
|
|
1655
|
+
);
|
|
1656
|
+
const parsedManifest = parseManifestSource(manifestSource);
|
|
1657
|
+
const checksumSource = await readBoundedRegularFile(
|
|
1658
|
+
itemDirectory,
|
|
1659
|
+
GHOSTGET_MEDIA_CHECKSUM_FILE,
|
|
1660
|
+
64 * 1024,
|
|
1661
|
+
dependencies,
|
|
1662
|
+
);
|
|
1663
|
+
const checksums = parseChecksumSource(checksumSource);
|
|
1664
|
+
if (checksums === null) throw new Error("manifest-sha256.txt is invalid");
|
|
1665
|
+
const expectedPaths = new Set([...parsedManifest.artifacts.map((artifact) => artifact.path), GHOSTGET_MEDIA_MANIFEST_FILE]);
|
|
1666
|
+
if (checksums.size !== expectedPaths.size || [...expectedPaths].some((path) => !checksums.has(path))) {
|
|
1667
|
+
failures.push("checksum file does not name exactly the manifest and recorded artifacts");
|
|
1668
|
+
}
|
|
1669
|
+
const manifestHash = createHash("sha256").update(manifestSource, "utf8").digest("hex");
|
|
1670
|
+
if (checksums.get(GHOSTGET_MEDIA_MANIFEST_FILE) !== manifestHash) failures.push("wrench-media.json checksum mismatch");
|
|
1671
|
+
const expectedItemFiles = new Set([...expectedPaths, GHOSTGET_MEDIA_CHECKSUM_FILE]);
|
|
1672
|
+
const actualItemFiles = await itemFileSet(itemDirectory);
|
|
1673
|
+
if (
|
|
1674
|
+
actualItemFiles.size !== expectedItemFiles.size
|
|
1675
|
+
|| [...expectedItemFiles].some((path) => !actualItemFiles.has(path))
|
|
1676
|
+
) {
|
|
1677
|
+
failures.push("item directory does not contain exactly the recorded artifacts and control files");
|
|
1678
|
+
}
|
|
1679
|
+
for (const artifact of parsedManifest.artifacts) {
|
|
1680
|
+
try {
|
|
1681
|
+
const actual = await hashRegularFile(itemDirectory, artifact.path);
|
|
1682
|
+
if (actual.bytes !== artifact.bytes) failures.push(`${artifact.path}: byte length mismatch`);
|
|
1683
|
+
if (actual.sha256 !== artifact.sha256 || checksums.get(artifact.path) !== actual.sha256) failures.push(`${artifact.path}: SHA-256 mismatch`);
|
|
1684
|
+
} catch (error) {
|
|
1685
|
+
failures.push(error instanceof Error ? error.message : `${artifact.path}: verification failed`);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
const finalItemFiles = await itemFileSet(itemDirectory);
|
|
1689
|
+
if (
|
|
1690
|
+
finalItemFiles.size !== expectedItemFiles.size
|
|
1691
|
+
|| [...expectedItemFiles].some((path) => !finalItemFiles.has(path))
|
|
1692
|
+
) {
|
|
1693
|
+
failures.push("item directory changed or contains an unrecorded file");
|
|
1694
|
+
}
|
|
1695
|
+
return {
|
|
1696
|
+
ok: failures.length === 0,
|
|
1697
|
+
itemDirectory: requestedItemDirectory,
|
|
1698
|
+
assetKey: parsedManifest.assetKey,
|
|
1699
|
+
checkedArtifacts: parsedManifest.artifacts.length,
|
|
1700
|
+
failures,
|
|
1701
|
+
};
|
|
1702
|
+
} catch (error) {
|
|
1703
|
+
failures.push(error instanceof Error ? error.message : "item verification failed");
|
|
1704
|
+
return { ok: false, itemDirectory: requestedItemDirectory, checkedArtifacts: 0, failures };
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
export function relativeArtifactPath(itemRoot: string, artifactPath: string): string {
|
|
1709
|
+
const value = relative(resolve(itemRoot), resolve(artifactPath)).split(sep).join("/");
|
|
1710
|
+
if (!safeRelativePath(value)) throw new Error("artifact path is outside the item directory");
|
|
1711
|
+
return value;
|
|
1712
|
+
}
|