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.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- 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
|
+
]
|