aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/streaming.py
blob: a425c653e212d6052439c506821d879dbeb44acb (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
"""Video streaming for the Videos app: the credit a viewer grants, the one
stream a session holds, and the ffmpeg pipeline behind it."""

import asyncio
import logging
import time

from meshbay_common import MNP_VERSION
from meshbay_common.protocol import MNP, chunk_ciphertext

from meshbay_node import hwaccel, platform
from meshbay_node.media_probe import BROWSER_INCOMPATIBLE_VIDEO_CODECS
from meshbay_node.media_probe import probe_video as _probe_video
from meshbay_node.roots import off_disk
from meshbay_node.transport.webrtc.disk import _locate
from meshbay_node.transport.webrtc.media_tools import _seek_lands_at

log = logging.getLogger("meshbay_node.transport.webrtc_server")


# ffmpeg is spawned per stream request; without a cap any member can fork-bomb
# the node by requesting many streams at once (H6).
#
# Two was sized when a stream was a burst: the client took segments as fast as
# it could append them, so a slot was held for the minute it took to push the
# file and then came back. Now that the client only pulls ninety seconds ahead
# of the playhead, a slot is held for as long as the film runs — so two slots
# means two people can watch anything at all, and the third is refused for the
# next hour and a half. The work behind a slot has not changed and is small:
# ffmpeg runs `-c copy`, a remux with no encoding in it, and spends most of the
# film blocked on a pipe nobody is reading.
#
# This is the default, not the policy: the right number depends on the machine,
# so the operator sets `max_concurrent_streams` under [node] in node.toml. This
# value applies when they have said nothing.
MAX_CONCURRENT_TRANSCODES = 8

STREAM_SEGMENT_SIZE = 256 * 1024

# What a client may ask for in one go, and how long the node waits for it to ask
# again before deciding nobody is watching any more.
STREAM_MAX_CREDIT = 256
STREAM_CREDIT_TIMEOUT = 120
# How often that budget is re-examined. A viewer who left stops being
# charged for a slot within this, rather than within the timeout.
STREAM_CREDIT_POLL = 3


class StreamingMixin:
    def _grant_stream_credit(self, msg: dict) -> None:
        """
        The client has room for more segments.

        `n` of zero is a keepalive, not a no-op: a viewer whose buffer is
        already a minute and a half ahead of the playhead deliberately grants
        nothing, and must still be able to say it is there. Without that, the
        stall timeout below cannot tell a paused film from a closed tab.
        """
        log.debug("stream credit +%s (had %d, sent %d)",
                  msg.get("n"), self._stream_credit, self._stream_segments)
        try:
            n = int(msg.get("n", 1))
        except (TypeError, ValueError):
            n = 1
        if n == 0:
            # The fingerprint of a client that bounds its read-ahead. A client
            # that never sends one is granting credit per append — which is
            # what fills the browser's buffer ceiling and wedges the player.
            self._stream_keepalives += 1
            if self._stream_keepalives == 1:
                log.info("stream: peer is pacing itself (first keepalive at "
                         "%d segments)", self._stream_segments)
        self._stream_credit += max(0, min(n, STREAM_MAX_CREDIT))
        self._stream_heard_at = time.monotonic()
        self._stream_credit_evt.set()

    def _stop_stream(self) -> None:
        """
        The viewer was closed. Stop transcoding and let go of the slot.

        Without this the only thing that ended a stream was the credit timeout,
        so ffmpeg kept running and held one of the node's two transcode slots
        for two minutes after nobody was watching — which is how closing a video
        made the next one answer "server busy".
        """
        self._stream_stopped = True
        self._stream_credit_evt.set()

    async def _await_stream_credit(self) -> bool:
        """
        Block until the client has room. False if it stopped asking.

        Without this the node hands ffmpeg's entire output to the channel as
        fast as it is produced, and the browser holds a four gigabyte film in a
        JavaScript array while MediaSource consumes it a segment at a time.
        """
        # Measured from the last thing the peer said, not from the start of the
        # wait: a viewer that is buffered well ahead sends keepalives and grants
        # nothing for minutes at a time, and that is a watched film, not a
        # stalled one.
        self._stream_heard_at = time.monotonic()
        waiting_since = 0.0
        while self._stream_credit <= 0:
            if waiting_since == 0.0:
                waiting_since = time.monotonic()
                # Debug: a paced viewer runs out of credit between every
                # window, so this is one line per eight segments — hundreds
                # per film. It is worth having, but not by default.
                log.debug("stream: out of credit at %d segments (%.0f MB) — "
                          "waiting for the peer",
                          self._stream_segments,
                          self._stream_segments * STREAM_SEGMENT_SIZE / 1048576)
            if self._stream_stopped:
                return False
            # Checked before the wait as well as after it: a peer that vanishes
            # sends no credit and fires no event, so waiting the full timeout
            # on a channel that is already shut is pure dead time on a slot.
            if self._channel is None or self._channel.readyState != "open":
                return False
            self._stream_credit_evt.clear()
            try:
                # In slices rather than one long sleep, so a connection that
                # dies mid-wait is noticed in seconds instead of minutes. The
                # total budget is unchanged.
                await asyncio.wait_for(self._stream_credit_evt.wait(),
                                       timeout=STREAM_CREDIT_POLL)
            except TimeoutError:
                silent = time.monotonic() - self._stream_heard_at
                if silent >= STREAM_CREDIT_TIMEOUT:
                    log.info("Stream stalled: nothing from peer=%s for %.0fs",
                             (self._user_id or "?")[:8], silent)
                    return False
                continue
            if self._stream_stopped:
                return False
            if self._channel is None or self._channel.readyState != "open":
                return False
        if waiting_since:
            waited_for = time.monotonic() - waiting_since
            # Only a wait long enough to be a symptom. Normal pacing puts a
            # gap of a few seconds between windows; a minute means the viewer
            # is buffered right up and playing, or has stopped watching.
            level = log.info if waited_for >= 10 else log.debug
            level("stream: credit arrived after %.1fs", waited_for)
        self._stream_credit -= 1
        return True

    async def _replace_stream(self, msg: dict) -> None:
        """Retire this session's previous stream before starting another.

        A viewer plays one film at a time, so a second request means the first
        one is finished whatever the client managed to tell us. Relying on
        `stream_stop` alone was not enough: a browser that is backgrounded,
        reloaded or simply loses the message never sends it, and the only other
        thing that ends a stream is STREAM_CREDIT_TIMEOUT — two minutes during
        which ffmpeg keeps running and holds one of the node's two transcode
        slots.

        That is the reported failure exactly: first video fine, second fine,
        third answered "Server busy" because the first two were still holding
        both slots. The client shows that as "buffering" forever.

        Waiting for the old task is what makes the slot available: it is the
        exit of its `async with sem` that releases it.
        """
        prev = self._stream_task
        if prev is not None and not prev.done():
            t0 = time.monotonic()
            log.info("stream: retiring previous stream")
            self._stop_stream()
            try:
                await asyncio.wait_for(asyncio.shield(prev), timeout=15)
                log.info("stream: previous stream ended in %.1fs",
                         time.monotonic() - t0)
            except TimeoutError:
                log.warning("stream: previous stream STILL RUNNING after 15s")
            except Exception:
                pass   # it failed on its own; the slot is free either way
        self._stream_task = asyncio.current_task()
        await self._stream_video(msg)

    def _transcode_semaphore(self) -> asyncio.Semaphore:
        """The node's stream budget, shared across every peer.

        One ffmpeg per request with no cap lets any member exhaust the node's
        CPU and process table (H6). The semaphore lives on the transport
        context rather than the session so that it counts the node's viewers
        and not one browser's, and it is created once: rebuilding it per call
        would hand every caller its own budget and cap nothing at all.
        """
        sem = self._ctx.get("_transcode_sem")
        if sem is None:
            n = self._ctx.get("max_concurrent_streams") or MAX_CONCURRENT_TRANSCODES
            sem = asyncio.Semaphore(n)
            self._ctx["_transcode_sem"] = sem
            log.info("stream: %d concurrent viewers allowed", n)
        return sem

    async def _stream_video(self, msg: dict) -> None:
        """Stream a video file as fMP4 segments via MSE-compatible output."""
        sem = self._transcode_semaphore()
        if sem.locked() and sem._value <= 0:
            self._send({"type": "error", "detail": "Server busy, retry shortly"})
            return
        ctx = self._ctx
        log.info("stream: waiting for a slot (%d of %d in use)",
                 ctx.get("_streams_in_flight", 0), self._stream_capacity())
        async with sem:
            # Counted here rather than read back out of the semaphore's private
            # `_value`: `set_capacity` needs to know how many slots are held in
            # order to resize without letting the pool overshoot, and a number
            # this code maintains itself is one that survives the semaphore
            # object being replaced underneath it.
            ctx["_streams_in_flight"] = ctx.get("_streams_in_flight", 0) + 1
            log.info("stream: slot acquired (%d of %d in use)",
                     ctx["_streams_in_flight"], self._stream_capacity())
            try:
                await self._stream_video_inner(msg)
            finally:
                ctx["_streams_in_flight"] = max(
                    0, ctx.get("_streams_in_flight", 1) - 1)
                log.info("stream: slot released (%d of %d in use)",
                         ctx["_streams_in_flight"], self._stream_capacity())

    def _stream_capacity(self) -> int:
        return self._ctx.get("max_concurrent_streams") or MAX_CONCURRENT_TRANSCODES

    async def _stream_video_inner(self, msg: dict) -> None:
        ctx = self._group_ctx()
        file_id = msg.get("file_id", "")
        entry = ctx["index"].get_entry(file_id)
        if not entry:
            self._send({"type": "error", "detail": "File not found"})
            return

        file_path, refusal = await off_disk(ctx["roots"], _locate, ctx["roots"], entry)
        if refusal is not None:
            self._send({"type": "error", "detail": refusal})
            return

        gek = ctx.get("gek")
        file_hash = bytes.fromhex(entry.id)

        try:
            probe = await _probe_video(str(file_path))
        except Exception as e:
            self._send({"type": "error", "detail": f"Probe failed: {e}"})
            return
        codec_str = probe.codec
        duration = probe.duration
        has_audio = probe.has_audio
        raw_video_codec = probe.raw_codec_name

        # No video stream at all is the only thing this path cannot serve, and
        # it is the only thing refused here. A source with no MSE codec string
        # is emphatically not that — it is the case the re-encode below exists
        # for, and refusing it here (as "Unsupported video codec") is what this
        # fixed.
        if not raw_video_codec:
            self._send({"type": "error", "detail": "No video stream in this file"})
            return

        # Where to begin. Seeking is a stream restarted somewhere else: the
        # viewer moves the scrubber, this session's previous stream is retired
        # by _replace_stream, and ffmpeg is spawned again with -ss.
        try:
            start = float(msg.get("start", 0) or 0)
        except (TypeError, ValueError):
            start = 0.0
        # Past the end would produce an empty stream and a player waiting for
        # segments that are never coming.
        if duration and start >= duration - 1:
            start = max(0.0, duration - 5)
        start = max(0.0, start)

        # Video is copied whenever the browser can decode it directly —
        # re-encoding it is the expensive thing this pipeline exists to avoid,
        # and H264/VP9/AV1 already decode fine in-browser. HEVC is the one
        # exception (BROWSER_INCOMPATIBLE_VIDEO_CODECS, media_probe.py): found
        # live, a real HEVC/EAC3 WEB-DL reported "Codec not supported for
        # streaming" from MediaSource.isTypeSupported even though ffprobe/VLC
        # play it fine — Chrome has no HEVC decoder on most non-Apple
        # platforms. The operator can turn this fallback off (node.toml
        # transcode_incompatible_video = false) for a client fleet they know
        # already decodes HEVC, since it is real CPU cost, unlike the copy
        # path. Audio is always transcoded to AAC, never copied — see
        # _probe_video for why "copy" there is not an option, not even for a
        # codec that sounds close enough (plain AC-3 has the same in-browser
        # decode problem as E-AC-3, just without ffmpeg also refusing to mux
        # it). Transcoding audio is cheap; it does not change the cost model
        # the transcode-slot semaphore is sized around.
        #
        # Two kinds of source cannot be copied, and both re-encode:
        #
        #  - one whose MSE codec string is real but that no mainstream browser
        #    decodes — HEVC, BROWSER_INCOMPATIBLE_VIDEO_CODECS;
        #  - one with **no MSE codec string at all**: MPEG-4 Part 2 (Xvid,
        #    DivX), MPEG-2, VC-1, WMV, Theora. `stream_init` has to carry a
        #    string the client puts through MediaSource.isTypeSupported, and
        #    `probe_video` returns None for these precisely because no browser
        #    has a MediaSource decoder for them, so there is none to carry.
        #    This second kind used to be refused outright with "Unsupported
        #    video codec" — which named the source's problem and not the
        #    node's answer to it, since ffmpeg re-encodes these in real time on
        #    any machine that can run this daemon. Reported live against an
        #    Xvid/MP3 .avi. `transcode_incompatible_video`'s own documentation
        #    (docs/MESHBAY_DESIGN.md §6.8) already said "HEVC *and other browser-
        #    incompatible video codecs*"; only HEVC was ever wired up.
        can_copy = (bool(codec_str)
                    and raw_video_codec not in BROWSER_INCOMPATIBLE_VIDEO_CODECS)
        allow_transcode = self._ctx.get("transcode_incompatible_video", True)
        transcode_video = not can_copy and allow_transcode
        if not can_copy and not allow_transcode and not codec_str:
            # The operator turned the fallback off and there is nothing to fall
            # back *to*: a stream_init with no codec string is one the client
            # refuses before the first byte arrives. Which of the two it is
            # matters — "unsupported codec" sends the reader to look at the
            # file, and the file is fine.
            log.info("stream: %s is %s, which needs a re-encode, and "
                     "transcode_incompatible_video is off — refusing",
                     entry.name, raw_video_codec)
            self._send({"type": "error",
                        "detail": "This video needs transcoding, which the "
                                  "operator has turned off"})
            return
        # Seeking, and the trap that made a seek on a copied stream unwatchable.
        #
        # -ss BEFORE -i seeks by the container index rather than by decoding up
        # to the point: milliseconds on a 500 MB film instead of tens of
        # seconds. It lands on the keyframe at or before `start`, so the
        # picture can begin a few seconds earlier than asked — which is what
        # every streaming player does.
        #
        # **`-accurate_seek` is on by default, and it trims what it can.** It
        # cannot trim copied video, which has to begin on a keyframe; it does
        # trim the re-encoded audio, to exactly `start`. So the output began
        # with video from the keyframe and audio from `start` — correct
        # timestamps, both streams honestly placed, and **a hole in the audio
        # one whole GOP wide**. Measured on a real film with a 10 s keyframe
        # interval: seeking to 609 s against a keyframe at 599.104 s left
        # 9.979 s of silence, after which sound and picture were a GOP apart
        # for the rest of the film.
        #
        # Nothing downstream could see it. Every timestamp check passes — the
        # first PTS of each stream, their durations, their spans, the browser's
        # own A/V delta through MediaSource — because the timestamps were never
        # wrong. Only the *content* at a given instant was, which is why this
        # was found by decoding the output and comparing it against the source:
        # the first frame is byte-identical to the source frame at the
        # keyframe, and with the fix the audio's energy envelope matches the
        # source at that same instant (r = 0.97) instead of one GOP later.
        #
        # This is also why re-encoded video never showed the fault, and why a
        # library's HEVC files looked like the only ones that worked: video
        # that is re-encoded *can* start exactly at `start`, so accurate
        # seeking is right there and stays on.
        #
        # **`start` is rewritten on the copy path to where the seek actually
        # lands**, which is measured below rather than predicted — see
        # `_seek_lands_at`. From the rewrite on, it is where the picture really
        # begins and not where the viewer dragged to. The distinction was
        # invisible while only the scrubber read the number; it stopped being
        # invisible when subtitles did, since their cues carry the source's
        # absolute times and every second of disagreement puts a line on
        # screen a second away from the voice saying it.
        requested = start
        seek_args: list[str] = []
        if requested > 0:
            seek_args = ["-ss", f"{requested:.3f}"] if transcode_video else [
                "-noaccurate_seek", "-ss", f"{requested:.3f}"]
        map_args = ["-map", "0:v:0"]
        if transcode_video:
            log.info("stream: re-encoding %s (%s) to H264", entry.name,
                     raw_video_codec)
            # Where the re-encode runs, and with which arguments — both live
            # in hwaccel.py now, including the 8-bit downsampling a 10-bit HDR
            # source needs before either encoder will take it. `modes_for` has
            # measured this machine by encoding on it and returns the ladder to
            # try, always ending in libx264: a node with no usable VA-API does
            # exactly what it did before this existed, and a Celeron with an
            # iGPU stops being a machine where `transcode_incompatible_video`
            # has to be turned off to keep streaming watchable.
            modes = await hwaccel.modes_for(raw_video_codec)
            hw = await hwaccel.encoder()
            # Must match "-profile:v high -level 4.1" byte-for-byte (avc1.<profile
            # hex><constraint><level hex>) — the client checks this string with
            # MediaSource.isTypeSupported before trusting a single byte of the
            # stream, so a mismatch here fails exactly the check this exists to
            # pass. Both encoders get those two arguments, spelled the same way,
            # from hwaccel._PROFILE_ARGS — one place, so they cannot drift.
            codec_str = "avc1.640029,mp4a.40.2" if has_audio else "avc1.640029"
        else:
            modes, hw = [hwaccel.SW], None

        def video_args(mode: str) -> list[str]:
            return (hwaccel.codec_args(mode, hw) if transcode_video
                    else ["-c:v", "copy"])

        # The audio half does not change with the video encoder, and is never a
        # copy — see _probe_video for why.
        audio_args: list[str] = []
        # Which audio track. A dubbed film carries several and the first one is
        # not a neutral default — it is whatever the person who muxed the file
        # happened to put first, which across a real library is overwhelmingly
        # one language. Out of range falls back to the first rather than
        # refusing: the client's list comes from a `stream_init` that may
        # predate the file being replaced on disk, and a viewer who asked for
        # the second track of a file that now has one wants the film, not an
        # error. `stream_init` says which track was actually used, the same way
        # it says which `start` was actually used and for the same reason.
        try:
            audio_track = int(msg.get("audio_track", 0) or 0)
        except (TypeError, ValueError):
            audio_track = 0
        if not 0 <= audio_track < len(probe.audio_tracks):
            audio_track = 0
        if has_audio:
            map_args += ["-map", f"0:a:{audio_track}"]
            # Downmixed to stereo: a WEB-DL's 5.1 track becomes 6-channel AAC
            # with no "-ac", which ffprobe and VLC accept fine but which some
            # browsers' MSE decoder rejects outright once real fragments are
            # appended — isTypeSupported() only checks the codec string, so
            # the failure doesn't surface until playback, as a SourceBuffer
            # forced out of its MediaSource with no further explanation.
            audio_args = ["-c:a", "aac", "-ac", "2", "-b:a", "192k"]
        # Where that seek lands, measured with the mapping this stream will
        # use. It has to be here rather than beside `seek_args` above: the
        # landing point depends on which audio track is mapped, because the
        # container is seeked to a position that serves *every* mapped stream
        # — on a real title, video alone landed at 4909.863 s and the same
        # seek with the second audio track landed at 4907.236 s. The `-ss`
        # argument is deliberately left at the request, so the bytes served
        # are exactly the ones served before; only the number naming them
        # changes.
        if requested > 0 and not transcode_video:
            landed = await _seek_lands_at(file_path, requested, map_args)
            if landed is not None:
                start = landed
        # One spawn per mode, and only ever more than one when hwaccel.py found
        # a working GPU. **What a mode is tried against is the file itself**:
        # a test encode proves the encoder, and nothing proves the GPU can
        # decode *this* source until it is asked to — iHD has no MPEG-4 Part 2
        # decoder at all, so an Xvid .avi fails the full-hardware mode and
        # nothing about the machine could have predicted it.
        #
        # The failure is silent and instant: ffmpeg writes its complaint to
        # stderr and exits, so stdout reaches EOF with nothing on it. That is
        # the signal read here, before `stream_init` is sent and therefore
        # before the client has been told anything it would have to be told
        # again. The first segment is kept and handed to the loop below rather
        # than re-read, since the process it came from is still running.
        #
        # The last mode is spawned and trusted, which is what keeps the
        # single-mode path — every node without a GPU, and every copied stream
        # — byte-for-byte what it was: no extra read, no extra wait.
        first_segment = b""
        for attempt, mode in enumerate(modes):
            proc = await asyncio.create_subprocess_exec(
                platform.ffmpeg_cmd(), "-hide_banner", "-loglevel", "error",
                *hwaccel.input_args(mode, hw),
                *seek_args,
                "-i", str(file_path),
                *map_args,
                *video_args(mode), *audio_args,
                "-movflags", "frag_keyframe+empty_moov+default_base_moof",
                "-f", "mp4", "pipe:1",
                stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
            )
            if attempt == len(modes) - 1:
                break
            first_segment = await proc.stdout.read(STREAM_SEGMENT_SIZE)
            if first_segment:
                break
            err = (await proc.stderr.read()).decode("utf-8", "replace").strip()
            await proc.wait()
            hwaccel.demote(raw_video_codec, mode,
                           err.splitlines()[0] if err else "no output")

        self._send({
            "type": MNP.STREAM_INIT,
            "v": MNP_VERSION,
            "file_id": file_id,
            "codec": codec_str,
            "duration": duration,
            # ffmpeg restarts its timestamps at zero whatever we seek to, so
            # this is what the client adds back (`SourceBuffer.timestampOffset`)
            # to put the fragments where they belong on the timeline.
            "start": start,
            # The track list is how a client discovers that this node can
            # switch language at all — there is no version check anywhere in
            # the player. A node that does not send it gets no selector, and
            # the client then never sends `audio_track` to a peer that would
            # ignore it and serve the wrong language without saying so.
            "audio_tracks": [
                {
                    "i": tr.ordinal,
                    "lang": tr.language,
                    "title": tr.title,
                    "codec": tr.codec_name,
                    "ch": tr.channels,
                }
                for tr in probe.audio_tracks
            ],
            "audio_track": audio_track if has_audio else None,
            # Same discovery-from-the-answer shape as `audio_tracks`: a node
            # too old to enumerate sends no list, the client shows no selector
            # and never sends `subtitle_req` to a peer that would answer
            # "unknown message type". Text tracks only — a bitmap one has no
            # WebVTT to offer (media_probe.py), so it is absent here rather
            # than present and unplayable.
            "subtitle_tracks": [
                {
                    "i": tr.ordinal,
                    "lang": tr.language,
                    "title": tr.title,
                    "codec": tr.codec_name,
                    # What tells a full translation from signage-only. Without
                    # it the two are the same menu entry, and picking the
                    # forced one shows nothing for minutes at a time — which
                    # reads as a broken feature and was reported as one.
                    "forced": tr.forced,
                    "sdh": tr.hearing_impaired,
                }
                for tr in probe.subtitle_tracks
            ],
        })

        # A client that says nothing gets the old behaviour, which is why this
        # defaults to unlimited rather than to zero: a stream that waits for
        # credit from a peer that will never send any is a stream that hangs.
        try:
            self._stream_credit = int(msg.get("credits", 0) or 0)
        except (TypeError, ValueError):
            self._stream_credit = 0
        paced = self._stream_credit > 0
        self._stream_stopped = False

        index = 0
        self._stream_started_at = time.monotonic()
        self._stream_segments = 0
        reason = "eof"
        log.info("stream: stream_init sent file=%s paced=%s credits=%d start=%.1fs "
                 "audio=%s/%d",
                 file_id[:12], paced, self._stream_credit, start,
                 audio_track if has_audio else "-", len(probe.audio_tracks))
        try:
            while True:
                if paced and not await self._await_stream_credit():
                    reason = "no-credit-or-gone"
                    break
                if self._stream_stopped:
                    reason = "stopped-by-peer"
                    log.info("Stream stopped by peer=%s after %d segments",
                             (self._user_id or "?")[:8], index)
                    break
                if first_segment:
                    data, first_segment = first_segment, b""
                else:
                    data = await proc.stdout.read(STREAM_SEGMENT_SIZE)
                if not data:
                    break
                # Same derivation as a file chunk, indexed by segment: one
                # implementation, in `meshbay_common.protocol`.
                nonce, ct = chunk_ciphertext(gek, data, index, file_hash)
                self._send({
                    "type": MNP.STREAM_DATA,
                    "v": MNP_VERSION,
                    "file_id": file_id,
                    "segment_index": index,
                    "nonce": nonce,
                    "ct": ct,
                    "plaintext_size": len(data),
                })
                index += 1
                self._stream_segments = index
                if index % 100 == 0:
                    # A stream that stops shows up here as a last line, and the
                    # numbers on it say which side stopped it.
                    log.info("stream: %d segments (%.0f MB), credit=%d, "
                             "keepalives=%d, %.0fs in",
                             index, index * STREAM_SEGMENT_SIZE / 1048576,
                             self._stream_credit, self._stream_keepalives,
                             time.monotonic() - self._stream_started_at)
                await asyncio.sleep(0)
        except Exception as e:
            log.error("Stream error: %s", e)
        finally:
            try:
                proc.kill()
            except ProcessLookupError:
                pass
            # `await proc.wait()` on its own is the deadlock the asyncio docs
            # warn about: ffmpeg fills the stdout pipe we have stopped reading,
            # and the transport cannot finish closing until that buffer is
            # drained. Measured on 2026-08-16 with stream: — a viewer closed
            # the player after 99 segments (25 MB) and the task sat here past
            # the 15 s handover timeout, holding a transcode slot. The node has
            # two, so the next video waited and the one after was refused.
            #
            # Drain first, then wait with a bound. The slot must come back even
            # if the process is being stubborn: it has already had SIGKILL, and
            # the OS will reap it whether or not we are still watching.
            stderr_output = b""
            for pipe in (proc.stdout, proc.stderr):
                if pipe is None:
                    continue
                try:
                    drained = await asyncio.wait_for(pipe.read(), timeout=2)
                    if pipe is proc.stderr:
                        stderr_output = drained
                except Exception:
                    pass
            try:
                await asyncio.wait_for(proc.wait(), timeout=5)
            except Exception:
                log.warning("stream: ffmpeg did not reap in 5s — "
                            "releasing the slot regardless")

        # A positive returncode is ffmpeg exiting on its own with an error,
        # before we ever killed it (a kill shows up as a negative signal
        # number instead) — zero segments in that case is a real failure,
        # not a normal end, and saying nothing here is indistinguishable
        # from "the file is just this short". Found live against a real
        # 5.1 E-AC-3 WEB-DL that ffmpeg refused to even start muxing.
        # Detail stays server-side (L3: never hand a peer raw stderr).
        if index == 0 and proc.returncode is not None and proc.returncode > 0:
            log.error("stream: ffmpeg exited rc=%s before any output — %s",
                      proc.returncode,
                      stderr_output.decode(errors="replace").strip().splitlines()[-1:]
                      or "(no stderr)")
            if not self._stream_stopped:
                self._send({"type": "error",
                           "detail": "Could not stream this file"})
        elif not self._stream_stopped:
            self._send({
                "type": MNP.STREAM_END,
                "v": MNP_VERSION,
                "file_id": file_id,
            })
        log.info("stream: stream ended reason=%s segments=%d after %.1fs",
                 reason, index, time.monotonic() - self._stream_started_at)
        log.info("Streamed %s: %d segments", entry.name, index)
        self._audit("stream_video", entry.name)

    def _do_stream_request(self, msg: dict) -> None:
        sem = self._ctx.get("_transcode_sem")
        log.info("stream: req file=%s credits=%s slots_free=%s prev=%s",
                 str(msg.get("file_id"))[:12], msg.get("credits"),
                 getattr(sem, "_value", "?"),
                 "alive" if (self._stream_task and
                             not self._stream_task.done()) else "none")
        self._spawn(self._replace_stream(msg))

    def _do_client_diag(self, msg: dict) -> None:
        # Diagnostics only. The node acts on none of it — it writes it
        # next to its own view of the same stream, which is the only
        # place the two halves can be compared when the client is a
        # phone with no console.
        # Every field is peer-controlled, so each is stringified and
        # cut short: this is a log line, not a channel for writing
        # whatever one likes into the operator's file.
        def _f(key: str, n: int = 24) -> str:
            return str(msg.get(key))[:n].replace("\n", " ")
        if msg.get("event"):
            # Once per stream or per seek, not once per five seconds —
            # and a seek nobody asked for looks exactly like a viewer
            # dragging the scrubber from this side, so it has to be
            # visible without turning DEBUG on.
            log.info(
                "stream: client %s target=%s t=%ss offset=%s ready=%s "
                "duration=%s ranges=[%s]",
                _f("event", 16), _f("target"), _f("t"), _f("offset"),
                _f("ready"), _f("duration"), _f("ranges", 120))
        # Debug: one line every five seconds per viewer. Run the daemon
        # with --log-level debug to see inside a player that is
        # misbehaving — it is the only view of the browser there is
        # when the browser is a phone.
        else:
            # `ahead` on its own cannot say whether a short buffer is
            # the player's own gate holding or the network failing to
            # keep up, and those two want opposite answers. `limit` is
            # what the gate is set to for this film and `budget` the
            # byte budget it was derived from, so the three read as one
            # sentence.
            log.debug(
                "stream: client t=%ss ahead=%ss/%ss budget=%sMB "
                "ready=%s paused=%s "
                "stalled=%s q=%s inflight=%s appending=%s updating=%s "
                "quota=%s ms=%s err=%s ranges=[%s] (sent=%d)",
                _f("t"), _f("ahead"), _f("limit"), _f("budgetMB"),
                _f("ready"), _f("paused"),
                _f("stalled"), _f("q"), _f("inflight"), _f("appending"),
                _f("updating"), _f("quota"), _f("ms"), _f("err", 80),
                _f("ranges", 120), self._stream_segments)

    def _do_stream_stop(self) -> None:
        age = (time.monotonic() - self._stream_started_at
               if self._stream_started_at else -1)
        log.info("stream: stop received %.1fs after start, %d segments sent",
                 age, self._stream_segments)
        self._stop_stream()