aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport/webrtc
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-24 01:44:17 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-24 16:45:37 +0200
commit7216c1779a799652a8fb4d9301bbf05952b33661 (patch)
tree8644234c0966e9f78d1ed9519bf3cad1395c7b63 /packages/meshbay-node/src/meshbay_node/transport/webrtc
parent15e7084bad34edb60a5999994731a8a2ed1dc6c8 (diff)
downloadmeshbay-7216c1779a799652a8fb4d9301bbf05952b33661.tar.gz
refactor(node): move the subtitle handler out of webrtc_server
SubtitlesMixin in transport/webrtc/apps/subtitles.py; _locate, which it shares with the files, music and streaming handlers, in webrtc/disk.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/webrtc')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/__init__.py1
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/subtitles.py170
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/disk.py23
3 files changed, 194 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/__init__.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/__init__.py
new file mode 100644
index 0000000..a2e212a
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/__init__.py
@@ -0,0 +1 @@
+"""What the node does for one app only: Videos, Music."""
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/subtitles.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/subtitles.py
new file mode 100644
index 0000000..bd2ab52
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/subtitles.py
@@ -0,0 +1,170 @@
+"""Subtitle tracks for the Videos app: extracted once, whole-file, to WebVTT."""
+
+import asyncio
+import logging
+import time
+
+import blake3
+from meshbay_common import MNP_VERSION
+from meshbay_common.protocol import MNP
+
+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 (
+ _extract_subtitle_to_webvtt,
+ _subtitle_timeout_for,
+)
+
+log = logging.getLogger("meshbay_node.transport.webrtc_server")
+
+
+class SubtitlesMixin:
+ async def _do_subtitle_request(self, msg: dict) -> None:
+ """
+ One embedded subtitle track, extracted whole-file to WebVTT and served
+ back through the ordinary file_req/chunk path — the same indirection
+ as `_do_audio_transcode_request`, and cached the same way, so a
+ film's subtitles are extracted once in the life of the file rather
+ than once per viewing.
+
+ The ordinal is validated against `probe_video`'s *filtered* list and
+ then used as the ffmpeg `-map 0:s:<n>` argument, which is only correct
+ because `SubtitleTrack.ordinal` counts every subtitle stream including
+ the bitmap ones the list omits (see media_probe.py). Checking
+ membership rather than range is what makes that hold: a bitmap
+ ordinal is in range and is not in the list, and extracting it would
+ produce an empty WebVTT — a subtitle track with no subtitles in it,
+ which reports no error anywhere.
+ """
+ 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
+
+ media_cache = self._ctx.get("media_cache")
+ if media_cache is None:
+ self._send({"type": "error", "detail": "Subtitles unavailable"})
+ return
+
+ try:
+ ordinal = int(msg.get("track", 0) or 0)
+ except (TypeError, ValueError):
+ ordinal = -1
+
+ # Logged before the cache is consulted, and that ordering is the point:
+ # a cached track used to answer without leaving a line, so the journal
+ # could not say whether a viewer had asked for subtitles at all. That
+ # turned "no request in the log" into evidence it was never entitled
+ # to be — the second time in this feature that a silent success was
+ # read as an absence.
+ log.info("subtitle: req file=%s track=%d size=%.1fMB slots_free=%s",
+ file_id[:12], ordinal, entry.size / 1e6,
+ getattr(self._ctx.get("_transcode_sem"), "_value", "?"))
+ t0 = time.monotonic()
+
+ synthetic_id = f"subtitle:{entry.id}:{ordinal}"
+ cached_hash = await media_cache.get_thumb_hash_by_file_id(synthetic_id)
+ if cached_hash is not None:
+ blob = await media_cache.get_thumb(cached_hash)
+ if blob is not None:
+ log.info("subtitle: served file=%s track=%d from cache, %d bytes",
+ file_id[:12], ordinal, len(blob))
+ self._send({"type": MNP.SUBTITLE_RESP, "v": MNP_VERSION,
+ "file_id": file_id, "track": ordinal,
+ "hash": cached_hash, "size": len(blob),
+ "mime": "text/vtt"})
+ return
+ # Cached hash but the blob was pruned: fall through and extract
+ # again, same as a cold cache.
+
+ inflight = self._ctx.setdefault("_subtitle_inflight", {})
+ running = inflight.get(synthetic_id)
+ if running is not None:
+ log.info("subtitle: file=%s track=%d already extracting, waiting",
+ file_id[:12], ordinal)
+ # Shielded: this waiter being cancelled must not cancel the
+ # extraction every other waiter is also relying on.
+ answer = await asyncio.shield(running)
+ if answer is None:
+ self._send({"type": "error", "detail": "Subtitle extraction failed"})
+ return
+ subtitle_hash, size = answer
+ log.info("subtitle: served file=%s track=%d from a shared extraction, %d bytes",
+ file_id[:12], ordinal, size)
+ self._send({"type": MNP.SUBTITLE_RESP, "v": MNP_VERSION,
+ "file_id": file_id, "track": ordinal,
+ "hash": subtitle_hash, "size": size, "mime": "text/vtt"})
+ return
+
+ # One extraction per file and track at a time, across every viewer.
+ # The cache is only consulted on the way in, so two clicks seconds
+ # apart both missed it and both ran — seen in the log as two identical
+ # extractions of one 4.3 GB file overlapping, each holding a transcode
+ # slot and reading the whole file. Latecomers wait for the answer the
+ # first one is already producing.
+ #
+ # **Registered before the first `await` below**, never after. The
+ # first version registered it after the probe, and two requests
+ # arriving together both got past the check above while neither had
+ # registered yet — so both extracted, which is the race this exists to
+ # close. The probe and the slot check sit behind it for that reason.
+ fut = asyncio.get_running_loop().create_future()
+ inflight[synthetic_id] = fut
+ try:
+ probe = await _probe_video(str(file_path))
+ if not any(tr.ordinal == ordinal for tr in probe.subtitle_tracks):
+ # Not a range check — see this method's docstring.
+ log.warning("subtitle: file=%s has no text track %d",
+ file_id[:12], ordinal)
+ self._send({"type": "error", "detail": "No such subtitle track"})
+ return
+
+ sem = self._transcode_semaphore()
+ if sem.locked() and sem._value <= 0:
+ log.info("subtitle: refused, no transcode slot free")
+ self._send({"type": "error", "detail": "Server busy, retry shortly"})
+ return
+
+ budget = _subtitle_timeout_for(entry.size)
+ async with sem:
+ log.info("subtitle: extracting file=%s track=%d (slot taken, up to %.0fs)",
+ file_id[:12], ordinal, budget)
+ blob = await _extract_subtitle_to_webvtt(file_path, ordinal, budget)
+
+ subtitle_hash = blake3.blake3(blob).hexdigest()
+ await media_cache.put_thumb(subtitle_hash, synthetic_id, blob)
+ self._audit("subtitle_extract", f"{entry.name} [{ordinal}]")
+ log.info("subtitle: extracted file=%s track=%d in %.1fs, %d bytes",
+ file_id[:12], ordinal, time.monotonic() - t0, len(blob))
+ fut.set_result((subtitle_hash, len(blob)))
+ self._send({"type": MNP.SUBTITLE_RESP, "v": MNP_VERSION,
+ "file_id": file_id, "track": ordinal,
+ "hash": subtitle_hash, "size": len(blob),
+ "mime": "text/vtt"})
+ # BaseException, not Exception: a cancelled task — the peer went away,
+ # the session is being torn down — raises CancelledError, which is not
+ # an Exception and would otherwise leave this handler with no reply
+ # sent and no line in the log. The client is then waiting on something
+ # nothing will ever answer, which is the shape nobody can report.
+ except BaseException as e:
+ log.warning("subtitle: extract failed file=%s track=%d after %.1fs: %r",
+ file_id[:12], ordinal, time.monotonic() - t0, e)
+ self._send({"type": "error",
+ "detail": f"Subtitle extraction failed: {e}"})
+ if isinstance(e, asyncio.CancelledError):
+ raise
+ finally:
+ # A result either way, never an exception: a future nobody is
+ # waiting on yet would be an unretrieved-exception warning, and a
+ # cancelled task would otherwise leave every latecomer waiting on
+ # a future that is never resolved at all.
+ if not fut.done():
+ fut.set_result(None)
+ inflight.pop(synthetic_id, None)
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/disk.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/disk.py
new file mode 100644
index 0000000..fd3184c
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/disk.py
@@ -0,0 +1,23 @@
+"""Blocking disk work the session hands to `off_disk`."""
+
+from pathlib import Path
+
+from meshbay_node.roots import ROOT_NOT_SERVED, RootSet, entry_abs_path
+
+
+def _locate(roots: RootSet, entry) -> tuple[Path | None, str | None]:
+ """
+ Where an entry is, and whether it is readable — or the refusal to send.
+
+ Both halves are syscalls: `resolve()` walks the path and `exists()` stats
+ it, and a stat is what *wakes* a sleeping disk. Leaving either on the event
+ loop and offloading only the read would move the stall rather than remove
+ it, and the read would then find the disk already awake. Blocking; called
+ through `off_disk`.
+ """
+ path = entry_abs_path(roots, entry)
+ if path is None:
+ return None, ROOT_NOT_SERVED
+ if not path.exists():
+ return None, "File not on disk"
+ return path, None