@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,906 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isLiteralLanguageTag, normalizeAuthContextName } from "./args";
3
+ import { compareUtf8 } from "./utf8-order";
4
+
5
+ export interface CaptionSelection {
6
+ readonly source: "manual" | "automatic";
7
+ readonly language: string;
8
+ }
9
+
10
+ export interface AcquisitionIdentity {
11
+ /** Exact provider identity used only for probe/capture comparison and hashing. */
12
+ readonly extractor: string;
13
+ readonly id: string;
14
+ }
15
+
16
+ export const YT_DLP_OPAQUE_IDENTITY_PROFILE = "yt-dlp-opaque-url-v1" as const;
17
+ export const YT_DLP_AUTH_IDENTITY_PROFILE = "yt-dlp-auth-context-v1" as const;
18
+ export const AUTH_CONTEXT_IDENTITY_PROFILE = "wrench-media-auth-context-v1" as const;
19
+
20
+ /** Path-free inputs that let schema verification reproduce an opaque yt-dlp key. */
21
+ export interface OpaqueYtDlpIdentity {
22
+ readonly profile: typeof YT_DLP_OPAQUE_IDENTITY_PROFILE;
23
+ readonly providerIdentitySha256: string;
24
+ readonly requestedUrlSha256: string;
25
+ }
26
+
27
+ export type YtDlpPrivateAccessMode = "browser" | "ambient_config";
28
+
29
+ /** Path-free inputs that isolate one explicit private-access realm. */
30
+ export interface AuthenticatedYtDlpIdentity {
31
+ readonly profile: typeof YT_DLP_AUTH_IDENTITY_PROFILE;
32
+ readonly providerIdentitySha256: string;
33
+ readonly requestedUrlSha256: string;
34
+ readonly accessMode: YtDlpPrivateAccessMode;
35
+ readonly authContext: {
36
+ readonly profile: typeof AUTH_CONTEXT_IDENTITY_PROFILE;
37
+ readonly sha256: string;
38
+ };
39
+ }
40
+
41
+ export interface YtDlpAuthorizationIdentityInput {
42
+ readonly mode: YtDlpPrivateAccessMode;
43
+ readonly contextSha256: string;
44
+ }
45
+
46
+ export type SourceProjection = "youtube" | "opaque";
47
+
48
+ export interface ProbeMetadata {
49
+ readonly acquisitionIdentity: AcquisitionIdentity;
50
+ readonly projection: SourceProjection;
51
+ /** Exact unowned values that must be scrubbed from downstream diagnostics. */
52
+ readonly diagnosticRedactions: readonly string[];
53
+ readonly id: string;
54
+ readonly extractor: string;
55
+ readonly extractorDirectory: string;
56
+ readonly itemDirectory: string;
57
+ readonly assetKey: string;
58
+ /** Present only for unowned yt-dlp sources; never contains a raw URL or provider value. */
59
+ readonly opaqueYtDlpIdentity?: OpaqueYtDlpIdentity;
60
+ /** Present only for explicitly named private access; contains digests, never credentials. */
61
+ readonly authenticatedYtDlpIdentity?: AuthenticatedYtDlpIdentity;
62
+ readonly canonicalUrl: string;
63
+ readonly title?: string;
64
+ readonly uploader?: string;
65
+ readonly channel?: string;
66
+ readonly description?: string;
67
+ readonly license?: string;
68
+ readonly uploadDate?: string;
69
+ readonly timestamp?: number;
70
+ readonly durationSeconds?: number;
71
+ readonly originalLanguage?: string;
72
+ readonly manualCaptionLanguages: readonly string[];
73
+ readonly automaticCaptionLanguages: readonly string[];
74
+ }
75
+
76
+ const IDENTITY_KEY_VERSION = 1 as const;
77
+ const DIRECT_HTTP_IDENTITY_VERSION = 1 as const;
78
+ const OWNED_YOUTUBE_EXTRACTOR = "Youtube" as const;
79
+ const DIRECT_HTTP_EXTRACTOR = "DirectHttp" as const;
80
+ const OPAQUE_EXTRACTOR = "External" as const;
81
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
82
+ const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/u;
83
+ const IDENTITY_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
84
+ const EXTRACTOR_DIRECTORY_MAX_LENGTH = 80;
85
+ const WINDOWS_DEVICE_NAME_PATTERN = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu;
86
+
87
+ export const PROVIDER_METADATA_SCHEMA_VERSION = 1 as const;
88
+
89
+ export interface ProviderMetadataDocument {
90
+ readonly schemaVersion: typeof PROVIDER_METADATA_SCHEMA_VERSION;
91
+ readonly sourceAssetKey: string;
92
+ readonly source: {
93
+ readonly extractor: string;
94
+ readonly id: string;
95
+ readonly canonicalUrl: string;
96
+ readonly title?: string;
97
+ readonly uploader?: string;
98
+ readonly channel?: string;
99
+ readonly description?: string;
100
+ readonly license?: string;
101
+ readonly uploadDate?: string;
102
+ readonly timestamp?: number;
103
+ readonly durationSeconds?: number;
104
+ };
105
+ readonly captions: {
106
+ readonly originalLanguage?: string;
107
+ readonly manualLanguages: readonly string[];
108
+ readonly automaticLanguages: readonly string[];
109
+ };
110
+ }
111
+
112
+ export type ParseProbeResult =
113
+ | { readonly ok: true; readonly metadata: ProbeMetadata }
114
+ | {
115
+ readonly ok: false;
116
+ readonly kind: "invalid";
117
+ readonly message: string;
118
+ }
119
+ | {
120
+ readonly ok: false;
121
+ readonly kind: "unsupported";
122
+ readonly reason: "playlist" | "multi-video" | "live" | "upcoming" | "post-live" | "drm";
123
+ readonly message: string;
124
+ };
125
+
126
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
127
+ return typeof value === "object" && value !== null && !Array.isArray(value);
128
+ }
129
+
130
+ function hasControlCharacter(value: string): boolean {
131
+ for (const character of value) {
132
+ const codePoint = character.codePointAt(0);
133
+ if (codePoint !== undefined && (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f))) return true;
134
+ }
135
+ return false;
136
+ }
137
+
138
+ export function isWellFormedIdentity(value: string): boolean {
139
+ for (let index = 0; index < value.length; index += 1) {
140
+ const codeUnit = value.charCodeAt(index);
141
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
142
+ const next = value.charCodeAt(index + 1);
143
+ if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
144
+ index += 1;
145
+ } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
146
+ return false;
147
+ }
148
+ }
149
+ return true;
150
+ }
151
+
152
+ function assertWellFormedIdentity(value: string): void {
153
+ if (!isWellFormedIdentity(value)) {
154
+ throw new TypeError("provider identity contains ill-formed UTF-16");
155
+ }
156
+ }
157
+
158
+ function boundedString(record: Readonly<Record<string, unknown>>, key: string, maximum = 4_096): string | undefined {
159
+ const value = record[key];
160
+ if (typeof value !== "string") return undefined;
161
+ const normalized = value.normalize("NFC").trim();
162
+ return normalized.length > 0 && normalized.length <= maximum ? normalized : undefined;
163
+ }
164
+
165
+ function boundedIdentityString(
166
+ record: Readonly<Record<string, unknown>>,
167
+ key: string,
168
+ maximum: number,
169
+ ): string | undefined {
170
+ const value = record[key];
171
+ return typeof value === "string"
172
+ && value.length > 0
173
+ && value.length <= maximum
174
+ && !hasControlCharacter(value)
175
+ && isWellFormedIdentity(value)
176
+ ? value
177
+ : undefined;
178
+ }
179
+
180
+ function finiteNumber(record: Readonly<Record<string, unknown>>, key: string): number | undefined {
181
+ const value = record[key];
182
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
183
+ }
184
+
185
+ function diagnosticRedactions(
186
+ record: Readonly<Record<string, unknown>>,
187
+ acquisitionIdentity: AcquisitionIdentity,
188
+ ): readonly string[] {
189
+ const values: string[] = [acquisitionIdentity.extractor, acquisitionIdentity.id];
190
+ for (const key of [
191
+ "title",
192
+ "uploader",
193
+ "channel",
194
+ "description",
195
+ "license",
196
+ "upload_date",
197
+ "language",
198
+ ]) {
199
+ const value = record[key];
200
+ if (
201
+ typeof value === "string"
202
+ && value.length > 0
203
+ && value.length <= 64 * 1_024
204
+ && !hasControlCharacter(value)
205
+ && isWellFormedIdentity(value)
206
+ ) {
207
+ values.push(value);
208
+ const normalized = value.normalize("NFC").trim();
209
+ if (
210
+ normalized.length > 0
211
+ && normalized.length <= 64 * 1_024
212
+ && !hasControlCharacter(normalized)
213
+ && isWellFormedIdentity(normalized)
214
+ ) values.push(normalized);
215
+ }
216
+ }
217
+ // Longest-first exact replacement is deterministic and prevents a shorter
218
+ // identity from destroying a match for a longer descriptive value.
219
+ return [...new Set(values)].toSorted((left, right) => right.length - left.length);
220
+ }
221
+
222
+ const youtubeHosts = new Set([
223
+ "youtube.com",
224
+ "www.youtube.com",
225
+ "m.youtube.com",
226
+ "music.youtube.com",
227
+ "youtu.be",
228
+ "www.youtu.be",
229
+ "youtube-nocookie.com",
230
+ "www.youtube-nocookie.com",
231
+ ]);
232
+
233
+ function parsedSafeWebUrl(value: string | undefined): URL | undefined {
234
+ if (value === undefined || value.length > 8_192 || hasControlCharacter(value)) return undefined;
235
+ try {
236
+ const parsed = new URL(value);
237
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.username !== "" || parsed.password !== "") {
238
+ return undefined;
239
+ }
240
+ return parsed;
241
+ } catch {
242
+ return undefined;
243
+ }
244
+ }
245
+
246
+ function isOwnedYouTubeSource(
247
+ extractor: string,
248
+ providerId: string,
249
+ candidateUrls: readonly (string | undefined)[],
250
+ ): boolean {
251
+ // The exact extractor key is part of the ownership allowlist. Prefix or
252
+ // case-insensitive matching would let a plugin-defined extractor opt itself
253
+ // into persistence of raw identity and descriptive metadata.
254
+ return extractor === OWNED_YOUTUBE_EXTRACTOR
255
+ && YOUTUBE_VIDEO_ID_PATTERN.test(providerId)
256
+ && candidateUrls.some((value) => {
257
+ const parsed = parsedSafeWebUrl(value);
258
+ return parsed !== undefined && youtubeHosts.has(parsed.hostname.toLowerCase());
259
+ });
260
+ }
261
+
262
+ function canonicalSourceUrl(
263
+ candidateUrls: readonly (string | undefined)[],
264
+ projection: SourceProjection,
265
+ providerId: string,
266
+ ): string | undefined {
267
+ if (projection === "youtube") {
268
+ // YouTube's public video identity belongs in `v`; every other query
269
+ // parameter is navigation state, tracking data, or potentially secret.
270
+ const canonical = new URL("https://www.youtube.com/watch");
271
+ canonical.searchParams.set("v", providerId);
272
+ return canonical.href;
273
+ }
274
+ for (const value of candidateUrls) {
275
+ const parsed = parsedSafeWebUrl(value);
276
+ if (parsed === undefined) continue;
277
+ // Ghostget media does not own the semantics or privacy of arbitrary URL paths.
278
+ // Persist only the public origin until a provider-specific canonicalizer
279
+ // can explicitly allowlist more.
280
+ parsed.hash = "";
281
+ parsed.search = "";
282
+ parsed.pathname = "/";
283
+ return parsed.href;
284
+ }
285
+ return undefined;
286
+ }
287
+
288
+ function youtubeCanonicalUrl(providerId: string): string {
289
+ const canonical = new URL("https://www.youtube.com/watch");
290
+ canonical.searchParams.set("v", providerId);
291
+ return canonical.href;
292
+ }
293
+
294
+ function languageKeys(value: unknown): readonly string[] {
295
+ if (!isRecord(value)) return [];
296
+ return Object.keys(value)
297
+ .filter((key) => {
298
+ const tracks = value[key];
299
+ return Array.isArray(tracks)
300
+ && tracks.some(isRecord)
301
+ && isLiteralLanguageTag(key)
302
+ && !hasControlCharacter(key)
303
+ && isWellFormedIdentity(key)
304
+ && !key.includes("/")
305
+ && !key.includes("\\");
306
+ })
307
+ .toSorted(compareUtf8);
308
+ }
309
+
310
+ export function isPortableIdentityDirectorySegment(value: string): boolean {
311
+ return IDENTITY_SEGMENT_PATTERN.test(value)
312
+ && value !== "."
313
+ && value !== ".."
314
+ && !value.endsWith(".")
315
+ && !WINDOWS_DEVICE_NAME_PATTERN.test(value);
316
+ }
317
+
318
+ export function identityDirectorySegment(value: string, fallbackPrefix: string): string {
319
+ const normalized = value.normalize("NFKC");
320
+ // NFKC is useful for detecting compatibility characters, but accepting the
321
+ // normalized spelling would collapse distinct provider identities onto the
322
+ // same physical directory. Only an already-canonical raw value is reused.
323
+ if (
324
+ normalized === value
325
+ && isPortableIdentityDirectorySegment(value)
326
+ ) {
327
+ return normalized;
328
+ }
329
+ const digest = createHash("sha256").update(value, "utf8").digest("hex").slice(0, 24);
330
+ return `${fallbackPrefix}-${digest}`;
331
+ }
332
+
333
+ /**
334
+ * A readable but filesystem-portable provider item leaf. The digest is always
335
+ * present because provider IDs are case-sensitive while common macOS and
336
+ * Windows volumes are not; it also prevents Unicode normalization aliases.
337
+ */
338
+ export function sourceItemDirectory(value: string): string {
339
+ assertWellFormedIdentity(value);
340
+ const digest = createHash("sha256").update(value, "utf8").digest("hex").slice(0, 24);
341
+ const readable = value.normalize("NFKC").toLowerCase()
342
+ .replace(/[^a-z0-9._-]+/gu, "-")
343
+ .replace(/^[._-]+|[.-]+$/gu, "");
344
+ const maximumPrefixLength = 128 - digest.length - 1;
345
+ const prefix = readable
346
+ .slice(0, maximumPrefixLength)
347
+ .replace(/[.-]+$/gu, "") || "item";
348
+ return `${prefix}-${digest}`;
349
+ }
350
+
351
+ export function sourceExtractorDirectory(value: string): string {
352
+ assertWellFormedIdentity(value);
353
+ const compatibilityNormalized = value.normalize("NFKC");
354
+ const lower = compatibilityNormalized.toLowerCase();
355
+ const slug = lower
356
+ .replace(/[^a-z0-9._-]+/gu, "-")
357
+ .replace(/^[._-]+|[.-]+$/gu, "");
358
+ const digest = createHash("sha256").update(value, "utf8").digest("hex").slice(0, 24);
359
+ const readablePrefix = slug.length === 0 || slug === "." || slug === ".." ? "source" : slug;
360
+ const maximumPrefixLength = EXTRACTOR_DIRECTORY_MAX_LENGTH - digest.length - 1;
361
+ return `${readablePrefix.slice(0, maximumPrefixLength).replace(/[-.]+$/gu, "") || "source"}-${digest}`;
362
+ }
363
+
364
+ function tupleDigest(domain: string, components: readonly string[]): string {
365
+ const hash = createHash("sha256");
366
+ hash.update("wrench-media-identity-key\0", "utf8");
367
+ for (const component of [domain, ...components]) {
368
+ const bytes = new TextEncoder().encode(component);
369
+ const length = new Uint8Array(8);
370
+ new DataView(length.buffer).setBigUint64(0, BigInt(bytes.byteLength), false);
371
+ hash.update(length);
372
+ hash.update(bytes);
373
+ }
374
+ return hash.digest("hex");
375
+ }
376
+
377
+ function invalidProbe(message: string): Extract<ParseProbeResult, { readonly kind: "invalid" }> {
378
+ return { ok: false, kind: "invalid", message };
379
+ }
380
+
381
+ function unsupportedProbe(
382
+ reason: Extract<ParseProbeResult, { readonly kind: "unsupported" }>["reason"],
383
+ ): Extract<ParseProbeResult, { readonly kind: "unsupported" }> {
384
+ const labels = {
385
+ playlist: "playlist",
386
+ "multi-video": "multi-video source",
387
+ live: "live source",
388
+ upcoming: "upcoming live source",
389
+ "post-live": "post-live source",
390
+ drm: "DRM-protected source",
391
+ } as const;
392
+ return {
393
+ ok: false,
394
+ kind: "unsupported",
395
+ reason,
396
+ message: `yt-dlp probe returned an unsupported ${labels[reason]}`,
397
+ };
398
+ }
399
+
400
+ function unsupportedProbeEnvelope(
401
+ value: Readonly<Record<string, unknown>>,
402
+ ): Extract<ParseProbeResult, { readonly kind: "unsupported" }> | null {
403
+ if (value["_type"] === "playlist") return unsupportedProbe("playlist");
404
+ if (value["_type"] === "multi_video") return unsupportedProbe("multi-video");
405
+
406
+ const liveStatus = value["live_status"];
407
+ if (value["is_live"] === true || liveStatus === "is_live") return unsupportedProbe("live");
408
+ if (value["is_upcoming"] === true || liveStatus === "is_upcoming") {
409
+ return unsupportedProbe("upcoming");
410
+ }
411
+ if (liveStatus === "post_live") return unsupportedProbe("post-live");
412
+
413
+ if (value["has_drm"] === true || value["_has_drm"] === true) {
414
+ return unsupportedProbe("drm");
415
+ }
416
+ const requestedFormats = value["requested_formats"];
417
+ if (
418
+ Array.isArray(requestedFormats)
419
+ && requestedFormats.some((format) => isRecord(format) && format["has_drm"] === true)
420
+ ) {
421
+ return unsupportedProbe("drm");
422
+ }
423
+ const formats = value["formats"];
424
+ const formatRecords = Array.isArray(formats) ? formats.filter(isRecord) : [];
425
+ if (
426
+ Array.isArray(formats)
427
+ && formatRecords.length > 0
428
+ && formatRecords.length === formats.length
429
+ && formatRecords.every((format) => format["has_drm"] === true)
430
+ ) {
431
+ return unsupportedProbe("drm");
432
+ }
433
+ return null;
434
+ }
435
+
436
+ /** Domain-separated digest of the exact, case-sensitive provider tuple. */
437
+ export function providerIdentitySha256(extractor: string, providerId: string): string {
438
+ assertWellFormedIdentity(extractor);
439
+ assertWellFormedIdentity(providerId);
440
+ return tupleDigest("source", [extractor, providerId]);
441
+ }
442
+
443
+ /** SHA-256 of the normalized HTTP(S) request URL after removing only its fragment. */
444
+ export function requestedUrlSha256(value: string): string {
445
+ const parsed = parsedSafeWebUrl(value);
446
+ if (parsed === undefined) throw new TypeError("yt-dlp requested URL is malformed");
447
+ parsed.hash = "";
448
+ return createHash("sha256").update(parsed.href, "utf8").digest("hex");
449
+ }
450
+
451
+ /** Stable digest of a canonical user-declared authorization realm name. */
452
+ export function authContextSha256(value: string): string {
453
+ const normalized = normalizeAuthContextName(value);
454
+ if (normalized === null) throw new TypeError("authorization context is malformed");
455
+ return tupleDigest("auth-context", [normalized]);
456
+ }
457
+
458
+ /** A collision-resistant, domain-separated identity for the raw provider tuple. */
459
+ export function sourceAssetKey(extractor: string, providerId: string): string {
460
+ return `source-v${String(IDENTITY_KEY_VERSION)}-${providerIdentitySha256(extractor, providerId)}`;
461
+ }
462
+
463
+ /** Builds the URL-qualified identity used by every unowned yt-dlp source. */
464
+ export function opaqueYtDlpSourceAssetKey(identity: OpaqueYtDlpIdentity): string {
465
+ if (
466
+ identity.profile !== YT_DLP_OPAQUE_IDENTITY_PROFILE
467
+ || !SHA256_PATTERN.test(identity.providerIdentitySha256)
468
+ || !SHA256_PATTERN.test(identity.requestedUrlSha256)
469
+ ) {
470
+ throw new TypeError("opaque yt-dlp identity is malformed");
471
+ }
472
+ return `source-v2-${tupleDigest("yt-dlp-opaque-url", [
473
+ identity.providerIdentitySha256,
474
+ identity.requestedUrlSha256,
475
+ ])}`;
476
+ }
477
+
478
+ /** Stable public projection of a URL-qualified opaque yt-dlp source key. */
479
+ export function opaqueYtDlpSourceId(assetKey: string): string {
480
+ const match = /^source-v2-([0-9a-f]{64})$/u.exec(assetKey);
481
+ if (match?.[1] === undefined) throw new TypeError("opaque yt-dlp source asset key is malformed");
482
+ return `opaque-v2-${match[1]}`;
483
+ }
484
+
485
+ /** Builds the disjoint opaque identity used by browser and ambient-config access. */
486
+ export function authenticatedYtDlpSourceAssetKey(
487
+ identity: AuthenticatedYtDlpIdentity,
488
+ ): string {
489
+ if (
490
+ identity.profile !== YT_DLP_AUTH_IDENTITY_PROFILE
491
+ || !SHA256_PATTERN.test(identity.providerIdentitySha256)
492
+ || !SHA256_PATTERN.test(identity.requestedUrlSha256)
493
+ || (identity.accessMode !== "browser" && identity.accessMode !== "ambient_config")
494
+ || identity.authContext.profile !== AUTH_CONTEXT_IDENTITY_PROFILE
495
+ || !SHA256_PATTERN.test(identity.authContext.sha256)
496
+ ) {
497
+ throw new TypeError("authenticated yt-dlp identity is malformed");
498
+ }
499
+ return `source-v3-${tupleDigest("yt-dlp-auth-context", [
500
+ identity.providerIdentitySha256,
501
+ identity.requestedUrlSha256,
502
+ identity.accessMode,
503
+ identity.authContext.sha256,
504
+ ])}`;
505
+ }
506
+
507
+ /** Stable public projection of an authenticated opaque yt-dlp source key. */
508
+ export function authenticatedYtDlpSourceId(assetKey: string): string {
509
+ const match = /^source-v3-([0-9a-f]{64})$/u.exec(assetKey);
510
+ if (match?.[1] === undefined) {
511
+ throw new TypeError("authenticated yt-dlp source asset key is malformed");
512
+ }
513
+ return `opaque-v3-${match[1]}`;
514
+ }
515
+
516
+ export interface DirectHttpMetadataInput {
517
+ /** Origin-only public projection of the requested URL. */
518
+ readonly requestedOrigin: string;
519
+ /** SHA-256 of the exact fragment-stripped requested URL. */
520
+ readonly requestedUrlSha256: string;
521
+ /** SHA-256 of the complete response body. */
522
+ readonly bodySha256: string;
523
+ }
524
+
525
+ /**
526
+ * Builds a final direct-file identity only after the complete body is known.
527
+ * URL secrets never cross this boundary: callers provide an origin and hashes.
528
+ */
529
+ export function createDirectHttpMetadata(input: DirectHttpMetadataInput): ProbeMetadata {
530
+ if (
531
+ !isOriginOnlyCanonicalUrl(input.requestedOrigin)
532
+ || !SHA256_PATTERN.test(input.requestedUrlSha256)
533
+ || !SHA256_PATTERN.test(input.bodySha256)
534
+ ) {
535
+ throw new TypeError("direct HTTP identity is malformed");
536
+ }
537
+ const acquisitionId = `direct-http-v${String(DIRECT_HTTP_IDENTITY_VERSION)}-${tupleDigest(
538
+ "direct-http",
539
+ [input.requestedUrlSha256, input.bodySha256],
540
+ )}`;
541
+ const assetKey = sourceAssetKey(DIRECT_HTTP_EXTRACTOR, acquisitionId);
542
+ const projectedId = opaqueSourceId(assetKey);
543
+ return {
544
+ acquisitionIdentity: { extractor: DIRECT_HTTP_EXTRACTOR, id: acquisitionId },
545
+ projection: "opaque",
546
+ diagnosticRedactions: [acquisitionId, DIRECT_HTTP_EXTRACTOR],
547
+ id: projectedId,
548
+ extractor: OPAQUE_EXTRACTOR,
549
+ extractorDirectory: sourceExtractorDirectory(OPAQUE_EXTRACTOR),
550
+ itemDirectory: sourceItemDirectory(projectedId),
551
+ assetKey,
552
+ canonicalUrl: input.requestedOrigin,
553
+ manualCaptionLanguages: [],
554
+ automaticCaptionLanguages: [],
555
+ };
556
+ }
557
+
558
+ /** Stable opaque identity projected from a raw provider tuple digest. */
559
+ export function opaqueSourceId(assetKey: string): string {
560
+ const prefix = `source-v${String(IDENTITY_KEY_VERSION)}-`;
561
+ if (!assetKey.startsWith(prefix) || !/^[0-9a-f]{64}$/u.test(assetKey.slice(prefix.length))) {
562
+ throw new TypeError("source asset key is malformed");
563
+ }
564
+ return `opaque-v${String(IDENTITY_KEY_VERSION)}-${assetKey.slice(prefix.length)}`;
565
+ }
566
+
567
+ function isOriginOnlyCanonicalUrl(value: string): boolean {
568
+ try {
569
+ const url = new URL(value);
570
+ return (url.protocol === "http:" || url.protocol === "https:")
571
+ && url.username === ""
572
+ && url.password === ""
573
+ && url.pathname === "/"
574
+ && url.search === ""
575
+ && url.hash === ""
576
+ && url.href === `${url.origin}/`;
577
+ } catch {
578
+ return false;
579
+ }
580
+ }
581
+
582
+ /** Validates the complete in-memory projection before any path or artifact write. */
583
+ export function isNormalizedProbeMetadata(metadata: ProbeMetadata): boolean {
584
+ try {
585
+ if (metadata.projection !== "youtube" && metadata.projection !== "opaque") return false;
586
+ const raw = metadata.acquisitionIdentity;
587
+ if (
588
+ raw.extractor.length === 0
589
+ || raw.extractor.length > 512
590
+ || raw.id.length === 0
591
+ || raw.id.length > 512
592
+ || hasControlCharacter(raw.extractor)
593
+ || hasControlCharacter(raw.id)
594
+ || !isWellFormedIdentity(raw.extractor)
595
+ || !isWellFormedIdentity(raw.id)
596
+ ) return false;
597
+ const v1AssetKey = sourceAssetKey(raw.extractor, raw.id);
598
+ const opaqueIdentity = metadata.opaqueYtDlpIdentity;
599
+ const authenticatedIdentity = metadata.authenticatedYtDlpIdentity;
600
+ if (opaqueIdentity !== undefined && authenticatedIdentity !== undefined) return false;
601
+ const assetKey = authenticatedIdentity !== undefined
602
+ ? authenticatedYtDlpSourceAssetKey(authenticatedIdentity)
603
+ : opaqueIdentity === undefined
604
+ ? v1AssetKey
605
+ : opaqueYtDlpSourceAssetKey(opaqueIdentity);
606
+ const expectedExtractor = metadata.projection === "youtube"
607
+ ? OWNED_YOUTUBE_EXTRACTOR
608
+ : OPAQUE_EXTRACTOR;
609
+ const expectedId = metadata.projection === "youtube"
610
+ ? raw.id
611
+ : authenticatedIdentity !== undefined
612
+ ? authenticatedYtDlpSourceId(assetKey)
613
+ : opaqueIdentity === undefined
614
+ ? opaqueSourceId(assetKey)
615
+ : opaqueYtDlpSourceId(assetKey);
616
+ if (
617
+ metadata.assetKey !== assetKey
618
+ || metadata.extractor !== expectedExtractor
619
+ || metadata.id !== expectedId
620
+ || metadata.extractorDirectory !== sourceExtractorDirectory(expectedExtractor)
621
+ || metadata.itemDirectory !== sourceItemDirectory(expectedId)
622
+ || !isPortableIdentityDirectorySegment(metadata.extractorDirectory)
623
+ || !isPortableIdentityDirectorySegment(metadata.itemDirectory)
624
+ ) return false;
625
+
626
+ if (metadata.projection === "youtube") {
627
+ return raw.extractor === OWNED_YOUTUBE_EXTRACTOR
628
+ && YOUTUBE_VIDEO_ID_PATTERN.test(raw.id)
629
+ && metadata.canonicalUrl === youtubeCanonicalUrl(raw.id)
630
+ && opaqueIdentity === undefined
631
+ && authenticatedIdentity === undefined
632
+ && metadata.diagnosticRedactions.length === 0;
633
+ }
634
+ const directHttpIdentity = raw.extractor === DIRECT_HTTP_EXTRACTOR
635
+ && /^direct-http-v1-[0-9a-f]{64}$/u.test(raw.id);
636
+ if (directHttpIdentity) {
637
+ if (opaqueIdentity !== undefined || authenticatedIdentity !== undefined) return false;
638
+ } else if (authenticatedIdentity !== undefined) {
639
+ if (
640
+ authenticatedIdentity.profile !== YT_DLP_AUTH_IDENTITY_PROFILE
641
+ || authenticatedIdentity.providerIdentitySha256 !== providerIdentitySha256(raw.extractor, raw.id)
642
+ || !SHA256_PATTERN.test(authenticatedIdentity.requestedUrlSha256)
643
+ || (authenticatedIdentity.accessMode !== "browser" && authenticatedIdentity.accessMode !== "ambient_config")
644
+ || authenticatedIdentity.authContext.profile !== AUTH_CONTEXT_IDENTITY_PROFILE
645
+ || !SHA256_PATTERN.test(authenticatedIdentity.authContext.sha256)
646
+ ) return false;
647
+ } else {
648
+ if (
649
+ opaqueIdentity === undefined
650
+ || opaqueIdentity.profile !== YT_DLP_OPAQUE_IDENTITY_PROFILE
651
+ || opaqueIdentity.providerIdentitySha256 !== providerIdentitySha256(raw.extractor, raw.id)
652
+ || !SHA256_PATTERN.test(opaqueIdentity.requestedUrlSha256)
653
+ ) return false;
654
+ }
655
+ if (!isOriginOnlyCanonicalUrl(metadata.canonicalUrl)) return false;
656
+ if (
657
+ metadata.title !== undefined
658
+ || metadata.uploader !== undefined
659
+ || metadata.channel !== undefined
660
+ || metadata.description !== undefined
661
+ || metadata.license !== undefined
662
+ || metadata.uploadDate !== undefined
663
+ || metadata.timestamp !== undefined
664
+ || metadata.durationSeconds !== undefined
665
+ || metadata.originalLanguage !== undefined
666
+ ) return false;
667
+ if (
668
+ !metadata.diagnosticRedactions.includes(raw.extractor)
669
+ || !metadata.diagnosticRedactions.includes(raw.id)
670
+ || metadata.diagnosticRedactions.length > 16
671
+ || metadata.diagnosticRedactions.some((value) =>
672
+ value.length === 0
673
+ || value.length > 64 * 1_024
674
+ || hasControlCharacter(value)
675
+ || !isWellFormedIdentity(value))
676
+ ) return false;
677
+ return true;
678
+ } catch {
679
+ return false;
680
+ }
681
+ }
682
+
683
+ /** A disjoint identity for one focused view of a source archive. */
684
+ export function variantAssetKey(
685
+ sourceKey: string,
686
+ variantSegments: readonly string[],
687
+ ): string {
688
+ return `variant-v${String(IDENTITY_KEY_VERSION)}-${tupleDigest("variant", [sourceKey, ...variantSegments])}`;
689
+ }
690
+
691
+ export function parseProbeMetadata(
692
+ value: unknown,
693
+ requestedUrl: string,
694
+ authorization?: YtDlpAuthorizationIdentityInput,
695
+ ): ParseProbeResult {
696
+ if (!isRecord(value)) return invalidProbe("yt-dlp probe did not return an object");
697
+ const unsupported = unsupportedProbeEnvelope(value);
698
+ if (unsupported !== null) return unsupported;
699
+ const id = boundedIdentityString(value, "id", 512);
700
+ const extractor = boundedIdentityString(value, "extractor_key", 512)
701
+ ?? boundedIdentityString(value, "extractor", 512);
702
+ if (id === undefined || extractor === undefined) {
703
+ return invalidProbe("yt-dlp probe is missing a bounded extractor identity");
704
+ }
705
+ const candidateUrls = [
706
+ boundedString(value, "webpage_url", 8_192),
707
+ boundedString(value, "original_url", 8_192),
708
+ requestedUrl,
709
+ ] as const;
710
+ if (
711
+ authorization !== undefined
712
+ && (
713
+ (authorization.mode !== "browser" && authorization.mode !== "ambient_config")
714
+ || !SHA256_PATTERN.test(authorization.contextSha256)
715
+ )
716
+ ) return invalidProbe("yt-dlp authorization identity is malformed");
717
+ const projection: SourceProjection = authorization === undefined
718
+ && isOwnedYouTubeSource(extractor, id, candidateUrls)
719
+ ? "youtube"
720
+ : "opaque";
721
+ const canonicalUrl = canonicalSourceUrl(candidateUrls, projection, id);
722
+ if (canonicalUrl === undefined) return invalidProbe("yt-dlp probe is missing a safe canonical URL");
723
+ let opaqueIdentity: OpaqueYtDlpIdentity | undefined;
724
+ let authenticatedIdentity: AuthenticatedYtDlpIdentity | undefined;
725
+ if (projection === "opaque") {
726
+ let requestDigest: string;
727
+ try {
728
+ requestDigest = requestedUrlSha256(requestedUrl);
729
+ } catch {
730
+ return invalidProbe("yt-dlp probe is missing a safe requested URL");
731
+ }
732
+ const providerDigest = providerIdentitySha256(extractor, id);
733
+ if (authorization === undefined) {
734
+ opaqueIdentity = {
735
+ profile: YT_DLP_OPAQUE_IDENTITY_PROFILE,
736
+ providerIdentitySha256: providerDigest,
737
+ requestedUrlSha256: requestDigest,
738
+ };
739
+ } else {
740
+ authenticatedIdentity = {
741
+ profile: YT_DLP_AUTH_IDENTITY_PROFILE,
742
+ providerIdentitySha256: providerDigest,
743
+ requestedUrlSha256: requestDigest,
744
+ accessMode: authorization.mode,
745
+ authContext: {
746
+ profile: AUTH_CONTEXT_IDENTITY_PROFILE,
747
+ sha256: authorization.contextSha256,
748
+ },
749
+ };
750
+ }
751
+ }
752
+ const assetKey = authenticatedIdentity !== undefined
753
+ ? authenticatedYtDlpSourceAssetKey(authenticatedIdentity)
754
+ : opaqueIdentity === undefined
755
+ ? sourceAssetKey(extractor, id)
756
+ : opaqueYtDlpSourceAssetKey(opaqueIdentity);
757
+ const projectedExtractor = projection === "youtube" ? OWNED_YOUTUBE_EXTRACTOR : OPAQUE_EXTRACTOR;
758
+ const projectedId = projection === "youtube"
759
+ ? id
760
+ : authenticatedIdentity === undefined
761
+ ? opaqueYtDlpSourceId(assetKey)
762
+ : authenticatedYtDlpSourceId(assetKey);
763
+ const extractorPath = sourceExtractorDirectory(projectedExtractor);
764
+ const itemPath = sourceItemDirectory(projectedId);
765
+ // Descriptive fields are provider-owned public metadata only when the source
766
+ // is on Ghostget media's narrow ownership allowlist. Generic extractors frequently
767
+ // derive these strings from signed URL basenames.
768
+ const title = projection === "youtube" ? boundedString(value, "title", 2_048) : undefined;
769
+ const uploader = projection === "youtube" ? boundedString(value, "uploader", 1_024) : undefined;
770
+ const channel = projection === "youtube" ? boundedString(value, "channel", 1_024) : undefined;
771
+ const description = projection === "youtube" ? boundedString(value, "description", 64 * 1_024) : undefined;
772
+ const license = projection === "youtube" ? boundedString(value, "license", 1_024) : undefined;
773
+ const uploadDate = projection === "youtube" ? boundedString(value, "upload_date", 32) : undefined;
774
+ const timestamp = projection === "youtube" ? finiteNumber(value, "timestamp") : undefined;
775
+ const durationSeconds = projection === "youtube" ? finiteNumber(value, "duration") : undefined;
776
+ const originalLanguage = projection === "youtube" ? boundedString(value, "language", 128) : undefined;
777
+ return {
778
+ ok: true,
779
+ metadata: {
780
+ acquisitionIdentity: { extractor, id },
781
+ projection,
782
+ diagnosticRedactions: projection === "opaque"
783
+ ? diagnosticRedactions(value, { extractor, id })
784
+ : [],
785
+ id: projectedId,
786
+ extractor: projectedExtractor,
787
+ extractorDirectory: extractorPath,
788
+ itemDirectory: itemPath,
789
+ assetKey,
790
+ ...(opaqueIdentity === undefined ? {} : { opaqueYtDlpIdentity: opaqueIdentity }),
791
+ ...(authenticatedIdentity === undefined
792
+ ? {}
793
+ : { authenticatedYtDlpIdentity: authenticatedIdentity }),
794
+ canonicalUrl,
795
+ ...(title === undefined ? {} : { title }),
796
+ ...(uploader === undefined ? {} : { uploader }),
797
+ ...(channel === undefined ? {} : { channel }),
798
+ ...(description === undefined ? {} : { description }),
799
+ ...(license === undefined ? {} : { license }),
800
+ ...(uploadDate === undefined ? {} : { uploadDate }),
801
+ ...(timestamp === undefined ? {} : { timestamp }),
802
+ ...(durationSeconds === undefined ? {} : { durationSeconds }),
803
+ ...(originalLanguage === undefined ? {} : { originalLanguage }),
804
+ manualCaptionLanguages: languageKeys(value["subtitles"]),
805
+ automaticCaptionLanguages: languageKeys(value["automatic_captions"]),
806
+ },
807
+ };
808
+ }
809
+
810
+ /**
811
+ * Builds the complete provider metadata artifact from Ghostget media's owned probe model.
812
+ * Unknown yt-dlp fields never cross this boundary, including formats, fragment
813
+ * URLs, request headers, cookies, and extractor-private state.
814
+ */
815
+ export function createProviderMetadataDocument(
816
+ metadata: ProbeMetadata,
817
+ ): ProviderMetadataDocument {
818
+ if (!isNormalizedProbeMetadata(metadata)) {
819
+ throw new TypeError("provider metadata projection is inconsistent");
820
+ }
821
+ return {
822
+ schemaVersion: PROVIDER_METADATA_SCHEMA_VERSION,
823
+ sourceAssetKey: metadata.assetKey,
824
+ source: {
825
+ extractor: metadata.extractor,
826
+ id: metadata.id,
827
+ canonicalUrl: metadata.canonicalUrl,
828
+ ...(metadata.title === undefined ? {} : { title: metadata.title }),
829
+ ...(metadata.uploader === undefined ? {} : { uploader: metadata.uploader }),
830
+ ...(metadata.channel === undefined ? {} : { channel: metadata.channel }),
831
+ ...(metadata.description === undefined ? {} : { description: metadata.description }),
832
+ ...(metadata.license === undefined ? {} : { license: metadata.license }),
833
+ ...(metadata.uploadDate === undefined ? {} : { uploadDate: metadata.uploadDate }),
834
+ ...(metadata.timestamp === undefined ? {} : { timestamp: metadata.timestamp }),
835
+ ...(metadata.durationSeconds === undefined
836
+ ? {}
837
+ : { durationSeconds: metadata.durationSeconds }),
838
+ },
839
+ captions: {
840
+ ...(metadata.originalLanguage === undefined
841
+ ? {}
842
+ : { originalLanguage: metadata.originalLanguage }),
843
+ manualLanguages: [...metadata.manualCaptionLanguages],
844
+ automaticLanguages: [...metadata.automaticCaptionLanguages],
845
+ },
846
+ };
847
+ }
848
+
849
+ export function renderProviderMetadataJson(metadata: ProbeMetadata): string {
850
+ return `${JSON.stringify(createProviderMetadataDocument(metadata), null, 2)}\n`;
851
+ }
852
+
853
+ function languageFamily(value: string): string {
854
+ return value.toLowerCase().split("-")[0] ?? "";
855
+ }
856
+
857
+ export function selectCaption(metadata: ProbeMetadata, requestedLanguage: string): CaptionSelection | null {
858
+ const requested = requestedLanguage.toLowerCase();
859
+ const requestedFamily = languageFamily(requested);
860
+ const original = metadata.originalLanguage?.toLowerCase();
861
+ const originalFamily = original === undefined ? undefined : languageFamily(original);
862
+ const relevance = (language: string): number => {
863
+ const normalized = language.toLowerCase();
864
+ if (normalized === requested) return 0;
865
+ if (languageFamily(normalized) === requestedFamily) return 1;
866
+ if (original !== undefined && normalized === original) return 2;
867
+ if (originalFamily !== undefined && languageFamily(normalized) === originalFamily) return 3;
868
+ return 4;
869
+ };
870
+
871
+ let best: Readonly<{
872
+ selection: CaptionSelection;
873
+ relevance: number;
874
+ quality: number;
875
+ order: number;
876
+ }> | null = null;
877
+ let order = 0;
878
+ for (const [source, languages] of [
879
+ ["manual", metadata.manualCaptionLanguages],
880
+ ["automatic", metadata.automaticCaptionLanguages],
881
+ ] as const) {
882
+ const quality = source === "manual" ? 0 : 1;
883
+ for (const language of languages) {
884
+ const candidate = {
885
+ selection: { source, language },
886
+ relevance: relevance(language),
887
+ quality,
888
+ order,
889
+ } as const;
890
+ order += 1;
891
+ if (
892
+ best === null
893
+ || candidate.relevance < best.relevance
894
+ || (candidate.relevance === best.relevance && candidate.quality < best.quality)
895
+ || (
896
+ candidate.relevance === best.relevance
897
+ && candidate.quality === best.quality
898
+ && candidate.order < best.order
899
+ )
900
+ ) {
901
+ best = candidate;
902
+ }
903
+ }
904
+ }
905
+ return best?.selection ?? null;
906
+ }