@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.
Files changed (500) hide show
  1. package/CHANGELOG.md +458 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1359 -0
  4. package/bunfig.toml +2 -0
  5. package/dist/apple-photos-client.js +704 -0
  6. package/dist/beeper-client.js +4602 -0
  7. package/dist/client.js +1605 -0
  8. package/dist/index-26yq8q16.js +31 -0
  9. package/dist/index-9t13b4k7.js +508 -0
  10. package/dist/index-axwm9b3v.js +5 -0
  11. package/dist/index-gwk7rbyj.js +30 -0
  12. package/dist/index.js +701 -0
  13. package/dist/messaging.js +1130 -0
  14. package/dist/omni-client.js +1406 -0
  15. package/dist/whatsapp-client.js +355 -0
  16. package/docs/imessage-direct-provider.md +106 -0
  17. package/docs/rental-listings.md +94 -0
  18. package/package.json +600 -0
  19. package/skills/ghostget/SKILL.md +251 -0
  20. package/skills/ghostget/agents/openai.yaml +4 -0
  21. package/skills/ghostget/references/adapter-contract.md +181 -0
  22. package/skills/ghostget/references/apple-photos.md +115 -0
  23. package/skills/ghostget/references/article-drafts.md +235 -0
  24. package/skills/ghostget/references/code-owned-provider-scaffold.md +134 -0
  25. package/skills/ghostget/references/cross-posting-video.md +139 -0
  26. package/skills/ghostget/references/cross-posting.md +82 -0
  27. package/skills/ghostget/references/derivation.md +390 -0
  28. package/skills/ghostget/references/hraness-social-profile-stats.json +269 -0
  29. package/skills/ghostget/references/install.md +66 -0
  30. package/skills/ghostget/references/linkedin-adapter.md +442 -0
  31. package/skills/ghostget/references/messaging.md +168 -0
  32. package/skills/ghostget/references/meta-comet-contract.md +153 -0
  33. package/skills/ghostget/references/platform-patterns.md +122 -0
  34. package/skills/ghostget/references/provider-plugins.md +496 -0
  35. package/skills/ghostget/references/reddit-flair.md +78 -0
  36. package/skills/ghostget/references/safety-and-state.md +214 -0
  37. package/skills/ghostget/references/settlement-and-duplicate-cleanup.md +79 -0
  38. package/skills/ghostget/references/social-platform-routing.md +66 -0
  39. package/skills/ghostget/references/social-profile-stats.md +195 -0
  40. package/skills/ghostget/references/social-video-platform-routing.md +89 -0
  41. package/skills/ghostget/references/whatsapp-adapter.md +101 -0
  42. package/skills/ghostget/references/x-adapter.md +332 -0
  43. package/skills/ghostget/references/x-ai-disclosure.md +71 -0
  44. package/src/apple-photos-cli.ts +639 -0
  45. package/src/apple-photos-client-types.ts +168 -0
  46. package/src/apple-photos-client.ts +201 -0
  47. package/src/apple-photos-contact-evidence.ts +838 -0
  48. package/src/apple-photos-local-source.ts +1131 -0
  49. package/src/args.ts +2053 -0
  50. package/src/article-draft-document.ts +417 -0
  51. package/src/article-draft-embeds.ts +123 -0
  52. package/src/article-draft-images.ts +214 -0
  53. package/src/assets/adapter-template/wrench-adapter.json +40 -0
  54. package/src/assets/adapters/beeper/wrench-web-adapter.json +1500 -0
  55. package/src/assets/adapters/beeper/wrench-web-adapter.v1.0.0.json +129 -0
  56. package/src/assets/adapters/beeper/wrench-web-adapter.v1.1.0.json +207 -0
  57. package/src/assets/adapters/beeper/wrench-web-adapter.v2.0.0.json +1463 -0
  58. package/src/assets/adapters/beeper/wrench-web-adapter.v2.1.0.json +1463 -0
  59. package/src/assets/adapters/beeper/wrench-web-adapter.v2.2.0.json +1475 -0
  60. package/src/assets/adapters/beeper/wrench-web-adapter.v2.3.0.json +1488 -0
  61. package/src/assets/adapters/bluesky/wrench-web-adapter.json +684 -0
  62. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json +581 -0
  63. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json +581 -0
  64. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.2.0.json +581 -0
  65. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.3.0.json +615 -0
  66. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.4.0.json +684 -0
  67. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.5.0.json +684 -0
  68. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.6.0.json +684 -0
  69. package/src/assets/adapters/clasificados/wrench-web-adapter.json +54 -0
  70. package/src/assets/adapters/facebook/wrench-web-adapter.json +750 -0
  71. package/src/assets/adapters/facebook/wrench-web-adapter.v1.0.0.json +706 -0
  72. package/src/assets/adapters/facebook/wrench-web-adapter.v1.1.0.json +703 -0
  73. package/src/assets/adapters/facebook/wrench-web-adapter.v1.2.0.json +750 -0
  74. package/src/assets/adapters/facebook-group/wrench-web-adapter.json +682 -0
  75. package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.0.0.json +583 -0
  76. package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.1.0.json +682 -0
  77. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.json +437 -0
  78. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.0.0.json +383 -0
  79. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.0.json +437 -0
  80. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.1.json +437 -0
  81. package/src/assets/adapters/facebook-page/wrench-web-adapter.json +880 -0
  82. package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.0.0.json +740 -0
  83. package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.1.0.json +880 -0
  84. package/src/assets/adapters/github/wrench-web-adapter.json +69 -0
  85. package/src/assets/adapters/github/wrench-web-adapter.v1.0.0.json +42 -0
  86. package/src/assets/adapters/github/wrench-web-adapter.v1.1.0.json +69 -0
  87. package/src/assets/adapters/gmail/wrench-adapter.json +161 -0
  88. package/src/assets/adapters/gmail/wrench-adapter.v1.2.0.json +157 -0
  89. package/src/assets/adapters/hacker-news/wrench-web-adapter.json +315 -0
  90. package/src/assets/adapters/hacker-news/wrench-web-adapter.v1.0.0.json +315 -0
  91. package/src/assets/adapters/imessage/wrench-web-adapter.json +236 -0
  92. package/src/assets/adapters/instagram/wrench-web-adapter.json +719 -0
  93. package/src/assets/adapters/instagram/wrench-web-adapter.v1.0.0.json +627 -0
  94. package/src/assets/adapters/instagram/wrench-web-adapter.v1.1.0.json +609 -0
  95. package/src/assets/adapters/instagram/wrench-web-adapter.v1.2.0.json +640 -0
  96. package/src/assets/adapters/instagram/wrench-web-adapter.v1.3.0.json +671 -0
  97. package/src/assets/adapters/instagram/wrench-web-adapter.v1.4.0.json +710 -0
  98. package/src/assets/adapters/instagram/wrench-web-adapter.v1.5.0.json +710 -0
  99. package/src/assets/adapters/instagram/wrench-web-adapter.v1.6.0.json +719 -0
  100. package/src/assets/adapters/instagram/wrench-web-adapter.v1.7.0.json +719 -0
  101. package/src/assets/adapters/linkedin/wrench-adapter.json +442 -0
  102. package/src/assets/adapters/linkedin/wrench-adapter.v0.4.0.json +124 -0
  103. package/src/assets/adapters/linkedin/wrench-adapter.v1.0.0.json +411 -0
  104. package/src/assets/adapters/linkedin/wrench-web-adapter.json +914 -0
  105. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.0.0.json +604 -0
  106. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.1.0.json +604 -0
  107. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.10.0.json +809 -0
  108. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.11.0.json +809 -0
  109. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.12.0.json +820 -0
  110. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.13.0.json +819 -0
  111. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json +819 -0
  112. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json +819 -0
  113. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json +874 -0
  114. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json +874 -0
  115. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.18.0.json +874 -0
  116. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.19.0.json +874 -0
  117. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.2.0.json +731 -0
  118. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.3.0.json +768 -0
  119. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.4.0.json +808 -0
  120. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.5.0.json +808 -0
  121. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.6.0.json +808 -0
  122. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.7.0.json +825 -0
  123. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.8.0.json +792 -0
  124. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.9.0.json +809 -0
  125. package/src/assets/adapters/reddit/wrench-web-adapter.json +855 -0
  126. package/src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json +577 -0
  127. package/src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json +604 -0
  128. package/src/assets/adapters/reddit/wrench-web-adapter.v1.10.0.json +712 -0
  129. package/src/assets/adapters/reddit/wrench-web-adapter.v1.11.0.json +712 -0
  130. package/src/assets/adapters/reddit/wrench-web-adapter.v1.12.0.json +712 -0
  131. package/src/assets/adapters/reddit/wrench-web-adapter.v1.2.0.json +653 -0
  132. package/src/assets/adapters/reddit/wrench-web-adapter.v1.3.0.json +712 -0
  133. package/src/assets/adapters/reddit/wrench-web-adapter.v1.4.0.json +713 -0
  134. package/src/assets/adapters/reddit/wrench-web-adapter.v1.5.0.json +712 -0
  135. package/src/assets/adapters/reddit/wrench-web-adapter.v1.6.0.json +713 -0
  136. package/src/assets/adapters/reddit/wrench-web-adapter.v1.7.0.json +713 -0
  137. package/src/assets/adapters/reddit/wrench-web-adapter.v1.8.0.json +713 -0
  138. package/src/assets/adapters/reddit/wrench-web-adapter.v1.9.0.json +713 -0
  139. package/src/assets/adapters/substack/wrench-web-adapter.json +936 -0
  140. package/src/assets/adapters/substack/wrench-web-adapter.v1.0.0.json +817 -0
  141. package/src/assets/adapters/substack/wrench-web-adapter.v1.1.0.json +812 -0
  142. package/src/assets/adapters/substack/wrench-web-adapter.v1.2.0.json +812 -0
  143. package/src/assets/adapters/substack/wrench-web-adapter.v1.3.0.json +902 -0
  144. package/src/assets/adapters/substack/wrench-web-adapter.v1.4.0.json +936 -0
  145. package/src/assets/adapters/substack/wrench-web-adapter.v1.5.0.json +936 -0
  146. package/src/assets/adapters/substack/wrench-web-adapter.v1.6.0.json +936 -0
  147. package/src/assets/adapters/threads/wrench-web-adapter.json +692 -0
  148. package/src/assets/adapters/threads/wrench-web-adapter.v1.0.0.json +629 -0
  149. package/src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json +623 -0
  150. package/src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json +622 -0
  151. package/src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json +622 -0
  152. package/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json +622 -0
  153. package/src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json +699 -0
  154. package/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json +693 -0
  155. package/src/assets/adapters/threads/wrench-web-adapter.v1.7.0.json +692 -0
  156. package/src/assets/adapters/tiktok/wrench-web-adapter.json +762 -0
  157. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json +681 -0
  158. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.1.0.json +708 -0
  159. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.2.0.json +762 -0
  160. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.3.0.json +762 -0
  161. package/src/assets/adapters/twitch/wrench-web-adapter.json +42 -0
  162. package/src/assets/adapters/twitch/wrench-web-adapter.v1.0.0.json +42 -0
  163. package/src/assets/adapters/whatsapp/wrench-web-adapter.json +400 -0
  164. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json +355 -0
  165. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.1.0.json +386 -0
  166. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.2.0.json +386 -0
  167. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.3.0.json +400 -0
  168. package/src/assets/adapters/x/wrench-adapter.json +749 -0
  169. package/src/assets/adapters/x/wrench-adapter.v1.0.0.json +700 -0
  170. package/src/assets/adapters/x/wrench-adapter.v1.1.0.json +707 -0
  171. package/src/assets/adapters/x/wrench-web-adapter.json +712 -0
  172. package/src/assets/adapters/x/wrench-web-adapter.v1.1.0.json +579 -0
  173. package/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +685 -0
  174. package/src/assets/adapters/x/wrench-web-adapter.v1.11.0.json +712 -0
  175. package/src/assets/adapters/x/wrench-web-adapter.v1.12.0.json +712 -0
  176. package/src/assets/adapters/x/wrench-web-adapter.v1.13.0.json +712 -0
  177. package/src/assets/adapters/x/wrench-web-adapter.v1.2.0.json +578 -0
  178. package/src/assets/adapters/x/wrench-web-adapter.v1.3.0.json +610 -0
  179. package/src/assets/adapters/x/wrench-web-adapter.v1.4.0.json +617 -0
  180. package/src/assets/adapters/x/wrench-web-adapter.v1.5.0.json +634 -0
  181. package/src/assets/adapters/x/wrench-web-adapter.v1.6.0.json +632 -0
  182. package/src/assets/adapters/x/wrench-web-adapter.v1.7.0.json +649 -0
  183. package/src/assets/adapters/x/wrench-web-adapter.v1.8.0.json +649 -0
  184. package/src/assets/adapters/x/wrench-web-adapter.v1.9.0.json +683 -0
  185. package/src/assets/adapters/youtube/wrench-web-adapter.json +561 -0
  186. package/src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json +465 -0
  187. package/src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json +519 -0
  188. package/src/assets/adapters/youtube/wrench-web-adapter.v1.2.0.json +561 -0
  189. package/src/assets/adapters/youtube/wrench-web-adapter.v1.3.0.json +561 -0
  190. package/src/assets/code-owned-provider-template/plugin.test.ts.template +54 -0
  191. package/src/assets/code-owned-provider-template/plugin.ts.template +102 -0
  192. package/src/assets/code-owned-provider-template/runtime.internal.test.ts.template +44 -0
  193. package/src/assets/code-owned-provider-template/runtime.ts.template +59 -0
  194. package/src/assets/code-owned-provider-template/wrench-adapter.json.template +33 -0
  195. package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
  196. package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
  197. package/src/assets/fonts/nebula-sans/PROVENANCE.md +14 -0
  198. package/src/assets.d.ts +4 -0
  199. package/src/auth.ts +1157 -0
  200. package/src/beeper-client-types.ts +169 -0
  201. package/src/beeper-client.ts +250 -0
  202. package/src/beeper-contact-interactions-cli.ts +180 -0
  203. package/src/beeper-contact-interactions.ts +1612 -0
  204. package/src/beeper-message-bundle-v1.ts +257 -0
  205. package/src/beeper-message-like-me-cli.ts +84 -0
  206. package/src/beeper-message-like-me-export.ts +2274 -0
  207. package/src/beeper-message-like-me-recovery.ts +931 -0
  208. package/src/beeper-message-like-me-source.ts +4034 -0
  209. package/src/browser-admission.ts +928 -0
  210. package/src/browser-snapshots.ts +260 -0
  211. package/src/browser.ts +3873 -0
  212. package/src/canonical-json.ts +60 -0
  213. package/src/catalog-cli.ts +638 -0
  214. package/src/cli.ts +208 -0
  215. package/src/client-types.ts +301 -0
  216. package/src/client.ts +2726 -0
  217. package/src/confirmed-write-failure.ts +44 -0
  218. package/src/confirmed-write-model.ts +72 -0
  219. package/src/confirmed-write-platform.ts +1055 -0
  220. package/src/confirmed-write-program.ts +224 -0
  221. package/src/confirmed-write-runtime.ts +16 -0
  222. package/src/cursor-token.ts +228 -0
  223. package/src/derivation-dnr-readiness.ts +367 -0
  224. package/src/derivation-file-chooser.ts +384 -0
  225. package/src/derivation-network-boundary.ts +835 -0
  226. package/src/derivation-network-guard.ts +782 -0
  227. package/src/derivation-network-proxy-helper.ts +440 -0
  228. package/src/derivation-network-proxy.ts +385 -0
  229. package/src/derive-command-helper.ts +693 -0
  230. package/src/derive-fixtures.ts +343 -0
  231. package/src/derive-review.ts +980 -0
  232. package/src/derive.ts +4754 -0
  233. package/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +2 -0
  234. package/src/fixtures/beeper-message-like-me-v1/conversations.ndjson +1 -0
  235. package/src/fixtures/beeper-message-like-me-v1/manifest.json +1 -0
  236. package/src/fixtures/beeper-message-like-me-v1/messages.ndjson +2 -0
  237. package/src/fixtures/beeper-message-like-me-v1/participants.ndjson +3 -0
  238. package/src/fixtures/beeper-message-like-me-v1/reactions.ndjson +1 -0
  239. package/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson +1 -0
  240. package/src/ghostget.ts +3672 -0
  241. package/src/gmail-capture.ts +1244 -0
  242. package/src/har-internal.ts +1242 -0
  243. package/src/har.ts +743 -0
  244. package/src/index.ts +218 -0
  245. package/src/invocation-read-platform.ts +55 -0
  246. package/src/invocation-read-program.ts +93 -0
  247. package/src/invocation-read-runtime.ts +11 -0
  248. package/src/linked-device-lifecycle-admission.ts +450 -0
  249. package/src/linked-device-lifecycle-journal.ts +1560 -0
  250. package/src/linked-device-lifecycle-runtime.ts +1602 -0
  251. package/src/local-cli-admission.ts +135 -0
  252. package/src/local-cli-contracts.ts +271 -0
  253. package/src/local-cli-execution.ts +98 -0
  254. package/src/local-cli-surface-contract.ts +1510 -0
  255. package/src/local-cli-tool-identity.ts +336 -0
  256. package/src/media/archive.ts +2325 -0
  257. package/src/media/args.ts +303 -0
  258. package/src/media/cli.ts +287 -0
  259. package/src/media/doctor.ts +475 -0
  260. package/src/media/ffmpeg.ts +1330 -0
  261. package/src/media/http-capture.ts +711 -0
  262. package/src/media/http-probe.ts +593 -0
  263. package/src/media/http.ts +387 -0
  264. package/src/media/index.ts +10 -0
  265. package/src/media/local-transcription.ts +613 -0
  266. package/src/media/lock.ts +321 -0
  267. package/src/media/manifest.ts +1712 -0
  268. package/src/media/metadata.ts +906 -0
  269. package/src/media/process.ts +723 -0
  270. package/src/media/revision.ts +166 -0
  271. package/src/media/runtime-closure.ts +1063 -0
  272. package/src/media/source-router.ts +187 -0
  273. package/src/media/transcriber-config.ts +1122 -0
  274. package/src/media/transcript-persistence-model.ts +15 -0
  275. package/src/media/transcript-persistence-platform.ts +89 -0
  276. package/src/media/transcript-persistence-program.ts +39 -0
  277. package/src/media/transcript-persistence-runtime.ts +24 -0
  278. package/src/media/transcript.ts +616 -0
  279. package/src/media/utf8-order.ts +30 -0
  280. package/src/media/whisper-cpp.ts +464 -0
  281. package/src/media/whisper-language.ts +58 -0
  282. package/src/media/yt-dlp.ts +439 -0
  283. package/src/message-like-me-agentic-messaging.ts +882 -0
  284. package/src/messaging-action-store.ts +793 -0
  285. package/src/messaging-runtime.ts +2136 -0
  286. package/src/messaging-store.ts +539 -0
  287. package/src/messaging-types.ts +1663 -0
  288. package/src/messaging.ts +334 -0
  289. package/src/model.ts +2671 -0
  290. package/src/oauth-google.ts +888 -0
  291. package/src/omni-client-types.ts +306 -0
  292. package/src/omni-client.ts +2225 -0
  293. package/src/omni-limits.ts +34 -0
  294. package/src/omni-model.ts +1614 -0
  295. package/src/omni-request.ts +379 -0
  296. package/src/omni-runtime.ts +1632 -0
  297. package/src/operation-deadline.ts +189 -0
  298. package/src/path-helper.ts +1656 -0
  299. package/src/pinned-https.ts +194 -0
  300. package/src/plan-assets.ts +832 -0
  301. package/src/platform-catalog.ts +1832 -0
  302. package/src/plugins/beeper-linked-device/plugin.ts +310 -0
  303. package/src/plugins/bluesky-web/plugin.ts +178 -0
  304. package/src/plugins/clasificados-web/plugin.ts +66 -0
  305. package/src/plugins/github-web/plugin.ts +80 -0
  306. package/src/plugins/gmail-official/plugin.ts +110 -0
  307. package/src/plugins/hacker-news-web/plugin.ts +52 -0
  308. package/src/plugins/imessage-direct/plugin.ts +195 -0
  309. package/src/plugins/imessage-direct/vendor/0001-fix-keep-AppleScript-send-payloads-out-of-child-argv.patch +1430 -0
  310. package/src/plugins/imessage-direct/vendor/0002-feat-rpc-add-exact-chat-lookup.patch +225 -0
  311. package/src/plugins/imessage-direct/vendor/provenance.json +80 -0
  312. package/src/plugins/linkedin-official/plugin.ts +84 -0
  313. package/src/plugins/linkedin-web/plugin.ts +412 -0
  314. package/src/plugins/meta-web/plugin.ts +317 -0
  315. package/src/plugins/reddit-web/flair.ts +492 -0
  316. package/src/plugins/reddit-web/plugin.ts +477 -0
  317. package/src/plugins/substack-web/plugin.ts +124 -0
  318. package/src/plugins/tiktok-web/plugin.ts +67 -0
  319. package/src/plugins/twitch-web/plugin.ts +54 -0
  320. package/src/plugins/whatsapp-linked-device/plugin.ts +105 -0
  321. package/src/plugins/x-official/plugin.ts +64 -0
  322. package/src/plugins/x-web/plugin.ts +488 -0
  323. package/src/plugins/youtube-web/plugin.ts +101 -0
  324. package/src/portable-run-recovery.ts +466 -0
  325. package/src/process-identity.ts +501 -0
  326. package/src/profile-clone-helper.ts +104 -0
  327. package/src/provider-catalog-views.ts +51 -0
  328. package/src/provider-context.ts +69 -0
  329. package/src/provider-contract-definitions-x.ts +45 -0
  330. package/src/provider-contract-definitions.ts +948 -0
  331. package/src/provider-contract-input-gmail.ts +176 -0
  332. package/src/provider-contract-input-linkedin.ts +77 -0
  333. package/src/provider-contract-input-x.ts +304 -0
  334. package/src/provider-contract-planning.ts +23 -0
  335. package/src/provider-contract-semantic-identity.ts +54 -0
  336. package/src/provider-contracts.ts +251 -0
  337. package/src/provider-http.ts +723 -0
  338. package/src/provider-plugin-auth.ts +36 -0
  339. package/src/provider-plugin-builtins.ts +229 -0
  340. package/src/provider-plugin-cleanup-barrier.ts +229 -0
  341. package/src/provider-plugin-cleanup-execution.ts +79 -0
  342. package/src/provider-plugin-cleanup-resource.ts +470 -0
  343. package/src/provider-plugin-contract-identity.ts +707 -0
  344. package/src/provider-plugin-host.ts +1972 -0
  345. package/src/provider-plugin-identifiers.ts +60 -0
  346. package/src/provider-plugin-invocation-lease.ts +889 -0
  347. package/src/provider-plugin-lifecycle-kernel.ts +566 -0
  348. package/src/provider-plugin-lifecycle.ts +1455 -0
  349. package/src/provider-plugin-module-analysis.ts +336 -0
  350. package/src/provider-plugin-package.ts +2530 -0
  351. package/src/provider-plugin-portable-authority.ts +106 -0
  352. package/src/provider-plugin-portable-catalog.ts +94 -0
  353. package/src/provider-plugin-portable-identity.ts +315 -0
  354. package/src/provider-plugin-portable-runtime.ts +2057 -0
  355. package/src/provider-plugin-protocol.ts +1625 -0
  356. package/src/provider-plugin-registry.ts +3342 -0
  357. package/src/provider-plugin-store.ts +2155 -0
  358. package/src/provider-plugin-test-fixture.ts +5 -0
  359. package/src/provider-plugin.ts +4907 -0
  360. package/src/provider-plugins.generated.ts +43 -0
  361. package/src/provider-plugins.ts +8 -0
  362. package/src/provider-subject.ts +41 -0
  363. package/src/provider.ts +315 -0
  364. package/src/providers/beeper-local-runtime.ts +5426 -0
  365. package/src/providers/beeper-local.ts +2739 -0
  366. package/src/providers/beeper-messaging.ts +462 -0
  367. package/src/providers/beeper-omni.ts +1159 -0
  368. package/src/providers/bluesky-web-runtime.ts +2895 -0
  369. package/src/providers/bluesky-web.ts +1614 -0
  370. package/src/providers/clasificados-web-runtime.ts +255 -0
  371. package/src/providers/clasificados-web.ts +503 -0
  372. package/src/providers/contact-projection.ts +301 -0
  373. package/src/providers/github-read-model.ts +94 -0
  374. package/src/providers/github-read-platform.ts +165 -0
  375. package/src/providers/github-read-program.ts +97 -0
  376. package/src/providers/github-web-runtime.ts +320 -0
  377. package/src/providers/github-web.ts +403 -0
  378. package/src/providers/gmail-api.ts +2834 -0
  379. package/src/providers/gmail-omni.ts +830 -0
  380. package/src/providers/gmail.ts +1130 -0
  381. package/src/providers/hacker-news-web-runtime.ts +220 -0
  382. package/src/providers/hacker-news-web.ts +809 -0
  383. package/src/providers/imessage-direct-install.ts +354 -0
  384. package/src/providers/imessage-direct-messaging.ts +532 -0
  385. package/src/providers/imessage-direct-omni.ts +400 -0
  386. package/src/providers/imessage-direct-runtime.ts +1498 -0
  387. package/src/providers/imessage-direct.ts +351 -0
  388. package/src/providers/instagram-video-foundations.ts +913 -0
  389. package/src/providers/instagram-web-profile-browser.ts +600 -0
  390. package/src/providers/iso-bmff.ts +338 -0
  391. package/src/providers/linkedin-company-platform.ts +42 -0
  392. package/src/providers/linkedin-company-program.ts +88 -0
  393. package/src/providers/linkedin-contact-failure.ts +84 -0
  394. package/src/providers/linkedin-contact-platform.ts +45 -0
  395. package/src/providers/linkedin-contact-program.ts +91 -0
  396. package/src/providers/linkedin-profile-activity-failure.ts +76 -0
  397. package/src/providers/linkedin-profile-activity-platform.ts +41 -0
  398. package/src/providers/linkedin-profile-activity-program.ts +67 -0
  399. package/src/providers/linkedin-read-failure.ts +52 -0
  400. package/src/providers/linkedin-self-platform.ts +53 -0
  401. package/src/providers/linkedin-self-program.ts +122 -0
  402. package/src/providers/linkedin-web-article-browser.ts +794 -0
  403. package/src/providers/linkedin-web-bootstrap.ts +146 -0
  404. package/src/providers/linkedin-web-contact.ts +830 -0
  405. package/src/providers/linkedin-web-feed-browser.ts +832 -0
  406. package/src/providers/linkedin-web-feed.ts +864 -0
  407. package/src/providers/linkedin-web-post-browser.ts +653 -0
  408. package/src/providers/linkedin-web-profile-browser.ts +683 -0
  409. package/src/providers/linkedin-web-runtime.ts +2388 -0
  410. package/src/providers/linkedin-web.ts +3305 -0
  411. package/src/providers/linkedin.ts +1885 -0
  412. package/src/providers/meta-bootstrap.ts +1043 -0
  413. package/src/providers/meta-facebook-group.ts +690 -0
  414. package/src/providers/meta-marketplace-relay.ts +747 -0
  415. package/src/providers/meta-omni.ts +373 -0
  416. package/src/providers/meta-relay-bundle.ts +1049 -0
  417. package/src/providers/meta-web-descriptors.ts +1740 -0
  418. package/src/providers/meta-web-runtime.ts +3388 -0
  419. package/src/providers/meta-web.ts +2931 -0
  420. package/src/providers/read-failure.ts +139 -0
  421. package/src/providers/reddit-omni.ts +462 -0
  422. package/src/providers/reddit-web-runtime.ts +1929 -0
  423. package/src/providers/reddit-web.ts +1611 -0
  424. package/src/providers/rental-listings.ts +460 -0
  425. package/src/providers/substack-omni.ts +406 -0
  426. package/src/providers/substack-video-mp4.ts +33 -0
  427. package/src/providers/substack-web-runtime.ts +2812 -0
  428. package/src/providers/substack-web.ts +1502 -0
  429. package/src/providers/tiktok-video-mp4.ts +33 -0
  430. package/src/providers/tiktok-web-runtime.ts +770 -0
  431. package/src/providers/tiktok-web.ts +2786 -0
  432. package/src/providers/twitch-web-runtime.ts +226 -0
  433. package/src/providers/twitch-web.ts +274 -0
  434. package/src/providers/whatsapp-account-identity.ts +55 -0
  435. package/src/providers/whatsapp-contact-projection-helper.ts +874 -0
  436. package/src/providers/whatsapp-contact-projection-protocol.ts +403 -0
  437. package/src/providers/whatsapp-interaction-projection-helper.ts +1452 -0
  438. package/src/providers/whatsapp-interaction-projection-protocol.ts +462 -0
  439. package/src/providers/whatsapp-message-export-projection-protocol.ts +532 -0
  440. package/src/providers/whatsapp-messaging.ts +132 -0
  441. package/src/providers/whatsapp-omni.ts +586 -0
  442. package/src/providers/whatsapp-web-runtime.ts +3108 -0
  443. package/src/providers/whatsapp-web.ts +1037 -0
  444. package/src/providers/x-image-provenance.ts +523 -0
  445. package/src/providers/x-made-with-ai.ts +106 -0
  446. package/src/providers/x-transaction-id.ts +413 -0
  447. package/src/providers/x-web-runtime.ts +3485 -0
  448. package/src/providers/x-web.ts +2332 -0
  449. package/src/providers/x.ts +1889 -0
  450. package/src/providers/youtube-web-runtime.ts +1400 -0
  451. package/src/providers/youtube-web.ts +1221 -0
  452. package/src/read-admission-runtime.ts +91 -0
  453. package/src/read-client.ts +521 -0
  454. package/src/read-effect-platform.ts +7 -0
  455. package/src/read-effect-runtime.ts +14 -0
  456. package/src/read-effect.ts +52 -0
  457. package/src/read-projection-admission.ts +866 -0
  458. package/src/read-projections.ts +3151 -0
  459. package/src/recovery.ts +1492 -0
  460. package/src/reviewed-template.ts +146 -0
  461. package/src/run-journal.ts +1272 -0
  462. package/src/runtime.ts +5116 -0
  463. package/src/scripts/generate-provider-plugin-catalog.ts +121 -0
  464. package/src/scripts/install-whatsapp-protocol.sh +232 -0
  465. package/src/scripts/recover-install-stages.ts +1491 -0
  466. package/src/scripts/resolve-state-home.ts +30 -0
  467. package/src/scripts/scaffold-web-provider.ts +973 -0
  468. package/src/scripts/sync-bundled-adapters.ts +695 -0
  469. package/src/session-secrets.ts +1183 -0
  470. package/src/state-helper.bunfig.toml +2 -0
  471. package/src/state-helper.ts +2914 -0
  472. package/src/storage.ts +3842 -0
  473. package/src/transport-policy.ts +2 -0
  474. package/src/usage.ts +182 -0
  475. package/src/version.ts +2 -0
  476. package/src/web-session-authentication-policy.ts +136 -0
  477. package/src/web-session-cleanup-admission.ts +2344 -0
  478. package/src/web-session-cleanup-join.ts +99 -0
  479. package/src/web-session-client.ts +996 -0
  480. package/src/web-session-contract-definitions.ts +820 -0
  481. package/src/web-session-contract-planning.ts +42 -0
  482. package/src/web-session-contracts.ts +228 -0
  483. package/src/web-session-cookies.ts +500 -0
  484. package/src/web-session-execution.ts +385 -0
  485. package/src/web-session-http.ts +373 -0
  486. package/src/web-session-read-errors.ts +19 -0
  487. package/src/web-session-read-runtime.ts +124 -0
  488. package/src/web-session-recovery.ts +818 -0
  489. package/src/web-session-sites.ts +58 -0
  490. package/src/web-session-template.ts +994 -0
  491. package/src/web-session.ts +93 -0
  492. package/src/whatsapp-client-binding.ts +21 -0
  493. package/src/whatsapp-client-types.ts +75 -0
  494. package/src/whatsapp-client.ts +395 -0
  495. package/src/whatsapp-export-coordinate.ts +17 -0
  496. package/src/whatsapp-message-bundle-v2.ts +195 -0
  497. package/src/whatsapp-message-like-me-cli.ts +195 -0
  498. package/src/whatsapp-message-like-me-export.ts +1072 -0
  499. package/src/whatsapp-message-like-me-source.ts +1012 -0
  500. 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
+ }