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