fleetmesh-node 0.2.0__py3-none-any.whl

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 (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. sidecar/fleetmesh_ssb.py +603 -0
@@ -0,0 +1,847 @@
1
+ """Live CMAF / LL-HLS Packager, p2panda Manifest Engine & RhioBaoTree Verification (Phase 2).
2
+
3
+ Stores video segments across BlobStore and RhioBlobSync with 1024-byte chunked
4
+ Bao Merkle tree roots, appends cryptographic manifest operations to P2PandaLogStore,
5
+ renders dynamic HLS/LL-HLS `.m3u8` playlists, projects stream announcements across
6
+ all 6 protocols via ProtocolTranslator, and enforces commercial settlement via
7
+ PaidFetch dual-item HTTP 402 or CrossProtocolSettlementGateway token buckets.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import math
14
+ import os
15
+ import threading
16
+ import time
17
+ import urllib.parse
18
+ from typing import Any, Callable, Mapping, Optional, Sequence, TypedDict
19
+
20
+ from cryptography.hazmat.primitives.asymmetric import ed25519
21
+
22
+ from fleetmesh_core.identity.cid import sha256_to_cid
23
+ from fleetmesh_core.wire.cbor import cbor_decode, cbor_encode
24
+ from fleetmesh_core.wire.interfaces import ProtocolHostRouter
25
+ from fleetmesh_core.wire.p2panda import P2PandaOperation, validate_operation_chain
26
+ from fleetmesh_core.wire.video_transit import (
27
+ VideoSegmentEntry,
28
+ VideoStreamGenesis,
29
+ )
30
+ from node.planes.services.gateway.settle import CrossProtocolSettlementGateway
31
+ from node.planes.services.gateway.translate import ProtocolTranslator, UnifiedPost
32
+ from node.protocols.p2panda.identity import P2PandaIdentityAdapter
33
+ from node.protocols.p2panda.store import P2PandaLogStore
34
+ from node.protocols.rhio.store import RhioBaoTree, RhioBlobSync
35
+ from node.planes.services.video.timed_segments import stamp_capture_time
36
+
37
+
38
+ class StreamStateRecord(TypedDict):
39
+ stream_id: str
40
+ author_key: str
41
+ genesis: VideoStreamGenesis
42
+ segments: list[VideoSegmentEntry]
43
+ next_segment_index: int
44
+ transit_endpoints: list[str]
45
+ latest_op_id: str
46
+ latest_seq: int
47
+ verified_up_to: int
48
+
49
+
50
+ class SegmentMultiAddress(TypedDict):
51
+ sha256: str
52
+ tree_root: str
53
+ cid: str
54
+ size: int
55
+ blossom_path: str
56
+ rhio_path: str
57
+ atproto_path: str
58
+
59
+
60
+ class VideoStreamPackager:
61
+ """Packages CMAF / LL-HLS segments into content-addressed storage and
62
+ maintains tamper-evident p2panda manifest operation logs.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ p2panda_store: Optional[P2PandaLogStore] = None,
68
+ rhio_store: Optional[RhioBlobSync] = None,
69
+ blob_store: Optional[Any] = None,
70
+ identity: Optional[P2PandaIdentityAdapter] = None,
71
+ strain_ledger: Optional[Any] = None,
72
+ paid_fetch: Optional[Any] = None,
73
+ clock: Optional[Callable[[], int]] = None,
74
+ settlement_gateway: Optional[CrossProtocolSettlementGateway] = None,
75
+ ) -> None:
76
+ # An injected clock, defaulting to wall time, so a suite can move time
77
+ # at the boundaries this code decides on. The compartment passes its own.
78
+ self._clock: Callable[[], int] = clock or (lambda: int(time.time()))
79
+ # Segments stored in the CAS but not published to the blob server.
80
+ # This was `except Exception: pass`, so a segment was in the manifest
81
+ # and unfetchable by any viewer, and nothing said so.
82
+ self.unpublished: int = 0
83
+ self.last_publish_error: str = ''
84
+ self.p2panda_store = p2panda_store or P2PandaLogStore(':memory:')
85
+ self.rhio_store = rhio_store or RhioBlobSync(':memory:')
86
+ self.blob_store = blob_store
87
+ self.identity = identity or P2PandaIdentityAdapter(
88
+ private_key=ed25519.Ed25519PrivateKey.generate()
89
+ )
90
+ self.strain_ledger = strain_ledger
91
+ self.paid_fetch = paid_fetch
92
+ self.settlement_gateway = settlement_gateway
93
+
94
+ self._lock = threading.RLock()
95
+ self._streams: dict[str, StreamStateRecord] = {}
96
+ self._stream_keys: dict[str, ed25519.Ed25519PrivateKey] = {}
97
+
98
+ def _signing_key_for_stream(self, stream_id: str) -> tuple[ed25519.Ed25519PrivateKey, str]:
99
+ """Use a deterministic per-stream Ed25519 subkey so each stream has its own
100
+ independent p2panda operation log starting at seq_num == 0.
101
+ """
102
+ with self._lock:
103
+ if stream_id not in self._stream_keys:
104
+ priv_key = getattr(self.identity, '_private_key', None)
105
+ base_raw = (
106
+ priv_key.private_bytes_raw()
107
+ if priv_key is not None
108
+ else self.identity.author_id.encode('utf-8')
109
+ )
110
+ seed = hashlib.sha256(base_raw + b':' + stream_id.encode('utf-8')).digest()
111
+ self._stream_keys[stream_id] = ed25519.Ed25519PrivateKey.from_private_bytes(seed)
112
+ sk = self._stream_keys[stream_id]
113
+ pk_hex = sk.public_key().public_bytes_raw().hex()
114
+ return sk, pk_hex
115
+
116
+ # ---- Storage & Multi-Protocol Content Addressing ------------------------
117
+
118
+ def store_segment_bytes(self, data: bytes) -> SegmentMultiAddress:
119
+ """Store a segment in RhioBlobSync (computing Bao Merkle tree root) and
120
+ mirror into BlobStore if attached. Returns all multi-protocol addresses.
121
+ """
122
+ sha256_hex, tree_root, size = self.rhio_store.store_blob(data)
123
+ if self.blob_store is not None and hasattr(self.blob_store, 'put'):
124
+ try:
125
+ self.blob_store.put(data)
126
+ except Exception as e:
127
+ # The segment is in the CAS and will be in the manifest, but
128
+ # no viewer can fetch it. Counted, not discarded.
129
+ self.unpublished += 1
130
+ self.last_publish_error = '%s: %s' % (type(e).__name__, e)
131
+ cid_str = sha256_to_cid(sha256_hex)
132
+ return {
133
+ 'sha256': sha256_hex,
134
+ 'tree_root': tree_root,
135
+ 'cid': cid_str,
136
+ 'size': size,
137
+ 'blossom_path': f'/{sha256_hex}.m4s',
138
+ 'rhio_path': f'/rhio/blobs/{sha256_hex}',
139
+ 'atproto_path': f'/cid/{cid_str}',
140
+ }
141
+
142
+ def get_segment_bytes(self, sha256_hex: str) -> Optional[bytes]:
143
+ clean = sha256_hex.lower().strip()
144
+ if clean.endswith('.m4s'):
145
+ clean = clean[:-4]
146
+ elif clean.endswith('.mp4'):
147
+ clean = clean[:-4]
148
+ blob = self.rhio_store.get_blob(clean)
149
+ if blob is None and self.blob_store is not None and hasattr(self.blob_store, 'get'):
150
+ try:
151
+ blob = self.blob_store.get(clean)
152
+ except Exception:
153
+ blob = None
154
+ return blob
155
+
156
+ # ---- Bao 1024-Byte Sub-Segment Verification & Strain Penalty ------------
157
+
158
+ def compute_subchunk_leaves(self, segment_data: bytes) -> tuple[str, list[str]]:
159
+ """Return (tree_root, leaf_hashes_hex) for 1024-byte slices of a segment."""
160
+ root, leaves = RhioBaoTree.compute_tree(segment_data)
161
+ return root, [leaf.hex() for leaf in leaves]
162
+
163
+ def verify_subchunk(
164
+ self,
165
+ chunk_data: bytes,
166
+ expected_leaf_hash: str,
167
+ peer: Optional[str] = None,
168
+ ) -> bool:
169
+ """Verify a single 1024-byte slice against its expected Bao leaf hash.
170
+ Penalizes offending peer in StrainLedger immediately upon mismatch.
171
+ """
172
+ computed = hashlib.sha256(b'\x00' + chunk_data).hexdigest()
173
+ ok = computed.lower() == expected_leaf_hash.lower()
174
+ if not ok and peer and self.strain_ledger is not None:
175
+ if hasattr(self.strain_ledger, 'record'):
176
+ self.strain_ledger.record('corrupt_chunk', peer=peer)
177
+ return ok
178
+
179
+ def verify_segment(
180
+ self,
181
+ segment_data: bytes,
182
+ expected_tree_root: str,
183
+ peer: Optional[str] = None,
184
+ ) -> bool:
185
+ """Verify an entire segment against its Bao Merkle tree root."""
186
+ ok = RhioBaoTree.verify(segment_data, expected_tree_root)
187
+ if not ok and peer and self.strain_ledger is not None:
188
+ if hasattr(self.strain_ledger, 'record'):
189
+ self.strain_ledger.record('corrupt_chunk', peer=peer)
190
+ return ok
191
+
192
+ # ---- Stream Lifecycle & p2panda Manifest Operations ---------------------
193
+
194
+ def create_stream(
195
+ self,
196
+ stream_id: str,
197
+ broadcaster: str,
198
+ title: str = 'Live Stream',
199
+ codecs: Sequence[str] = ('avc1.64001f', 'mp4a.40.2'),
200
+ target_duration_ms: int = 2000,
201
+ part_target_duration_ms: int = 500,
202
+ init_segment: Optional[bytes] = None,
203
+ transit_endpoints: Sequence[str] = (),
204
+ price_msat_per_mb: int = 0,
205
+ account_fee_msat: int = 0,
206
+ now: Optional[int] = None,
207
+ log_key: Optional[ed25519.Ed25519PrivateKey] = None,
208
+ genesis_extensions: Optional[Mapping[str, Any]] = None,
209
+ ) -> tuple[VideoStreamGenesis, P2PandaOperation]:
210
+ """Initialize a new video stream and append its genesis operation (seq_num == 0)
211
+ to P2PandaLogStore.
212
+
213
+ `log_key` signs the stream's log in place of the node-derived subkey, so whoever holds
214
+ it (the broadcaster, a failover node) writes the same log. If that log already exists,
215
+ the stream resumes it instead of forking a second genesis; an ended log is refused.
216
+ `genesis_extensions` are merged into the genesis header, and so covered by its signature.
217
+ """
218
+ now_val = self._clock() if now is None else int(now)
219
+ init_sha256 = ''
220
+ init_tree_root = ''
221
+ init_size = 0
222
+ if init_segment is not None:
223
+ addr = self.store_segment_bytes(init_segment)
224
+ init_sha256 = addr['sha256']
225
+ init_tree_root = addr['tree_root']
226
+ init_size = addr['size']
227
+ if hasattr(self.rhio_store, 'claim_blob'):
228
+ self.rhio_store.claim_blob(
229
+ init_tree_root,
230
+ {'identity_id': broadcaster.lower(), 'did': f'did:nostr:{broadcaster.lower()}', 'handle': ''},
231
+ sha256_hex=init_sha256,
232
+ size=init_size,
233
+ kind='video',
234
+ mime_type='video/mp4',
235
+ visibility='public',
236
+ )
237
+
238
+ genesis = VideoStreamGenesis(
239
+ stream_id=stream_id,
240
+ broadcaster=broadcaster.lower(),
241
+ title=title,
242
+ codecs=tuple(codecs),
243
+ target_duration_ms=int(target_duration_ms),
244
+ part_target_duration_ms=int(part_target_duration_ms),
245
+ init_sha256=init_sha256,
246
+ init_tree_root=init_tree_root,
247
+ init_size=init_size,
248
+ transit_endpoints=tuple(transit_endpoints),
249
+ price_msat_per_mb=int(price_msat_per_mb),
250
+ account_fee_msat=int(account_fee_msat),
251
+ created_at=now_val,
252
+ )
253
+
254
+ if log_key is not None:
255
+ with self._lock:
256
+ self._stream_keys[stream_id] = log_key
257
+ sk, pk_hex = self._signing_key_for_stream(stream_id)
258
+ latest = self.p2panda_store.get_latest_operation(pk_hex)
259
+ next_segment_index = 0
260
+ if latest is not None:
261
+ if (latest.header.extensions or {}).get('kind') == 'video.end':
262
+ raise ValueError(f'The log for stream {stream_id} has ended; start a new log key')
263
+ op = self.p2panda_store.get_operation_by_seq(pk_hex, 0) or latest
264
+ if latest.seq_num > 0 and latest.body:
265
+ try:
266
+ last_body, _ = cbor_decode(latest.body)
267
+ next_segment_index = int(last_body.get('segment_index', -1)) + 1
268
+ except Exception:
269
+ next_segment_index = 0
270
+ else:
271
+ op = P2PandaOperation.create(
272
+ public_key=pk_hex,
273
+ body=genesis.to_cbor(),
274
+ seq_num=0,
275
+ backlink=None,
276
+ timestamp=now_val,
277
+ private_key=sk,
278
+ extensions={**dict(genesis_extensions or {}), 'stream_id': stream_id, 'kind': 'video.genesis'},
279
+ )
280
+ self.p2panda_store.append_operation(op, verify_chain=True)
281
+ latest = op
282
+
283
+ with self._lock:
284
+ self._streams[stream_id] = {
285
+ 'stream_id': stream_id,
286
+ 'author_key': pk_hex,
287
+ 'genesis': genesis,
288
+ 'segments': [],
289
+ 'next_segment_index': next_segment_index,
290
+ 'transit_endpoints': list(transit_endpoints),
291
+ 'latest_op_id': latest.id,
292
+ 'latest_seq': latest.seq_num,
293
+ 'verified_up_to': latest.seq_num,
294
+ }
295
+ return genesis, op
296
+
297
+ def end_stream(self, stream_id: str, now: Optional[int] = None) -> P2PandaOperation:
298
+ """Append the signed `video.end` operation that closes the stream's log. Idempotent."""
299
+ with self._lock:
300
+ if stream_id not in self._streams:
301
+ raise KeyError(f"Unknown stream_id: {stream_id}")
302
+ state = self._streams[stream_id]
303
+ latest = self.p2panda_store.get_latest_operation(state['author_key'])
304
+ if latest is not None and (latest.header.extensions or {}).get('kind') == 'video.end':
305
+ return latest
306
+ now_val = max(self._clock() if now is None else int(now), state['genesis'].created_at)
307
+ if latest is not None:
308
+ now_val = max(now_val, latest.header.timestamp)
309
+ sk, pk_hex = self._signing_key_for_stream(stream_id)
310
+ next_seq = state['latest_seq'] + 1
311
+ body = cbor_encode({
312
+ 'type': 'video.end', 'stream_id': stream_id,
313
+ 'final_segment_seq': state['latest_seq'], 'ended_at': now_val,
314
+ }, canonical=True)
315
+ op = P2PandaOperation.create(
316
+ public_key=pk_hex,
317
+ body=body,
318
+ seq_num=next_seq,
319
+ backlink=state['latest_op_id'],
320
+ timestamp=now_val,
321
+ private_key=sk,
322
+ extensions={'stream_id': stream_id, 'kind': 'video.end'},
323
+ )
324
+ self.p2panda_store.append_operation(op, verify_chain=True)
325
+ state['latest_op_id'] = op.id
326
+ state['latest_seq'] = next_seq
327
+ state['verified_up_to'] = next_seq
328
+ return op
329
+
330
+ def checkpoint_and_prune(self, stream_id: str, keep_ops: int, *, min_batch: int = 256, now: Optional[int] = None) -> Optional[P2PandaOperation]:
331
+ """Bound a long-running log: keep its genesis and newest `keep_ops` ops, and prune the rest
332
+ behind a signed `video.checkpoint` op.
333
+
334
+ The checkpoint names the last pruned op (`through_op_id`, which the first kept op backlinks
335
+ to) and a sha256 over the pruned op ids, so the kept log still verifies as one signed chain
336
+ and anyone holding the pruned ops can check them against it. Runs only once `min_batch`
337
+ ops are prunable, so a busy stream does not checkpoint on every segment.
338
+ """
339
+ with self._lock:
340
+ if stream_id not in self._streams:
341
+ raise KeyError(f"Unknown stream_id: {stream_id}")
342
+ state = self._streams[stream_id]
343
+ author = state['author_key']
344
+ latest = self.p2panda_store.get_latest_operation(author)
345
+ if latest is None or (latest.header.extensions or {}).get('kind') == 'video.end':
346
+ return None
347
+ cut = state['latest_seq'] - max(1, int(keep_ops))
348
+ oldest = self.p2panda_store.get_log(author, from_seq=1, limit=1)
349
+ if not oldest or cut - oldest[0].seq_num + 1 < max(1, int(min_batch)):
350
+ return None
351
+ through = self.p2panda_store.get_operation_by_seq(author, cut)
352
+ if through is None:
353
+ return None
354
+ digest = hashlib.sha256()
355
+ seq = oldest[0].seq_num
356
+ while seq <= cut:
357
+ batch = self.p2panda_store.get_log(author, from_seq=seq, limit=500)
358
+ if not batch:
359
+ break
360
+ for op in batch:
361
+ if op.seq_num <= cut:
362
+ digest.update(bytes.fromhex(op.id))
363
+ seq = batch[-1].seq_num + 1
364
+ now_val = max(self._clock() if now is None else int(now), latest.header.timestamp)
365
+ sk, pk_hex = self._signing_key_for_stream(stream_id)
366
+ body = cbor_encode({
367
+ 'type': 'video.checkpoint', 'stream_id': stream_id, 'pruned_from_seq': oldest[0].seq_num,
368
+ 'through_seq': cut, 'through_op_id': through.id, 'pruned_ops_sha256': digest.hexdigest(),
369
+ }, canonical=True)
370
+ op = P2PandaOperation.create(
371
+ public_key=pk_hex, body=body, seq_num=state['latest_seq'] + 1, backlink=state['latest_op_id'],
372
+ timestamp=now_val, private_key=sk, extensions={'stream_id': stream_id, 'kind': 'video.checkpoint'},
373
+ )
374
+ self.p2panda_store.append_operation(op, verify_chain=True)
375
+ state['latest_op_id'] = op.id
376
+ state['latest_seq'] = op.seq_num
377
+ state['verified_up_to'] = op.seq_num
378
+ with self.p2panda_store._lock:
379
+ with self.p2panda_store.transaction() as cur:
380
+ cur.execute(
381
+ 'DELETE FROM p2panda_operations WHERE author = ? AND seq_num > 0 AND seq_num <= ?',
382
+ (author.lower(), cut),
383
+ )
384
+ return op
385
+
386
+ def export_stream_log(self, author_key: str, page: int = 500) -> list[dict[str, Any]]:
387
+ """Every operation in a stream log, oldest first, as `{op_id, header, body_hex}`."""
388
+ out: list[dict[str, Any]] = []
389
+ seq = 0
390
+ while True:
391
+ batch = self.p2panda_store.get_log(author_key, from_seq=seq, limit=page)
392
+ out.extend({'op_id': op.id, 'header': op.header.to_dict(), 'body_hex': op.body.hex() if op.body else ''} for op in batch)
393
+ if len(batch) < page:
394
+ return out
395
+ seq = batch[-1].seq_num + 1
396
+
397
+ def ingest_segment(
398
+ self,
399
+ stream_id: str,
400
+ segment_data: bytes,
401
+ duration_ms: int = 2000,
402
+ part_index: int = 0,
403
+ is_keyframe: bool = True,
404
+ is_audio_only: bool = False,
405
+ discontinuity: bool = False,
406
+ now: Optional[int] = None,
407
+ captured_us: Optional[int] = None,
408
+ ) -> tuple[VideoSegmentEntry, P2PandaOperation, SegmentMultiAddress]:
409
+ """Store a CMAF segment or LL-HLS part and append a signed operation
410
+ to the stream's p2panda manifest log.
411
+
412
+ A *captured* segment (`captured_us`: its capture time, UTC microseconds) is stored with that time
413
+ inside its own bytes (`timed_segments.stamp_capture_time`), so the fiber carries its moment and its
414
+ address covers it. Re-aired segments (playlists, ad breaks) are passed without one.
415
+ """
416
+ with self._lock:
417
+ if stream_id not in self._streams:
418
+ raise KeyError(f"Unknown stream_id: {stream_id}")
419
+ state = self._streams[stream_id]
420
+ now_val = max(self._clock() if now is None else int(now), state['genesis'].created_at)
421
+
422
+ if captured_us is not None:
423
+ segment_data = stamp_capture_time(segment_data, int(captured_us))
424
+ addr = self.store_segment_bytes(segment_data)
425
+ if hasattr(self.rhio_store, 'claim_blob'):
426
+ self.rhio_store.claim_blob(
427
+ addr['tree_root'],
428
+ {'identity_id': state['genesis'].broadcaster, 'did': f"did:nostr:{state['genesis'].broadcaster}", 'handle': ''},
429
+ sha256_hex=addr['sha256'],
430
+ size=addr['size'],
431
+ kind='video',
432
+ mime_type='video/mp4',
433
+ visibility='public',
434
+ )
435
+ next_seg_idx = max(state['next_segment_index'], len(state['segments']))
436
+ if state['segments']:
437
+ next_seg_idx = max(next_seg_idx, state['segments'][-1].segment_index + 1)
438
+ seg_index = next_seg_idx
439
+ state['next_segment_index'] = seg_index + 1
440
+ entry = VideoSegmentEntry(
441
+ stream_id=stream_id,
442
+ segment_index=seg_index,
443
+ part_index=int(part_index),
444
+ duration_ms=int(duration_ms),
445
+ sha256=addr['sha256'],
446
+ tree_root=addr['tree_root'],
447
+ size=addr['size'],
448
+ is_keyframe=bool(is_keyframe),
449
+ is_audio_only=bool(is_audio_only),
450
+ discontinuity=bool(discontinuity),
451
+ transit_endpoints=tuple(state['transit_endpoints']),
452
+ published_at=now_val,
453
+ )
454
+
455
+ sk, pk_hex = self._signing_key_for_stream(stream_id)
456
+ next_seq = state['latest_seq'] + 1
457
+ op = P2PandaOperation.create(
458
+ public_key=pk_hex,
459
+ body=entry.to_cbor(),
460
+ seq_num=next_seq,
461
+ backlink=state['latest_op_id'],
462
+ timestamp=now_val,
463
+ private_key=sk,
464
+ extensions={'stream_id': stream_id, 'kind': 'video.segment'},
465
+ )
466
+ self.p2panda_store.append_operation(op, verify_chain=True)
467
+
468
+ state['segments'].append(entry)
469
+ state['latest_op_id'] = op.id
470
+ state['latest_seq'] = next_seq
471
+ state['verified_up_to'] = next_seq
472
+ return entry, op, addr
473
+
474
+ def update_transit_endpoints(
475
+ self,
476
+ stream_id: str,
477
+ transit_endpoints: Sequence[str],
478
+ ) -> list[str]:
479
+ """Update active Transit Reflector endpoints for a stream."""
480
+ with self._lock:
481
+ if stream_id not in self._streams:
482
+ raise KeyError(f"Unknown stream_id: {stream_id}")
483
+ self._streams[stream_id]['transit_endpoints'] = list(transit_endpoints)
484
+ return list(self._streams[stream_id]['transit_endpoints'])
485
+
486
+ def verify_stream_log(self, stream_id: str) -> tuple[bool, Optional[str], list[P2PandaOperation]]:
487
+ """Fetch all operations for `stream_id` from P2PandaLogStore and verify
488
+ the complete cryptographic operation chain.
489
+ """
490
+ with self._lock:
491
+ if stream_id not in self._streams:
492
+ raise KeyError(f"Unknown stream_id: {stream_id}")
493
+ author_key = self._streams[stream_id]['author_key']
494
+ ops = self.p2panda_store.get_log(author_key, from_seq=0, limit=10000)
495
+ ok, err = validate_operation_chain(ops)
496
+ if ok and ops:
497
+ with self._lock:
498
+ if stream_id in self._streams:
499
+ self._streams[stream_id]['verified_up_to'] = ops[-1].seq_num
500
+ return ok, err, ops
501
+
502
+ def render_m3u8(
503
+ self,
504
+ stream_id: str,
505
+ window_size: int = 6,
506
+ audio_only: bool = False,
507
+ ) -> str:
508
+ """Dynamically render a standards-compliant HLS / LL-HLS `.m3u8` playlist
509
+ directly from verified operations in P2PandaLogStore.
510
+ """
511
+ with self._lock:
512
+ if stream_id not in self._streams:
513
+ raise KeyError(f"Unknown stream_id: {stream_id}")
514
+ state = self._streams[stream_id]
515
+ author_key = state['author_key']
516
+ verified_up_to = state['verified_up_to']
517
+ latest_seq = state['latest_seq']
518
+ active_endpoints = list(state['transit_endpoints'])
519
+ mem_genesis = state['genesis']
520
+ mem_segments = list(state['segments'])
521
+
522
+ store_seq = self.p2panda_store.get_latest_seq(author_key)
523
+ if store_seq >= 0 and store_seq == verified_up_to and verified_up_to == latest_seq:
524
+ genesis = mem_genesis
525
+ segments = [seg for seg in mem_segments if not audio_only or seg.is_audio_only]
526
+ else:
527
+ ok, err, ops = self.verify_stream_log(stream_id)
528
+ if not ok or not ops:
529
+ raise ValueError(f"Cannot render m3u8 for unverified stream {stream_id}: {err}")
530
+
531
+ genesis_op = ops[0]
532
+ if genesis_op.body is None:
533
+ raise ValueError("Genesis operation missing body")
534
+ genesis = VideoStreamGenesis.from_cbor(genesis_op.body)
535
+
536
+ segments = []
537
+ for op in ops[1:]:
538
+ if op.body is not None:
539
+ decoded, _ = cbor_decode(op.body)
540
+ if isinstance(decoded, Mapping) and decoded.get('type') == 'video.segment':
541
+ seg = VideoSegmentEntry.from_dict(decoded)
542
+ if not audio_only or seg.is_audio_only:
543
+ segments.append(seg)
544
+
545
+ total_segs = len(segments)
546
+ start_idx = max(0, total_segs - window_size)
547
+ window_segs = segments[start_idx:]
548
+ media_seq = window_segs[0].segment_index if window_segs else 0
549
+ target_dur = max(1, math.ceil(genesis.target_duration_s))
550
+
551
+ lines = [
552
+ '#EXTM3U',
553
+ '#EXT-X-VERSION:7',
554
+ f'#EXT-X-TARGETDURATION:{target_dur}',
555
+ f'#EXT-X-MEDIA-SEQUENCE:{media_seq}',
556
+ f'#EXT-X-PART-INF:PART-TARGET={genesis.part_target_duration_s:.3f}',
557
+ f'#EXT-X-FLEETMESH-STREAM-ID:{genesis.stream_id}',
558
+ f'#EXT-X-FLEETMESH-BROADCASTER:{genesis.broadcaster}',
559
+ ]
560
+ init_sha = genesis.init_sha256 or mem_genesis.init_sha256
561
+ init_root = genesis.init_tree_root or mem_genesis.init_tree_root
562
+ if init_sha:
563
+ lines.append(
564
+ f'#EXT-X-MAP:URI="{init_sha}.mp4",BAO-ROOT="{init_root}"'
565
+ )
566
+ for ep in active_endpoints:
567
+ lines.append(f'#EXT-X-FLEETMESH-TRANSIT:URI="{ep}"')
568
+
569
+ for seg in window_segs:
570
+ if seg.discontinuity:
571
+ lines.append('#EXT-X-DISCONTINUITY')
572
+ lines.append(f'#EXT-X-FLEETMESH-BAO-ROOT:{seg.tree_root}')
573
+ ind_attr = ',INDEPENDENT=YES' if seg.is_keyframe else ''
574
+ lines.append(
575
+ f'#EXT-X-PART:DURATION={seg.duration_s:.3f},URI="{seg.sha256}.m4s"{ind_attr}'
576
+ )
577
+ lines.append(f'#EXTINF:{seg.duration_s:.3f},')
578
+ lines.append(f'{seg.sha256}.m4s')
579
+
580
+ return '\n'.join(lines) + '\n'
581
+
582
+ # ---- Cross-Protocol Stream Announcements --------------------------------
583
+
584
+ def announce_stream(
585
+ self,
586
+ stream_id: str,
587
+ base_url: str,
588
+ protocols: Sequence[str] = ('nostr', 'atproto', 'activitypub', 'ssb', 'p2panda', 'rhio'),
589
+ ) -> dict[str, Mapping[str, Any]]:
590
+ """Project a canonical stream announcement across all active protocols
591
+ via ProtocolTranslator.
592
+
593
+ `base_url` is required and has no default. It used to default to a
594
+ made-up `node.fleetmesh.local` address, so a node without an HTTP face
595
+ announced a manifest URL no viewer could reach to all six protocols -
596
+ and an mDNS `.local` name may resolve to another machine entirely on a
597
+ LAN. A caller with no face must not announce at all.
598
+ """
599
+ with self._lock:
600
+ if stream_id not in self._streams:
601
+ raise KeyError(f"Unknown stream_id: {stream_id}")
602
+ genesis = self._streams[stream_id]['genesis']
603
+
604
+ manifest_url = f"{base_url.rstrip('/')}/video/live/{stream_id}/index.m3u8"
605
+ parsed_host = urllib.parse.urlparse(base_url).hostname or ''
606
+ env_domain = os.environ.get('MESHTUBE_DOMAIN', '').strip().strip('/')
607
+ ap_domain = env_domain or (parsed_host if parsed_host not in ('0.0.0.0', '127.0.0.1', 'localhost', '') else 'meshtube.live')
608
+ post = UnifiedPost(
609
+ text=f"Live Broadcast: {genesis.title} ({manifest_url})",
610
+ author=genesis.broadcaster,
611
+ created_at=genesis.created_at,
612
+ attachments=[manifest_url],
613
+ tags=['live', 'video', 'fleetmesh'],
614
+ extra={'stream_id': stream_id},
615
+ )
616
+ announcements: dict[str, Mapping[str, Any]] = {}
617
+ for proto in protocols:
618
+ kwargs: dict[str, str] = {}
619
+ if proto == 'nostr':
620
+ kwargs['pubkey'] = genesis.broadcaster
621
+ elif proto == 'activitypub':
622
+ kwargs['domain'] = ap_domain
623
+ elif proto == 'rhio':
624
+ kwargs['subject'] = f'fleetmesh.video.{stream_id}'
625
+ announcements[proto] = ProtocolTranslator.from_unified(proto, post, **kwargs)
626
+ return announcements
627
+
628
+ # ---- ProtocolHost HTTP Ingress Mounting ---------------------------------
629
+
630
+ def mount_ingress(self, host: ProtocolHostRouter) -> None:
631
+ """Mount live video manifest and segment routes onto ProtocolHostRouter."""
632
+ from node.transport import ws
633
+
634
+ def _handle_streams(conn: Any, req: Any) -> bool:
635
+ with self._lock:
636
+ items = [
637
+ {
638
+ 'stream_id': sid,
639
+ 'broadcaster': st['genesis'].broadcaster,
640
+ 'title': st['genesis'].title,
641
+ 'segments_count': len(st['segments']),
642
+ 'latest_seq': st['latest_seq'],
643
+ 'transit_endpoints': list(st['transit_endpoints']),
644
+ }
645
+ for sid, st in self._streams.items()
646
+ ]
647
+ body = json.dumps({'streams': items, 'count': len(items)}).encode('utf-8')
648
+ ws.http_response(conn, '200 OK', body, 'application/json')
649
+ return True
650
+
651
+ def _handle_live(conn: Any, req: Any) -> bool:
652
+ uri = getattr(req, 'path', '')
653
+ parsed = urllib.parse.urlparse(uri)
654
+ route_path = parsed.path
655
+ qparams = urllib.parse.parse_qs(parsed.query)
656
+
657
+ prefix = '/video/live/'
658
+ if not route_path.startswith(prefix):
659
+ return False
660
+ tail = route_path[len(prefix):].strip('/')
661
+ parts = tail.split('/')
662
+ if len(parts) < 2:
663
+ ws.http_response(conn, '400 Bad Request', b'{"error":"invalid_stream_route"}', 'application/json')
664
+ return True
665
+
666
+ stream_id, resource = parts[0], parts[1]
667
+ with self._lock:
668
+ state = self._streams.get(stream_id)
669
+ if state is None:
670
+ ws.http_response(conn, '404 Not Found', b'{"error":"stream_not_found"}', 'application/json')
671
+ return True
672
+
673
+ # 1. Playlist request (.m3u8)
674
+ if resource.endswith('.m3u8'):
675
+ audio_only = qparams.get('audio_only', ['0'])[0] == '1'
676
+ try:
677
+ playlist = self.render_m3u8(stream_id, audio_only=audio_only)
678
+ ws.http_response(
679
+ conn,
680
+ '200 OK',
681
+ playlist.encode('utf-8'),
682
+ 'application/vnd.apple.mpegurl',
683
+ )
684
+ except Exception as e:
685
+ ws.http_response(
686
+ conn,
687
+ '500 Internal Server Error',
688
+ json.dumps({'error': str(e)}).encode('utf-8'),
689
+ 'application/json',
690
+ )
691
+ return True
692
+
693
+ # 2. Segment request (<sha256>.m4s or <sha256>.mp4)
694
+ is_origin_pull = qparams.get('origin', ['0'])[0] == '1'
695
+ active_reflectors = state['transit_endpoints']
696
+ if active_reflectors and not is_origin_pull:
697
+ # Capacity shed handover active: redirect viewer to Transit Reflector
698
+ target_url = f"{active_reflectors[0].rstrip('/')}/{resource}"
699
+ ws.http_response(
700
+ conn,
701
+ '307 Temporary Redirect',
702
+ json.dumps({'redirect': target_url}).encode('utf-8'),
703
+ 'application/json',
704
+ headers={'Location': target_url},
705
+ )
706
+ return True
707
+
708
+ clean_hash = resource.split('.')[0].lower()
709
+ with self._lock:
710
+ known_hashes = {s.sha256.lower() for s in state['segments']}
711
+ if state['genesis'].init_sha256:
712
+ known_hashes.add(state['genesis'].init_sha256.lower())
713
+ if known_hashes and clean_hash not in known_hashes:
714
+ ws.http_response(conn, '404 Not Found', b'{"error":"segment_not_found"}', 'application/json')
715
+ return True
716
+
717
+ blob = self.get_segment_bytes(resource)
718
+ if blob is None:
719
+ ws.http_response(conn, '404 Not Found', b'{"error":"segment_not_found"}', 'application/json')
720
+ return True
721
+
722
+ headers_raw = getattr(req, 'headers', {})
723
+ cashu_token = (
724
+ headers_raw.get(b'x-cashu', b'').decode('utf-8', errors='ignore').strip()
725
+ or headers_raw.get('x-cashu', '')
726
+ or headers_raw.get('X-Cashu', '')
727
+ )
728
+ viewer_peer = (
729
+ headers_raw.get(b'x-fleetmesh-peer', b'').decode('utf-8', errors='ignore').strip()
730
+ or 'anonymous'
731
+ )
732
+
733
+ # Commercial Settlement Path A: PaidFetch dual-item 402 (operator NUT-03 + creator NUT-11)
734
+ genesis = state['genesis']
735
+ if self.paid_fetch is not None and (genesis.price_msat_per_mb > 0 or genesis.account_fee_msat > 0):
736
+ fetcher_id = None if viewer_peer == 'anonymous' else viewer_peer
737
+ stat_doc = {'sha256': clean_hash, 'size': len(blob), 'type': 'video/iso.segment'}
738
+ import inspect
739
+ quote_params = inspect.signature(self.paid_fetch.quote).parameters
740
+ uses_stat_sig = 'stat' in quote_params
741
+ if not cashu_token:
742
+ if uses_stat_sig:
743
+ quote = self.paid_fetch.quote(stat_doc, fetcher_id, genesis.broadcaster)
744
+ else:
745
+ quote = self.paid_fetch.quote(
746
+ sha256=clean_hash,
747
+ size=len(blob),
748
+ content_type='video/iso.segment',
749
+ fetcher=fetcher_id,
750
+ account=genesis.broadcaster,
751
+ account_msat=genesis.account_fee_msat,
752
+ )
753
+ if quote is not None:
754
+ q_body = quote.body() if callable(getattr(quote, 'body', None)) else getattr(quote, 'body', {})
755
+ ws.http_response(
756
+ conn,
757
+ '402 Payment Required',
758
+ json.dumps(q_body).encode('utf-8'),
759
+ 'application/json',
760
+ headers=quote.headers(),
761
+ )
762
+ return True
763
+ else:
764
+ if uses_stat_sig:
765
+ quote = self.paid_fetch.quote(stat_doc, fetcher_id, genesis.broadcaster)
766
+ if quote is not None:
767
+ tokens = [t.strip() for t in str(cashu_token).split(',') if t.strip()]
768
+ verdict = self.paid_fetch.settle(quote, tokens)
769
+ if not hasattr(verdict, 'amount_msat') and not getattr(verdict, 'ok', False):
770
+ ws.http_response(
771
+ conn,
772
+ '402 Payment Required',
773
+ json.dumps({'error': getattr(verdict, 'reason', 'unpaid')}).encode('utf-8'),
774
+ 'application/json',
775
+ headers=quote.headers(),
776
+ )
777
+ return True
778
+ else:
779
+ verdict = self.paid_fetch.settle(
780
+ sha256=clean_hash,
781
+ size=len(blob),
782
+ content_type='video/iso.segment',
783
+ header=cashu_token,
784
+ fetcher=fetcher_id,
785
+ account=genesis.broadcaster,
786
+ account_msat=genesis.account_fee_msat,
787
+ )
788
+ if not getattr(verdict, 'ok', False):
789
+ quote = getattr(verdict, 'quote', None)
790
+ extra = quote.headers() if quote is not None else {}
791
+ ws.http_response(
792
+ conn,
793
+ '402 Payment Required',
794
+ json.dumps({'error': getattr(verdict, 'reason', 'unpaid')}).encode('utf-8'),
795
+ 'application/json',
796
+ headers=extra,
797
+ )
798
+ return True
799
+
800
+ # Commercial Settlement Path B: CrossProtocolSettlementGateway TokenBucket
801
+ elif self.settlement_gateway is not None:
802
+ if cashu_token:
803
+ try:
804
+ self.settlement_gateway.settle_cashu('video', viewer_peer, cashu_token)
805
+ except Exception as e:
806
+ ws.http_response(
807
+ conn,
808
+ '402 Payment Required',
809
+ json.dumps({'error': str(e)}).encode('utf-8'),
810
+ 'application/json',
811
+ )
812
+ return True
813
+ allowed, challenge = self.settlement_gateway.check_access('video', viewer_peer, cost=1)
814
+ if not allowed and challenge is not None:
815
+ _code, ch_headers, ch_body = challenge.to_http_402()
816
+ ws.http_response(
817
+ conn,
818
+ '402 Payment Required',
819
+ ch_body,
820
+ 'application/json',
821
+ headers=dict(ch_headers),
822
+ )
823
+ return True
824
+
825
+ meta = self.rhio_store.get_blob_meta(clean_hash) if hasattr(self.rhio_store, 'get_blob_meta') else None
826
+ if meta is not None and meta.get('tree_root'):
827
+ tree_root = str(meta['tree_root'])
828
+ else:
829
+ tree_root, _ = RhioBaoTree.compute_tree(blob)
830
+ ws.http_response(
831
+ conn,
832
+ '200 OK',
833
+ blob,
834
+ 'video/iso.segment',
835
+ headers={'X-Bao-Tree-Root': tree_root},
836
+ )
837
+ return True
838
+
839
+ host.mount_route('/video/streams', _handle_streams, methods=('GET',), owner='video')
840
+ host.mount_route('/video/live', _handle_live, methods=('GET',), owner='video')
841
+
842
+
843
+ __all__ = [
844
+ 'StreamStateRecord',
845
+ 'SegmentMultiAddress',
846
+ 'VideoStreamPackager',
847
+ ]