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,932 @@
|
|
|
1
|
+
"""VideoStream & Transit Reflector Compartment (node.planes.services.video.compartment).
|
|
2
|
+
|
|
3
|
+
Integrates VideoStreamPackager, TransitReflectorManager, and TransitLedgerStore
|
|
4
|
+
into the MeshNode runtime:
|
|
5
|
+
- Mounts `/video/streams` and `/video/live/<stream_id>/...` onto ProtocolHost.
|
|
6
|
+
- Routes `https://fleetmesh.org/video-transit/1.0/*` envelopes on ProtocolRouter.
|
|
7
|
+
- Evaluates Capacity shed handovers (`narrow_scope` / `amend_down` vs `restore_below`)
|
|
8
|
+
on every node tick.
|
|
9
|
+
- Exposes declarative control-socket commands (`video.start_stream`, `video.status`,
|
|
10
|
+
`video.settle`, `video.ingest_segment`, `video.solicit_transit`) and the `stream`
|
|
11
|
+
CLI verb for plugin/operator dispatch.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import dataclasses
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any, Callable, Mapping, Optional
|
|
19
|
+
|
|
20
|
+
from fleetmesh_core.wire.video_transit import (
|
|
21
|
+
TYPE_TRANSIT_CLOSE,
|
|
22
|
+
TYPE_TRANSIT_GRANT,
|
|
23
|
+
TYPE_TRANSIT_RECEIPT,
|
|
24
|
+
TYPE_TRANSIT_RECEIPT_ACK,
|
|
25
|
+
TYPE_TRANSIT_SOLICITATION,
|
|
26
|
+
VIDEO_TRANSIT_PIURI,
|
|
27
|
+
TransitGrantBody,
|
|
28
|
+
check_grant,
|
|
29
|
+
check_solicitation,
|
|
30
|
+
check_transit_close,
|
|
31
|
+
check_transit_receipt,
|
|
32
|
+
check_transit_receipt_ack,
|
|
33
|
+
make_transit_envelope,
|
|
34
|
+
)
|
|
35
|
+
from node import channels as ch_mod
|
|
36
|
+
from node import metrics as mx_mod
|
|
37
|
+
from node.compartment import Command, Compartment, Need, stores as st
|
|
38
|
+
from node.config import parameter as par
|
|
39
|
+
from node.config.parameter import ParamBlock, Parameter
|
|
40
|
+
from node.protocols.p2panda.store import P2PandaLogStore
|
|
41
|
+
from node.protocols.rhio.store import RhioBlobSync
|
|
42
|
+
|
|
43
|
+
from .barter import BarterHookResult, BarterSettlementBridge
|
|
44
|
+
from .cli import register_stream_cli
|
|
45
|
+
from .ingest import LiveMediaIngestBridge
|
|
46
|
+
from .ledger import TransitLedgerStore
|
|
47
|
+
from .packager import VideoStreamPackager
|
|
48
|
+
from .player import mount_player_routes
|
|
49
|
+
from .transit import TransitReflectorManager
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclasses.dataclass(frozen=True)
|
|
53
|
+
class VideoConfig(ParamBlock):
|
|
54
|
+
"""Configuration block for the Video Streaming & Transit Reflector compartment."""
|
|
55
|
+
|
|
56
|
+
key = 'video'
|
|
57
|
+
enabled: bool = True
|
|
58
|
+
ring_capacity: int = 256
|
|
59
|
+
default_debt_ceiling_bytes: int = 500 * 1024 * 1024
|
|
60
|
+
|
|
61
|
+
PARAMS = (
|
|
62
|
+
Parameter[bool](
|
|
63
|
+
key='enabled',
|
|
64
|
+
name='Video Streaming & Transit',
|
|
65
|
+
description='Enable live CMAF/LL-HLS packaging and Transit Reflector routing.',
|
|
66
|
+
type=bool,
|
|
67
|
+
default=True,
|
|
68
|
+
must='be a boolean',
|
|
69
|
+
live=True,
|
|
70
|
+
tags=('video', 'transit'),
|
|
71
|
+
sources=(par.ARG, par.ENV, par.FILE, par.MANIFEST),
|
|
72
|
+
),
|
|
73
|
+
Parameter[int](
|
|
74
|
+
key='ring_capacity',
|
|
75
|
+
name='MoQ Ring Buffer Capacity',
|
|
76
|
+
description='Maximum frames retained per stream in the real-time MoQ ring buffer.',
|
|
77
|
+
type=int,
|
|
78
|
+
default=256,
|
|
79
|
+
must='be a positive integer',
|
|
80
|
+
live=True,
|
|
81
|
+
tags=('video', 'moq'),
|
|
82
|
+
sources=(par.ARG, par.ENV, par.FILE),
|
|
83
|
+
),
|
|
84
|
+
Parameter[int](
|
|
85
|
+
key='default_debt_ceiling_bytes',
|
|
86
|
+
name='Default Pro-Bono Transit Debt Ceiling (Bytes)',
|
|
87
|
+
description='Unsettled transit byte threshold before soft audio-only degradation.',
|
|
88
|
+
type=int,
|
|
89
|
+
default=500 * 1024 * 1024,
|
|
90
|
+
must='be a positive integer',
|
|
91
|
+
live=True,
|
|
92
|
+
tags=('video', 'accounting'),
|
|
93
|
+
sources=(par.ARG, par.ENV, par.FILE),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_dict(cls, raw: Any) -> VideoConfig:
|
|
99
|
+
if isinstance(raw, bool):
|
|
100
|
+
return cls(enabled=raw)
|
|
101
|
+
if not isinstance(raw, Mapping):
|
|
102
|
+
return cls()
|
|
103
|
+
return cls(
|
|
104
|
+
enabled=bool(raw.get('enabled', True)),
|
|
105
|
+
ring_capacity=int(raw.get('ring_capacity', cls.ring_capacity)),
|
|
106
|
+
default_debt_ceiling_bytes=int(
|
|
107
|
+
raw.get('default_debt_ceiling_bytes', cls.default_debt_ceiling_bytes)
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class VideoTransitCompartment(Compartment):
|
|
113
|
+
"""MeshNode compartment orchestrating live video packaging, transit reflection,
|
|
114
|
+
and bilateral IOU / barter settlement.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
name: str = 'video'
|
|
118
|
+
optional: bool = True
|
|
119
|
+
enabled_by: Optional[str] = 'video'
|
|
120
|
+
# The three late needs below are compartments, so they are declared here:
|
|
121
|
+
# all three are core and tick before this one.
|
|
122
|
+
requires: tuple[str, ...] = ('strain', 'capacity', 'paidfetch', 'blob_replication')
|
|
123
|
+
# The utilities a transit debt can be repaid in. Optional, so they are
|
|
124
|
+
# `uses` and resolve to None when the operator has not configured them.
|
|
125
|
+
uses: tuple[str, ...] = ('compute', 'dvm')
|
|
126
|
+
reads: tuple[str, ...] = ('video',)
|
|
127
|
+
live: tuple[str, ...] = ('video',)
|
|
128
|
+
config_cls = VideoConfig
|
|
129
|
+
piuris: tuple[str, ...] = (VIDEO_TRANSIT_PIURI,)
|
|
130
|
+
cli = (('stream', register_stream_cli),)
|
|
131
|
+
|
|
132
|
+
STORES = (
|
|
133
|
+
st.Store(
|
|
134
|
+
key='recent_events',
|
|
135
|
+
description='recent video packaging and transit events, newest last',
|
|
136
|
+
kind=st.RING,
|
|
137
|
+
shape=tuple,
|
|
138
|
+
bound=128,
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
recent_events: st.Ring[tuple[Any, ...]]
|
|
142
|
+
|
|
143
|
+
CHANNELS = (
|
|
144
|
+
ch_mod.Channel(
|
|
145
|
+
'video_grant_undelivered',
|
|
146
|
+
'A signed transit grant was recorded but could not be sent to the peer',
|
|
147
|
+
level=ch_mod.TROUBLE,
|
|
148
|
+
say='grant {grant} for {peer} is in the ledger and did not reach the peer',
|
|
149
|
+
tags=('video', 'transit'),
|
|
150
|
+
),
|
|
151
|
+
ch_mod.Channel(
|
|
152
|
+
'video_ack_undelivered',
|
|
153
|
+
'An inbound receipt was recorded but its acknowledgement could not be sent',
|
|
154
|
+
level=ch_mod.TROUBLE,
|
|
155
|
+
say='receipt-ack for grant {grant} did not reach {peer}; it will resend',
|
|
156
|
+
tags=('video', 'transit'),
|
|
157
|
+
),
|
|
158
|
+
ch_mod.Channel(
|
|
159
|
+
'video_store_close_failed',
|
|
160
|
+
'A video store could not be closed at shutdown',
|
|
161
|
+
level=ch_mod.TROUBLE,
|
|
162
|
+
say='video store(s) would not close: {stores}',
|
|
163
|
+
tags=('video', 'storage'),
|
|
164
|
+
),
|
|
165
|
+
ch_mod.Channel(
|
|
166
|
+
'video_not_announced',
|
|
167
|
+
'A stream started on a node with no HTTP face, so it was not announced',
|
|
168
|
+
level=ch_mod.NOTE,
|
|
169
|
+
say='stream {stream_id} started but not announced: this node has no http face',
|
|
170
|
+
tags=('video', 'stream'),
|
|
171
|
+
),
|
|
172
|
+
ch_mod.Channel(
|
|
173
|
+
'video_stream_started',
|
|
174
|
+
'A live video stream genesis manifest was initialized',
|
|
175
|
+
level=ch_mod.INFO,
|
|
176
|
+
say='started live stream {stream_id} ({title})',
|
|
177
|
+
tags=('video', 'stream'),
|
|
178
|
+
),
|
|
179
|
+
ch_mod.Channel(
|
|
180
|
+
'video_segment_ingested',
|
|
181
|
+
'A CMAF / LL-HLS video segment was packaged and logged',
|
|
182
|
+
level=ch_mod.INFO,
|
|
183
|
+
say='packaged segment #{seq} for stream {stream_id} ({size} bytes)',
|
|
184
|
+
tags=('video', 'segment'),
|
|
185
|
+
),
|
|
186
|
+
ch_mod.Channel(
|
|
187
|
+
'video_transit_handover',
|
|
188
|
+
'Capacity shed triggered or released a Transit Reflector handover',
|
|
189
|
+
level=ch_mod.INFO,
|
|
190
|
+
say='stream {stream_id} transit handover {state}',
|
|
191
|
+
tags=('video', 'transit'),
|
|
192
|
+
),
|
|
193
|
+
ch_mod.Channel(
|
|
194
|
+
'video_transit_settled',
|
|
195
|
+
'Bilateral transit debt was reduced via bartered network utility',
|
|
196
|
+
level=ch_mod.INFO,
|
|
197
|
+
say='settled {credited_bytes} transit bytes for peer {peer} via {utility_kind}',
|
|
198
|
+
tags=('video', 'accounting'),
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
METRICS = (
|
|
203
|
+
mx_mod.Metric(
|
|
204
|
+
name='video_streams_started',
|
|
205
|
+
description='Live video streams initialized on this node.',
|
|
206
|
+
kind=mx_mod.COUNTER,
|
|
207
|
+
counts='video_stream_started',
|
|
208
|
+
tags=('video', 'stream'),
|
|
209
|
+
),
|
|
210
|
+
mx_mod.Metric(
|
|
211
|
+
name='video_segments_ingested',
|
|
212
|
+
description='Video segments packaged into RhioBaoTree and P2PandaLogStore.',
|
|
213
|
+
kind=mx_mod.COUNTER,
|
|
214
|
+
counts='video_segment_ingested',
|
|
215
|
+
tags=('video', 'segment'),
|
|
216
|
+
),
|
|
217
|
+
mx_mod.Metric(
|
|
218
|
+
name='video_transit_handovers',
|
|
219
|
+
description='Automated capacity shed handovers to Transit Reflectors.',
|
|
220
|
+
kind=mx_mod.COUNTER,
|
|
221
|
+
counts='video_transit_handover',
|
|
222
|
+
tags=('video', 'transit'),
|
|
223
|
+
),
|
|
224
|
+
mx_mod.Metric(
|
|
225
|
+
name='video_transit_settlements',
|
|
226
|
+
description='Bartered utility settlements applied to transit debt.',
|
|
227
|
+
kind=mx_mod.COUNTER,
|
|
228
|
+
counts='video_transit_settled',
|
|
229
|
+
tags=('video', 'accounting'),
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
NEEDS = (
|
|
234
|
+
Need('blobs', 'stores.blobs'),
|
|
235
|
+
Need('ks'),
|
|
236
|
+
Need('clock'),
|
|
237
|
+
Need('dir'),
|
|
238
|
+
Need('cfg', block=VideoConfig),
|
|
239
|
+
Need('host'),
|
|
240
|
+
Need('transport'),
|
|
241
|
+
Need('capacity', late=True),
|
|
242
|
+
Need('strain', late=True),
|
|
243
|
+
Need('paid_fetch', 'paidfetch', late=True),
|
|
244
|
+
# Barter counterparties, read on the tick. These were soft reaches
|
|
245
|
+
# through the node object, which the contract refuses: a coupling
|
|
246
|
+
# nothing declares is one nobody can see.
|
|
247
|
+
Need('blob_replication', late=True),
|
|
248
|
+
Need('compute', late=True),
|
|
249
|
+
Need('dvm', late=True),
|
|
250
|
+
Need('events'),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
COMMANDS = (
|
|
254
|
+
Command(
|
|
255
|
+
key='start_stream',
|
|
256
|
+
name='Start Video Stream',
|
|
257
|
+
description='initialize a new live video stream and append its p2panda genesis manifest',
|
|
258
|
+
tags=('video', 'stream'),
|
|
259
|
+
parameters=(
|
|
260
|
+
Parameter(key='stream_id', name='stream_id', description='unique stream identifier', type=str, default='', required=True),
|
|
261
|
+
Parameter(key='title', name='title', description='human-readable stream title', type=str, default='Live Stream', required=False),
|
|
262
|
+
Parameter(key='price_msat_per_mb', name='price_msat_per_mb', description='commercial price per MB in msat', type=int, default=0, required=False),
|
|
263
|
+
Parameter(key='account_fee_msat', name='account_fee_msat', description='creator P2PK fee in msat', type=int, default=0, required=False),
|
|
264
|
+
),
|
|
265
|
+
permission='video/start_stream',
|
|
266
|
+
run='start_stream_cmd',
|
|
267
|
+
reply='{stream_id, broadcaster, title, genesis_op_id, seq_num, announcements}',
|
|
268
|
+
),
|
|
269
|
+
Command(
|
|
270
|
+
key='status',
|
|
271
|
+
name='Video & Transit Status',
|
|
272
|
+
description='inspect active video streams, reflector sessions, and bilateral transit debt',
|
|
273
|
+
tags=('video', 'status'),
|
|
274
|
+
parameters=(
|
|
275
|
+
Parameter(key='peer', name='peer', description='optional peer pubkey filter', type=str, default='', required=False),
|
|
276
|
+
),
|
|
277
|
+
permission='video/status',
|
|
278
|
+
run='status_cmd',
|
|
279
|
+
reply='{streams, active_streams, reflector_sessions, debts, utility_credits}',
|
|
280
|
+
),
|
|
281
|
+
Command(
|
|
282
|
+
key='settle',
|
|
283
|
+
name='Settle Transit Debt via Barter',
|
|
284
|
+
description='credit bartered network utility (blob_shoring, verified_compute, dvm, mediation) against transit debt',
|
|
285
|
+
tags=('video', 'settle'),
|
|
286
|
+
parameters=(
|
|
287
|
+
Parameter(key='peer', name='peer', description='broadcaster peer pubkey', type=str, default='', required=True),
|
|
288
|
+
Parameter(key='utility_kind', name='utility_kind', description='blob_shoring, verified_compute, dvm, or mediation', type=str, default='blob_shoring', required=True),
|
|
289
|
+
Parameter(key='units', name='units', description='number of utility units to credit', type=int, default=1, required=True),
|
|
290
|
+
Parameter(key='grant_id', name='grant_id', description='optional grant_id to credit against', type=str, default='', required=False),
|
|
291
|
+
),
|
|
292
|
+
permission='video/settle',
|
|
293
|
+
run='settle_cmd',
|
|
294
|
+
reply='BarterCreditRecord with updated debt status',
|
|
295
|
+
),
|
|
296
|
+
Command(
|
|
297
|
+
key='ingest_segment',
|
|
298
|
+
name='Ingest Video Segment',
|
|
299
|
+
description='package a base64-encoded CMAF/LL-HLS media segment into RhioBaoTree and P2PandaLogStore',
|
|
300
|
+
tags=('video', 'ingest'),
|
|
301
|
+
parameters=(
|
|
302
|
+
Parameter(key='stream_id', name='stream_id', description='target stream identifier', type=str, default='', required=True),
|
|
303
|
+
Parameter(key='data_b64', name='data_b64', description='base64-encoded segment bytes', type=str, default='', required=True),
|
|
304
|
+
Parameter(key='duration_ms', name='duration_ms', description='segment duration in milliseconds', type=int, default=2000, required=False),
|
|
305
|
+
Parameter(key='is_keyframe', name='is_keyframe', description='whether segment begins with an IDR keyframe', type=bool, default=True, required=False),
|
|
306
|
+
Parameter(key='is_audio_only', name='is_audio_only', description='whether segment is an audio-only track', type=bool, default=False, required=False),
|
|
307
|
+
),
|
|
308
|
+
permission='video/ingest_segment',
|
|
309
|
+
run='ingest_segment_cmd',
|
|
310
|
+
reply='{stream_id, segment_index, sha256, tree_root, cid, size, op_id, seq_num}',
|
|
311
|
+
),
|
|
312
|
+
Command(
|
|
313
|
+
key='solicit_transit',
|
|
314
|
+
name='Solicit Supernode Transit Reflection',
|
|
315
|
+
description='build and optionally send a transit-solicitation for a stream',
|
|
316
|
+
tags=('video', 'transit'),
|
|
317
|
+
parameters=(
|
|
318
|
+
Parameter(key='stream_id', name='stream_id', description='stream identifier to offload', type=str, default='', required=True),
|
|
319
|
+
Parameter(key='peer', name='peer', description='optional target supernode pubkey', type=str, default='', required=False),
|
|
320
|
+
Parameter(key='mode', name='mode', description='pro-bono-barter or commercial-paid', type=str, default='pro-bono-barter', required=False),
|
|
321
|
+
Parameter(key='expected_bitrate_kbps', name='expected_bitrate_kbps', description='expected stream bitrate in kbps', type=int, default=2500, required=False),
|
|
322
|
+
),
|
|
323
|
+
permission='video/solicit_transit',
|
|
324
|
+
run='solicit_transit_cmd',
|
|
325
|
+
reply='{solicitation, sent}',
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def __init__(
|
|
330
|
+
self,
|
|
331
|
+
blobs: Optional[Any] = None,
|
|
332
|
+
ks: Optional[Any] = None,
|
|
333
|
+
clock: Optional[Callable[[], int]] = None,
|
|
334
|
+
dir: Optional[str] = None,
|
|
335
|
+
cfg: Optional[VideoConfig] = None,
|
|
336
|
+
host: Optional[Any] = None,
|
|
337
|
+
capacity: Optional[Any] = None,
|
|
338
|
+
strain: Optional[Any] = None,
|
|
339
|
+
paid_fetch: Optional[Any] = None,
|
|
340
|
+
transport: Optional[Any] = None,
|
|
341
|
+
events: Optional[Any] = None,
|
|
342
|
+
node: Optional[Any] = None,
|
|
343
|
+
) -> None:
|
|
344
|
+
super().__init__(node, clock)
|
|
345
|
+
# Backing fields for the write-through properties below. Set before
|
|
346
|
+
# anything that could trigger a setter.
|
|
347
|
+
self._strain: Optional[Any] = None
|
|
348
|
+
self._paid_fetch: Optional[Any] = None
|
|
349
|
+
self._events: Optional[Any] = None
|
|
350
|
+
self.blobs = blobs
|
|
351
|
+
self.ks = ks
|
|
352
|
+
self.dir = dir
|
|
353
|
+
self.cfg: VideoConfig = cfg or VideoConfig()
|
|
354
|
+
self.host = host
|
|
355
|
+
self.capacity = capacity
|
|
356
|
+
self.strain = strain
|
|
357
|
+
self.paid_fetch = paid_fetch
|
|
358
|
+
self.transport = transport
|
|
359
|
+
self.events = events
|
|
360
|
+
# Late needs: the registry sets these after assembly. Declared here so
|
|
361
|
+
# a bare suite that builds this compartment alone still has them.
|
|
362
|
+
self.blob_replication: Optional[Any] = None
|
|
363
|
+
self.compute: Optional[Any] = None
|
|
364
|
+
self.dvm: Optional[Any] = None
|
|
365
|
+
|
|
366
|
+
if dir:
|
|
367
|
+
os.makedirs(dir, exist_ok=True)
|
|
368
|
+
ledger_db = os.path.join(dir, 'video_transit_v1.db')
|
|
369
|
+
p2p_db = os.path.join(dir, 'p2panda_v1.db')
|
|
370
|
+
rhio_db = os.path.join(dir, 'rhio_v1.db')
|
|
371
|
+
rhio_cas: Optional[str] = os.path.join(dir, 'rhio_cas')
|
|
372
|
+
else:
|
|
373
|
+
ledger_db = ':memory:'
|
|
374
|
+
p2p_db = ':memory:'
|
|
375
|
+
rhio_db = ':memory:'
|
|
376
|
+
rhio_cas = None
|
|
377
|
+
|
|
378
|
+
self.p2panda_store = P2PandaLogStore(p2p_db)
|
|
379
|
+
self.rhio_store = RhioBlobSync(rhio_db, cas_dir=rhio_cas)
|
|
380
|
+
# One clock for the whole subsystem: the compartment's, so a suite that
|
|
381
|
+
# moves time moves the ledger, packager, reflector and bridges with it.
|
|
382
|
+
self.ledger = TransitLedgerStore(ledger_db, clock=self.clock)
|
|
383
|
+
self.packager = VideoStreamPackager(
|
|
384
|
+
p2panda_store=self.p2panda_store,
|
|
385
|
+
rhio_store=self.rhio_store,
|
|
386
|
+
blob_store=self.blobs,
|
|
387
|
+
strain_ledger=self.strain,
|
|
388
|
+
paid_fetch=self.paid_fetch,
|
|
389
|
+
clock=self.clock,
|
|
390
|
+
)
|
|
391
|
+
self.reflector = TransitReflectorManager(
|
|
392
|
+
ledger=self.ledger,
|
|
393
|
+
packager=self.packager,
|
|
394
|
+
ring_capacity=self.cfg.ring_capacity,
|
|
395
|
+
strain_ledger=self.strain,
|
|
396
|
+
clock=self.clock,
|
|
397
|
+
)
|
|
398
|
+
node_sk = getattr(self.ks, 'node_sk', None)
|
|
399
|
+
node_pk = getattr(self.ks, 'node_pk', None) or ('b' * 64)
|
|
400
|
+
self.ingest_bridge = LiveMediaIngestBridge(
|
|
401
|
+
packager=self.packager,
|
|
402
|
+
reflector=self.reflector,
|
|
403
|
+
broadcaster_sk=node_sk,
|
|
404
|
+
broadcaster_pk=node_pk,
|
|
405
|
+
clock=self.clock,
|
|
406
|
+
)
|
|
407
|
+
self.barter_bridge = BarterSettlementBridge(
|
|
408
|
+
ledger=self.ledger,
|
|
409
|
+
creditor_pk=node_pk,
|
|
410
|
+
blob_store=self.blobs,
|
|
411
|
+
rhio_store=self.rhio_store,
|
|
412
|
+
on_settled=self._on_auto_barter_settled,
|
|
413
|
+
clock=self.clock,
|
|
414
|
+
)
|
|
415
|
+
if events is not None:
|
|
416
|
+
self.barter_bridge.attach_event_bus(events)
|
|
417
|
+
self._received_grants: dict[str, TransitGrantBody] = {} # not a store: active per-stream transit grants
|
|
418
|
+
|
|
419
|
+
def _on_auto_barter_settled(self, res: BarterHookResult) -> None:
|
|
420
|
+
now_val = self.clock()
|
|
421
|
+
self.recent_events.append((
|
|
422
|
+
'auto-barter-settled',
|
|
423
|
+
res['debtor'],
|
|
424
|
+
res['utility_kind'],
|
|
425
|
+
res['equivalent_transit_bytes'],
|
|
426
|
+
now_val,
|
|
427
|
+
))
|
|
428
|
+
self.say(
|
|
429
|
+
'video_transit_settled',
|
|
430
|
+
credited_bytes=res['equivalent_transit_bytes'],
|
|
431
|
+
peer=res['debtor'][:16],
|
|
432
|
+
utility_kind=res['utility_kind'],
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
def mount_ingress(self, host: Any) -> None:
|
|
436
|
+
"""Mount HTTP live playlist, segment, HTTP push ingest, and web player routes onto ProtocolHost."""
|
|
437
|
+
self.host = host
|
|
438
|
+
if host is not None:
|
|
439
|
+
self.packager.mount_ingress(host)
|
|
440
|
+
self.ingest_bridge.mount_http_ingest(host)
|
|
441
|
+
mount_player_routes(host, self.packager, reflector=self.reflector, ledger=self.ledger)
|
|
442
|
+
|
|
443
|
+
# The registry resolves a `late=True` need with setattr, so these setters
|
|
444
|
+
# are where a late value reaches the objects that actually hold it. This is
|
|
445
|
+
# what `bind_runtime` did when NodeApp built this compartment by hand, and
|
|
446
|
+
# what a `wire()` override would do - which check-needs refuses, rightly:
|
|
447
|
+
# one declared way in, not three.
|
|
448
|
+
@property
|
|
449
|
+
def strain(self) -> Optional[Any]:
|
|
450
|
+
return self._strain
|
|
451
|
+
|
|
452
|
+
@strain.setter
|
|
453
|
+
def strain(self, value: Optional[Any]) -> None:
|
|
454
|
+
self._strain = value
|
|
455
|
+
if value is not None:
|
|
456
|
+
if getattr(self, 'packager', None) is not None:
|
|
457
|
+
self.packager.strain_ledger = value
|
|
458
|
+
if getattr(self, 'reflector', None) is not None:
|
|
459
|
+
self.reflector.strain_ledger = value
|
|
460
|
+
|
|
461
|
+
@property
|
|
462
|
+
def paid_fetch(self) -> Optional[Any]:
|
|
463
|
+
return self._paid_fetch
|
|
464
|
+
|
|
465
|
+
@paid_fetch.setter
|
|
466
|
+
def paid_fetch(self, value: Optional[Any]) -> None:
|
|
467
|
+
self._paid_fetch = value
|
|
468
|
+
if value is not None and getattr(self, 'packager', None) is not None:
|
|
469
|
+
self.packager.paid_fetch = value
|
|
470
|
+
|
|
471
|
+
@property
|
|
472
|
+
def events(self) -> Optional[Any]:
|
|
473
|
+
return self._events
|
|
474
|
+
|
|
475
|
+
@events.setter
|
|
476
|
+
def events(self, value: Optional[Any]) -> None:
|
|
477
|
+
self._events = value
|
|
478
|
+
if value is not None and getattr(self, 'barter_bridge', None) is not None:
|
|
479
|
+
self.barter_bridge.attach_event_bus(value)
|
|
480
|
+
|
|
481
|
+
def start(self) -> None:
|
|
482
|
+
if self.host is not None:
|
|
483
|
+
self.mount_ingress(self.host)
|
|
484
|
+
|
|
485
|
+
def stop(self) -> None:
|
|
486
|
+
super().stop()
|
|
487
|
+
|
|
488
|
+
def close(self) -> None:
|
|
489
|
+
"""Close the three stores, counting and saying any that will not.
|
|
490
|
+
|
|
491
|
+
This was `except Exception: pass` over all three, which is audit L5
|
|
492
|
+
verbatim - the finding that `NodeApp.close()` told nobody when a store
|
|
493
|
+
could not close. A store that will not close is a locked database or a
|
|
494
|
+
disk full at commit, and at restart it is exactly the one an operator
|
|
495
|
+
needs told about. The exception is still caught, because one store
|
|
496
|
+
failing must not leave the other two open.
|
|
497
|
+
"""
|
|
498
|
+
failed: list[str] = []
|
|
499
|
+
for name in ('ledger', 'p2panda_store', 'rhio_store'):
|
|
500
|
+
st_obj = getattr(self, name, None)
|
|
501
|
+
if st_obj is None:
|
|
502
|
+
continue
|
|
503
|
+
try:
|
|
504
|
+
st_obj.close()
|
|
505
|
+
except Exception as e:
|
|
506
|
+
failed.append('%s: %s' % (name, e))
|
|
507
|
+
self.refusals.refuse('store', 'close failed', name,
|
|
508
|
+
{'error': '%s: %s' % (type(e).__name__, e)})
|
|
509
|
+
if failed:
|
|
510
|
+
self.say('video_store_close_failed', stores=', '.join(failed))
|
|
511
|
+
|
|
512
|
+
def state(self) -> Mapping[str, Any]:
|
|
513
|
+
return self.status_cmd()
|
|
514
|
+
|
|
515
|
+
def tick(self, now: Optional[int] = None) -> list[tuple[Any, ...]]:
|
|
516
|
+
"""Evaluate capacity shed handovers and reconcile cross-compartment barter proofs."""
|
|
517
|
+
now_val = self.clock() if now is None else int(now)
|
|
518
|
+
did: list[tuple[Any, ...]] = []
|
|
519
|
+
if any((self.blob_replication, self.compute, self.dvm)):
|
|
520
|
+
settled = self.barter_bridge.reconcile_compartments(
|
|
521
|
+
blob_replication=self.blob_replication,
|
|
522
|
+
compute=self.compute,
|
|
523
|
+
dvm=self.dvm,
|
|
524
|
+
now=now_val,
|
|
525
|
+
)
|
|
526
|
+
for item in settled:
|
|
527
|
+
did.append(('video-barter-settled', item['debtor'], item['utility_kind'], item['equivalent_transit_bytes']))
|
|
528
|
+
if self.capacity is None or not self._received_grants:
|
|
529
|
+
return did
|
|
530
|
+
sk_hex = getattr(self.ks, 'node_sk', None)
|
|
531
|
+
if not sk_hex:
|
|
532
|
+
return did
|
|
533
|
+
for stream_id, grant in list(self._received_grants.items()):
|
|
534
|
+
prev_active = self.reflector.is_handover_active(stream_id)
|
|
535
|
+
window_epoch = now_val // 600
|
|
536
|
+
new_state = self.reflector.evaluate_capacity_handover(
|
|
537
|
+
self.capacity,
|
|
538
|
+
stream_id=stream_id,
|
|
539
|
+
standby_grant=grant,
|
|
540
|
+
window=window_epoch,
|
|
541
|
+
sk_hex=sk_hex,
|
|
542
|
+
now=now_val,
|
|
543
|
+
)
|
|
544
|
+
now_active = self.reflector.is_handover_active(stream_id)
|
|
545
|
+
if now_active != prev_active:
|
|
546
|
+
if new_state == 'released' and self.transport is not None:
|
|
547
|
+
rel_pair = getattr(self.reflector, '_last_release', {}).get(stream_id)
|
|
548
|
+
operator_peer = str(grant.get('operator', ''))
|
|
549
|
+
sender_pk = getattr(self.ks, 'node_pk', '') or str(grant.get('broadcaster', ''))
|
|
550
|
+
if rel_pair is not None and operator_peer:
|
|
551
|
+
rec_msg, close_msg = rel_pair
|
|
552
|
+
try:
|
|
553
|
+
env_r = make_transit_envelope(
|
|
554
|
+
TYPE_TRANSIT_RECEIPT, rec_msg, sender=sender_pk, recipient=operator_peer, created_time=now_val
|
|
555
|
+
)
|
|
556
|
+
self.transport.send(env_r, operator_peer, stored=False)
|
|
557
|
+
env_c = make_transit_envelope(
|
|
558
|
+
TYPE_TRANSIT_CLOSE, close_msg, sender=sender_pk, recipient=operator_peer, created_time=now_val
|
|
559
|
+
)
|
|
560
|
+
self.transport.send(env_c, operator_peer, stored=False)
|
|
561
|
+
except Exception as e:
|
|
562
|
+
self.refusals.refuse(
|
|
563
|
+
'close',
|
|
564
|
+
'not delivered',
|
|
565
|
+
operator_peer,
|
|
566
|
+
{'grant': grant['grant_id'], 'error': '%s: %s' % (type(e).__name__, e)},
|
|
567
|
+
)
|
|
568
|
+
self.recent_events.append(('handover', stream_id, new_state, now_val))
|
|
569
|
+
self.say('video_transit_handover', stream_id=stream_id, state=new_state)
|
|
570
|
+
did.append(('video-handover', stream_id, new_state))
|
|
571
|
+
return did
|
|
572
|
+
|
|
573
|
+
# ---- Wire Protocol Handler (video-transit/1.0) --------------------------
|
|
574
|
+
|
|
575
|
+
def handle(self, inbound: Any) -> Optional[tuple[Any, ...]]:
|
|
576
|
+
"""Handle inbound `https://fleetmesh.org/video-transit/1.0/*` messages."""
|
|
577
|
+
msg = getattr(inbound, 'message', inbound)
|
|
578
|
+
sender = getattr(inbound, 'sender', '')
|
|
579
|
+
if not isinstance(msg, Mapping):
|
|
580
|
+
return None
|
|
581
|
+
msg_type = str(msg.get('type', ''))
|
|
582
|
+
body = msg.get('body')
|
|
583
|
+
if not isinstance(body, Mapping):
|
|
584
|
+
return None
|
|
585
|
+
now_val = self.clock()
|
|
586
|
+
|
|
587
|
+
if msg_type == TYPE_TRANSIT_SOLICITATION:
|
|
588
|
+
err = check_solicitation(body)
|
|
589
|
+
if err:
|
|
590
|
+
self.refusals.refuse('solicitation', err, sender, {})
|
|
591
|
+
return ('refused', 'transit-solicitation', err)
|
|
592
|
+
sol_body: Any = body
|
|
593
|
+
op_pk = getattr(self.ks, 'node_pk', '') or sol_body['broadcaster']
|
|
594
|
+
op_sk = getattr(self.ks, 'node_sk', None)
|
|
595
|
+
if not op_sk:
|
|
596
|
+
self.refusals.refuse('solicitation', 'no signing key', sender, {})
|
|
597
|
+
return ('refused', 'transit-solicitation', 'no signing key')
|
|
598
|
+
face_url = ''
|
|
599
|
+
if self.host is not None and hasattr(self.host, 'http_url'):
|
|
600
|
+
face_url = f"{self.host.http_url}/video/live/{sol_body['stream_id']}"
|
|
601
|
+
if not face_url:
|
|
602
|
+
# A grant names where to fetch from. Without an HTTP face there
|
|
603
|
+
# is nowhere, and inventing `node.fleetmesh.local` published a
|
|
604
|
+
# host no viewer can reach - and one that mDNS may resolve to
|
|
605
|
+
# something else entirely on a LAN. Refuse, as the branch above
|
|
606
|
+
# refuses a missing signing key.
|
|
607
|
+
self.refusals.refuse('solicitation', 'no http face to reflect from', sender, {})
|
|
608
|
+
return ('refused', 'transit-solicitation', 'no http face to reflect from')
|
|
609
|
+
endpoints = [face_url]
|
|
610
|
+
grant = self.reflector.accept_solicitation(
|
|
611
|
+
sol_body,
|
|
612
|
+
operator_pk=op_pk,
|
|
613
|
+
operator_sk=op_sk,
|
|
614
|
+
reflector_endpoints=endpoints,
|
|
615
|
+
max_unsettled_transit_bytes=self.cfg.default_debt_ceiling_bytes,
|
|
616
|
+
now=now_val,
|
|
617
|
+
)
|
|
618
|
+
delivered = True
|
|
619
|
+
if self.transport is not None and sender:
|
|
620
|
+
try:
|
|
621
|
+
env = make_transit_envelope(TYPE_TRANSIT_GRANT, grant, sender=op_pk, recipient=sender, created_time=now_val)
|
|
622
|
+
self.transport.send(env, sender, stored=False)
|
|
623
|
+
except Exception as e:
|
|
624
|
+
# The grant is signed and already in the ledger
|
|
625
|
+
# (Record-Then-Answer), so it is not withdrawn - but the
|
|
626
|
+
# peer does not have it. Reporting 'accepted' and saying
|
|
627
|
+
# nothing left the ledger claiming a grant the other side
|
|
628
|
+
# had never seen, with nothing for an operator to act on.
|
|
629
|
+
delivered = False
|
|
630
|
+
self.refusals.refuse('grant', 'not delivered', sender,
|
|
631
|
+
{'grant': grant['grant_id'],
|
|
632
|
+
'error': '%s: %s' % (type(e).__name__, e)})
|
|
633
|
+
self.say('video_grant_undelivered', grant=grant['grant_id'][:16],
|
|
634
|
+
peer=(sender or '')[:16])
|
|
635
|
+
self.recent_events.append(
|
|
636
|
+
('solicitation-accepted' if delivered else 'solicitation-accepted-undelivered',
|
|
637
|
+
sol_body['stream_id'], grant['grant_id'], now_val))
|
|
638
|
+
return ('transit-solicitation', sender, sol_body['stream_id'])
|
|
639
|
+
|
|
640
|
+
if msg_type == TYPE_TRANSIT_GRANT:
|
|
641
|
+
err = check_grant(body)
|
|
642
|
+
if err:
|
|
643
|
+
self.refusals.refuse('grant', err, sender, {})
|
|
644
|
+
return ('refused', 'transit-grant', err)
|
|
645
|
+
grant_body: Any = body
|
|
646
|
+
if sender and str(grant_body.get('operator', '')).lower() != sender.lower():
|
|
647
|
+
self.refusals.refuse('grant', 'operator does not match sender', sender, {})
|
|
648
|
+
return ('refused', 'transit-grant', 'operator does not match sender')
|
|
649
|
+
my_pk = getattr(self.ks, 'node_pk', None)
|
|
650
|
+
if my_pk and str(grant_body.get('broadcaster', '')).lower() != str(my_pk).lower():
|
|
651
|
+
self.refusals.refuse('grant', 'grant broadcaster is not this node', sender, {})
|
|
652
|
+
return ('refused', 'transit-grant', 'grant broadcaster is not this node')
|
|
653
|
+
try:
|
|
654
|
+
self.ledger.save_grant(grant_body, status='live')
|
|
655
|
+
except ValueError as e:
|
|
656
|
+
self.refusals.refuse('grant', str(e), sender, {})
|
|
657
|
+
return ('refused', 'transit-grant', str(e))
|
|
658
|
+
self._received_grants[grant_body['stream_id']] = grant_body
|
|
659
|
+
self.recent_events.append(('grant-received', grant_body['stream_id'], grant_body['grant_id'], now_val))
|
|
660
|
+
return ('transit-grant', sender, grant_body['grant_id'])
|
|
661
|
+
|
|
662
|
+
if msg_type == TYPE_TRANSIT_RECEIPT:
|
|
663
|
+
err = check_transit_receipt(body)
|
|
664
|
+
if err:
|
|
665
|
+
self.refusals.refuse('receipt', err, sender, {})
|
|
666
|
+
return ('refused', 'transit-receipt', err)
|
|
667
|
+
rec_body: Any = body
|
|
668
|
+
signer_pk = sender or getattr(self.ks, 'node_pk', '')
|
|
669
|
+
ack = self.ledger.on_inbound_receipt(signer_pk, rec_body, now=now_val)
|
|
670
|
+
if self.transport is not None and sender:
|
|
671
|
+
try:
|
|
672
|
+
op_pk = getattr(self.ks, 'node_pk', '')
|
|
673
|
+
env = make_transit_envelope(TYPE_TRANSIT_RECEIPT_ACK, ack, sender=op_pk, recipient=sender, created_time=now_val)
|
|
674
|
+
self.transport.send(env, sender, stored=False)
|
|
675
|
+
except Exception as e:
|
|
676
|
+
# The receipt was recorded; the acknowledgement was not
|
|
677
|
+
# sent. The peer cannot tell its receipt landed, so it
|
|
678
|
+
# resends or the chain stalls - and this said nothing.
|
|
679
|
+
self.refusals.refuse('receipt-ack', 'not delivered', sender,
|
|
680
|
+
{'grant': rec_body['grant'],
|
|
681
|
+
'error': '%s: %s' % (type(e).__name__, e)})
|
|
682
|
+
self.say('video_ack_undelivered', grant=str(rec_body['grant'])[:16],
|
|
683
|
+
peer=(sender or '')[:16])
|
|
684
|
+
self.recent_events.append(('receipt', rec_body['grant'], ack['status'], now_val))
|
|
685
|
+
return ('transit-receipt', sender, ack['status'])
|
|
686
|
+
|
|
687
|
+
if msg_type == TYPE_TRANSIT_CLOSE:
|
|
688
|
+
err = check_transit_close(body)
|
|
689
|
+
if err:
|
|
690
|
+
self.refusals.refuse('close', err, sender, {})
|
|
691
|
+
return ('refused', 'transit-close', err)
|
|
692
|
+
close_body: Any = body
|
|
693
|
+
self.ledger.on_inbound_close(close_body, now=now_val)
|
|
694
|
+
self.recent_events.append(('close', close_body['stream_id'], close_body['grant'], now_val))
|
|
695
|
+
return ('transit-close', sender, close_body['grant'])
|
|
696
|
+
|
|
697
|
+
if msg_type == TYPE_TRANSIT_RECEIPT_ACK:
|
|
698
|
+
err = check_transit_receipt_ack(body)
|
|
699
|
+
if err:
|
|
700
|
+
self.refusals.refuse('receipt-ack', err, sender, {})
|
|
701
|
+
return ('refused', 'transit-receipt-ack', err)
|
|
702
|
+
ack_body: Any = body
|
|
703
|
+
self.recent_events.append(('receipt-ack', ack_body['grant'], ack_body['status'], now_val))
|
|
704
|
+
return ('transit-receipt-ack', sender, ack_body['status'])
|
|
705
|
+
|
|
706
|
+
return None
|
|
707
|
+
|
|
708
|
+
# ---- Control-Socket Command Handlers ------------------------------------
|
|
709
|
+
|
|
710
|
+
def start_stream_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
711
|
+
stream_id = str(args.get('stream_id') or '').strip()
|
|
712
|
+
if not stream_id:
|
|
713
|
+
raise ValueError('stream_id is required')
|
|
714
|
+
title = str(args.get('title') or 'Live Stream')
|
|
715
|
+
price_msat = int(args.get('price_msat_per_mb') or 0)
|
|
716
|
+
account_fee = int(args.get('account_fee_msat') or 0)
|
|
717
|
+
broadcaster = getattr(self.ks, 'node_pk', None) or ('a' * 64)
|
|
718
|
+
now_val = self.clock()
|
|
719
|
+
|
|
720
|
+
genesis, op = self.packager.create_stream(
|
|
721
|
+
stream_id=stream_id,
|
|
722
|
+
broadcaster=broadcaster,
|
|
723
|
+
title=title,
|
|
724
|
+
price_msat_per_mb=price_msat,
|
|
725
|
+
account_fee_msat=account_fee,
|
|
726
|
+
now=now_val,
|
|
727
|
+
)
|
|
728
|
+
# The announcement carries the manifest URL to six protocols. With no
|
|
729
|
+
# HTTP face there is no URL to carry, and the fabricated fallback meant
|
|
730
|
+
# a faceless node advertised `node.fleetmesh.local` to every network it
|
|
731
|
+
# spoke. The stream still starts; it is simply not announced.
|
|
732
|
+
base_url = (
|
|
733
|
+
self.host.http_url
|
|
734
|
+
if (self.host is not None and hasattr(self.host, 'http_url'))
|
|
735
|
+
else ''
|
|
736
|
+
)
|
|
737
|
+
if base_url:
|
|
738
|
+
announcements = self.packager.announce_stream(stream_id, base_url=base_url)
|
|
739
|
+
else:
|
|
740
|
+
announcements = {}
|
|
741
|
+
self.say('video_not_announced', stream_id=stream_id)
|
|
742
|
+
self.recent_events.append(('stream-started', stream_id, op.id, now_val))
|
|
743
|
+
self.say('video_stream_started', stream_id=stream_id, title=title)
|
|
744
|
+
return {
|
|
745
|
+
'status': 'started',
|
|
746
|
+
'stream_id': genesis.stream_id,
|
|
747
|
+
'broadcaster': genesis.broadcaster,
|
|
748
|
+
'title': genesis.title,
|
|
749
|
+
'genesis_op_id': op.id,
|
|
750
|
+
'seq_num': op.seq_num,
|
|
751
|
+
'announcements': sorted(announcements.keys()),
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
TROUBLE_WINDOW: int = 900
|
|
755
|
+
|
|
756
|
+
def trouble(self) -> list[str]:
|
|
757
|
+
"""What an operator has to act on, not merely what state() records.
|
|
758
|
+
|
|
759
|
+
The subsystem had no trouble() at all, so every condition below was
|
|
760
|
+
visible only to someone who read `video status` and knew to look -
|
|
761
|
+
which is audit I13 exactly: the deaf inbox was in state() and not in
|
|
762
|
+
trouble(), so health and `meshctl watch` stayed green while live wraps
|
|
763
|
+
were being lost.
|
|
764
|
+
"""
|
|
765
|
+
out: list[str] = []
|
|
766
|
+
if self.packager.unpublished:
|
|
767
|
+
out.append('%d video segment(s) are in the manifest and were never published '
|
|
768
|
+
'to the blob server, so no viewer can fetch them: %s'
|
|
769
|
+
% (self.packager.unpublished, self.packager.last_publish_error))
|
|
770
|
+
recent = self.refusals.within(self.TROUBLE_WINDOW, self.clock())
|
|
771
|
+
undelivered = [r for r in recent if r.detail.get('grant') and 'not delivered' in r.why]
|
|
772
|
+
if undelivered:
|
|
773
|
+
out.append('%d transit message(s) recorded in the ledger never reached the peer '
|
|
774
|
+
'in the last %ds: %s'
|
|
775
|
+
% (len(undelivered), self.TROUBLE_WINDOW,
|
|
776
|
+
'; '.join(sorted({(r.subject or '?')[:16] for r in undelivered}))))
|
|
777
|
+
if self.barter_bridge.skipped:
|
|
778
|
+
out.append('barter reconciliation could not credit %s: %s'
|
|
779
|
+
% (', '.join('%d %s' % (n, k) for k, n in
|
|
780
|
+
sorted(self.barter_bridge.skipped.items())),
|
|
781
|
+
'; '.join('%s: %s' % (k, v) for k, v in
|
|
782
|
+
sorted(self.barter_bridge.last_skip.items()))))
|
|
783
|
+
return out
|
|
784
|
+
|
|
785
|
+
def status_cmd(self, args: Optional[Mapping[str, Any]] = None) -> Mapping[str, Any]:
|
|
786
|
+
peer_filter = str((args or {}).get('peer') or '').strip() or None
|
|
787
|
+
with self.packager._lock:
|
|
788
|
+
streams = [
|
|
789
|
+
{
|
|
790
|
+
'stream_id': sid,
|
|
791
|
+
'broadcaster': st_rec['genesis'].broadcaster,
|
|
792
|
+
'title': st_rec['genesis'].title,
|
|
793
|
+
'segments_count': len(st_rec['segments']),
|
|
794
|
+
'latest_seq': st_rec['latest_seq'],
|
|
795
|
+
'transit_endpoints': list(st_rec['transit_endpoints']),
|
|
796
|
+
'handover_active': self.reflector.is_handover_active(sid),
|
|
797
|
+
}
|
|
798
|
+
for sid, st_rec in self.packager._streams.items()
|
|
799
|
+
]
|
|
800
|
+
summary = self.ledger.summary()
|
|
801
|
+
debts = (
|
|
802
|
+
[self.ledger.debt_summary(peer_filter)]
|
|
803
|
+
if peer_filter
|
|
804
|
+
else summary['debts']
|
|
805
|
+
)
|
|
806
|
+
# What the subsystem could not do, where an operator reads it. Each of
|
|
807
|
+
# these was `except Exception: pass` and so was invisible: segments in
|
|
808
|
+
# the manifest no viewer can fetch, credits a peer earned and never
|
|
809
|
+
# got, and frames a transport would not take. Present only when
|
|
810
|
+
# non-zero, so a healthy node's status stays short.
|
|
811
|
+
return {
|
|
812
|
+
'streams': streams,
|
|
813
|
+
'active_streams': len(streams),
|
|
814
|
+
'reflector_sessions': self.reflector.active_sessions(),
|
|
815
|
+
'debts': debts,
|
|
816
|
+
'utility_credits': summary['utility_credits'],
|
|
817
|
+
**({'unpublished_segments': self.packager.unpublished,
|
|
818
|
+
'last_publish_error': self.packager.last_publish_error}
|
|
819
|
+
if self.packager.unpublished else {}),
|
|
820
|
+
**({'frames_dropped': dict(self.reflector.frames_dropped)}
|
|
821
|
+
if self.reflector.frames_dropped else {}),
|
|
822
|
+
**({'barter_skipped': dict(self.barter_bridge.skipped),
|
|
823
|
+
'barter_last_skip': dict(self.barter_bridge.last_skip)}
|
|
824
|
+
if self.barter_bridge.skipped else {}),
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
def settle_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
828
|
+
peer = str(args.get('peer') or '').strip()
|
|
829
|
+
if not peer:
|
|
830
|
+
raise ValueError('peer is required')
|
|
831
|
+
utility_kind: Any = str(args.get('utility_kind') or 'blob_shoring').strip()
|
|
832
|
+
units = int(args.get('units') or 1)
|
|
833
|
+
grant_id = str(args.get('grant_id') or '').strip() or 'manual-proof'
|
|
834
|
+
creditor = getattr(self.ks, 'node_pk', None) or ('b' * 64)
|
|
835
|
+
now_val = self.clock()
|
|
836
|
+
rec = self.ledger.credit_barter(
|
|
837
|
+
debtor=peer,
|
|
838
|
+
creditor=creditor,
|
|
839
|
+
utility_kind=utility_kind,
|
|
840
|
+
units=units,
|
|
841
|
+
proof_ref=grant_id,
|
|
842
|
+
now=now_val,
|
|
843
|
+
)
|
|
844
|
+
debt_after = self.ledger.debt_summary(peer)
|
|
845
|
+
credited_bytes = int(rec['equivalent_transit_bytes'])
|
|
846
|
+
self.recent_events.append(('settled', peer, utility_kind, credited_bytes, now_val))
|
|
847
|
+
self.say(
|
|
848
|
+
'video_transit_settled',
|
|
849
|
+
credited_bytes=credited_bytes,
|
|
850
|
+
peer=peer[:16],
|
|
851
|
+
utility_kind=utility_kind,
|
|
852
|
+
)
|
|
853
|
+
return {
|
|
854
|
+
'credit': rec,
|
|
855
|
+
'credited_bytes': credited_bytes,
|
|
856
|
+
'peer': peer,
|
|
857
|
+
'utility_kind': utility_kind,
|
|
858
|
+
'units': units,
|
|
859
|
+
'debt_after': debt_after,
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
def ingest_segment_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
863
|
+
stream_id = str(args.get('stream_id') or '').strip()
|
|
864
|
+
data_b64 = str(args.get('data_b64') or '').strip()
|
|
865
|
+
if not stream_id or not data_b64:
|
|
866
|
+
raise ValueError('stream_id and data_b64 are required')
|
|
867
|
+
segment_bytes = base64.b64decode(data_b64)
|
|
868
|
+
duration_ms = int(args.get('duration_ms') or 2000)
|
|
869
|
+
is_keyframe = bool(args.get('is_keyframe', True))
|
|
870
|
+
is_audio_only = bool(args.get('is_audio_only', False))
|
|
871
|
+
now_val = self.clock()
|
|
872
|
+
|
|
873
|
+
entry, op, addr = self.packager.ingest_segment(
|
|
874
|
+
stream_id=stream_id,
|
|
875
|
+
segment_data=segment_bytes,
|
|
876
|
+
duration_ms=duration_ms,
|
|
877
|
+
is_keyframe=is_keyframe,
|
|
878
|
+
is_audio_only=is_audio_only,
|
|
879
|
+
now=now_val,
|
|
880
|
+
)
|
|
881
|
+
self.recent_events.append(('segment-ingested', stream_id, entry.segment_index, addr['size'], now_val))
|
|
882
|
+
self.say('video_segment_ingested', seq=entry.segment_index, stream_id=stream_id, size=addr['size'])
|
|
883
|
+
return {
|
|
884
|
+
'stream_id': stream_id,
|
|
885
|
+
'segment_index': entry.segment_index,
|
|
886
|
+
'sha256': addr['sha256'],
|
|
887
|
+
'tree_root': addr['tree_root'],
|
|
888
|
+
'cid': addr['cid'],
|
|
889
|
+
'size': addr['size'],
|
|
890
|
+
'op_id': op.id,
|
|
891
|
+
'seq_num': op.seq_num,
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
def solicit_transit_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
895
|
+
stream_id = str(args.get('stream_id') or '').strip()
|
|
896
|
+
if not stream_id:
|
|
897
|
+
raise ValueError('stream_id is required')
|
|
898
|
+
peer = str(args.get('peer') or '').strip()
|
|
899
|
+
raw_mode = str(args.get('mode') or 'pro-bono').strip()
|
|
900
|
+
mode: Any = 'commercial' if 'commercial' in raw_mode else 'pro-bono'
|
|
901
|
+
bitrate = int(args.get('expected_bitrate_kbps') or 2500)
|
|
902
|
+
broadcaster = getattr(self.ks, 'node_pk', None) or ('a' * 64)
|
|
903
|
+
now_val = self.clock()
|
|
904
|
+
|
|
905
|
+
sol = self.reflector.build_solicitation(
|
|
906
|
+
stream_id=stream_id,
|
|
907
|
+
broadcaster=broadcaster,
|
|
908
|
+
mode=mode,
|
|
909
|
+
bitrate_kbps=bitrate,
|
|
910
|
+
now=now_val,
|
|
911
|
+
)
|
|
912
|
+
sent = False
|
|
913
|
+
if peer and self.transport is not None:
|
|
914
|
+
try:
|
|
915
|
+
env = make_transit_envelope(
|
|
916
|
+
TYPE_TRANSIT_SOLICITATION,
|
|
917
|
+
sol,
|
|
918
|
+
sender=broadcaster,
|
|
919
|
+
recipient=peer,
|
|
920
|
+
created_time=now_val,
|
|
921
|
+
)
|
|
922
|
+
self.transport.send(env, peer, stored=False)
|
|
923
|
+
sent = True
|
|
924
|
+
except Exception:
|
|
925
|
+
sent = False
|
|
926
|
+
return {'solicitation': sol, 'sent': sent}
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
__all__ = [
|
|
930
|
+
'VideoConfig',
|
|
931
|
+
'VideoTransitCompartment',
|
|
932
|
+
]
|