Files
Owlbot/owlbot/builtin_modules/clips/cache.py
T
LogalDeveloper 0ff3c7a6b4
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 14s
CI / Tests (Python 3.13) (push) Successful in 14s
CI / Tests (Python 3.14) (push) Successful in 11s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
Enabled all Ruff lint rules and resolved findings with justified inline suppressions.
2026-04-13 15:31:06 -04:00

438 lines
16 KiB
Python

# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""HLS stream caching collaborator for the clips module.
Polls the Owncast HLS variant playlist, downloads new segments, and manages
a rolling window of cached chunks on disk. Has no dependency on the Owlbot
module framework; all external resources are passed in explicitly.
"""
from __future__ import annotations
import asyncio
import contextlib
import re
import shutil
import tempfile
from contextlib import asynccontextmanager
from http import HTTPStatus
from pathlib import Path
from typing import TYPE_CHECKING
from urllib.parse import urljoin
import aiohttp
import m3u8 # type: ignore[import-untyped]
if TYPE_CHECKING:
import logging
from collections.abc import AsyncIterator
_SEGMENT_URI_RE = re.compile(r"stream-([A-Za-z0-9_-]+)-(\d+)\.(ts|m4s)$")
_MAX_RETRIES = 10
_RETRY_DELAY = 3.0
class ChunkCache:
"""Manages a rolling window of cached HLS stream segments.
Uses a set to track downloaded sequence numbers. All segments in a variant
playlist share the same target duration, so total cache duration is
derived from the set size and the target duration.
"""
def __init__(
self,
http: aiohttp.ClientSession,
cache_dir: Path,
cache_duration: int,
segment_id: str,
extension: str,
target_duration: float,
variant_url: str,
logger: logging.Logger,
init_path: Path | None = None,
) -> None:
"""Initialize the chunk cache.
:param http: HTTP client session for polling.
:param cache_dir: Directory to store cached segment files.
:param cache_duration: Maximum cache window in seconds.
:param segment_id: Unique stream segment identifier (e.g. ``jdofFGg``).
:param extension: Segment file extension without dot (e.g. ``ts`` or ``m4s``).
:param target_duration: HLS target segment duration in seconds.
:param variant_url: URL to the variant m3u8 playlist.
:param logger: Logger instance.
:param init_path: Path to fMP4 initialization segment, if applicable.
"""
self._http = http
self._cache_dir = cache_dir
self._cache_duration = cache_duration
self._segment_id = segment_id
self._extension = extension
self._target_duration = target_duration
self._variant_url = variant_url
self._logger = logger
self._init_path = init_path
self._sequences: set[int] = set()
self._prune_guard_count: int = 0
self._task: asyncio.Task[None] | None = None
@property
def buffered_duration(self) -> float:
"""Return total duration of cached chunks in seconds."""
return len(self._sequences) * self._target_duration
def _segment_path(self, sequence: int) -> Path:
"""Return the file path for a segment by sequence number."""
return (
self._cache_dir / f"stream-{self._segment_id}-{sequence}.{self._extension}"
)
@asynccontextmanager
async def suppress_pruning(self) -> AsyncIterator[None]:
"""Context manager that suppresses pruning while reading from the cache.
Supports nested calls via reference counting.
"""
self._prune_guard_count += 1
try:
yield
finally:
self._prune_guard_count -= 1
def concat_protocol_string(self) -> str:
"""Build the ffmpeg concat protocol input string.
For fMP4 streams, the initialization segment is prepended.
:return: Pipe-separated list of cached segment paths.
"""
parts: list[str] = []
if self._init_path is not None:
parts.append(str(self._init_path))
parts.extend(str(self._segment_path(seq)) for seq in sorted(self._sequences))
return "|".join(parts)
def start(self) -> None:
"""Start the background polling task."""
if self._task is not None and not self._task.done():
return
self._task = asyncio.create_task(
self._polling_loop(), name="Clips Module - HLS polling loop"
)
async def stop(self) -> None:
"""Cancel the internal polling task and clean up cached files."""
if self._task is not None:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
await asyncio.to_thread(shutil.rmtree, self._cache_dir, ignore_errors=True)
self._sequences.clear()
async def _download_segment(self, segment_url: str, seq: int) -> bool:
"""Download a single HLS segment and add it to the cache.
:param segment_url: URL to download the segment from.
:param seq: HLS media sequence number.
:return: True if the segment was downloaded successfully, False otherwise.
"""
try:
async with self._http.get(segment_url) as seg_resp:
if seg_resp.status != HTTPStatus.OK:
return False
data = await seg_resp.read()
except asyncio.CancelledError:
raise
except aiohttp.ClientError:
self._logger.debug(
"Network error downloading segment %d.", seq, exc_info=True
)
return False
chunk_path = self._segment_path(seq)
await asyncio.to_thread(chunk_path.write_bytes, data)
self._sequences.add(seq)
self._logger.debug("Cached segment %d (%d bytes).", seq, len(data))
return True
async def _polling_loop(self) -> None:
"""Background task that polls the HLS variant playlist for new segments."""
pending_retries: set[int] = set()
while True:
try:
async with self._http.get(self._variant_url) as resp:
if resp.status != HTTPStatus.OK:
self._logger.warning(
"Variant playlist returned HTTP %d.", resp.status
)
await asyncio.sleep(self._target_duration)
continue
content = await resp.text()
playlist = m3u8.loads(content)
playlist_segments: dict[int, str] = {}
for segment in playlist.segments:
seg_match = _SEGMENT_URI_RE.search(segment.uri)
if seg_match is None:
continue
playlist_segments[int(seg_match.group(2))] = segment.uri
abandoned: set[int] = set()
for seq in list(pending_retries):
if seq not in playlist_segments:
abandoned.add(seq)
continue
segment_url = urljoin(self._variant_url, playlist_segments[seq])
if await self._download_segment(segment_url, seq):
pending_retries.discard(seq)
self._logger.info("Segment %d recovered after retry.", seq)
for seq in abandoned:
pending_retries.discard(seq)
self._logger.warning(
"Segment %d no longer in playlist, giving up.", seq
)
for seq, uri in playlist_segments.items():
if seq in self._sequences:
continue
if seq in pending_retries:
continue
segment_url = urljoin(self._variant_url, uri)
if not await self._download_segment(segment_url, seq):
pending_retries.add(seq)
self._logger.warning(
"Segment %d download failed, queued for retry.", seq
)
if self._prune_guard_count == 0:
pre_prune = len(self._sequences)
while (
self.buffered_duration > self._cache_duration
and len(self._sequences) > 1
):
oldest = min(self._sequences)
self._sequences.discard(oldest)
path = self._segment_path(oldest)
await asyncio.to_thread(path.unlink, missing_ok=True)
if len(self._sequences) < pre_prune:
self._logger.debug(
"Pruned %d segment(s). Cache: %d chunks, %.1fs total.",
pre_prune - len(self._sequences),
len(self._sequences),
self.buffered_duration,
)
except asyncio.CancelledError:
raise
except Exception:
self._logger.exception("Error in HLS polling loop.")
await asyncio.sleep(self._target_duration)
async def start_caching(
http: aiohttp.ClientSession,
base_url: str,
cache_duration: int,
logger: logging.Logger,
) -> ChunkCache:
"""Start HLS caching and return the running ChunkCache.
Fetches the master and variant playlists, constructs the cache, downloads
the init segment if needed, and launches the background polling task.
:param http: Shared HTTP client session.
:param base_url: Base Owncast URL (e.g. ``http://localhost:8080``).
:param cache_duration: Maximum cache window in seconds.
:param logger: Logger instance.
:return: A running ChunkCache with its background polling task active.
:raises RuntimeError: If initial playlist fetching or setup fails.
"""
master_url = f"{base_url.rstrip('/')}/hls/stream.m3u8"
cache_dir = Path(tempfile.mkdtemp(prefix="owlbot-clips-"))
try:
master_playlist = await _fetch_playlist(
http, master_url, "Master playlist", logger
)
if master_playlist is None or not master_playlist.playlists:
msg = "Could not fetch master playlist."
raise RuntimeError(msg)
best = max(
master_playlist.playlists,
key=lambda p: p.stream_info.bandwidth or 0,
)
variant_url = urljoin(master_url, best.uri)
logger.debug(
"Selected variant: %s (bandwidth=%s, resolution=%s)",
variant_url,
best.stream_info.bandwidth,
best.stream_info.resolution,
)
variant_playlist = await _fetch_playlist(
http, variant_url, "Variant playlist", logger
)
if variant_playlist is None or not variant_playlist.segments:
msg = "Could not fetch variant playlist."
raise RuntimeError(msg)
first_match = _SEGMENT_URI_RE.search(variant_playlist.segments[0].uri)
if first_match is None:
msg = "Could not parse segment URI."
raise RuntimeError(msg)
segment_id, extension = first_match.group(1), first_match.group(3)
poll_interval = (
float(variant_playlist.target_duration)
if variant_playlist.target_duration
else 2.0
)
logger.debug("Initial poll interval set to %.1fs.", poll_interval)
init_path: Path | None = None
if variant_playlist.segment_map:
init_uri = variant_playlist.segment_map[0].uri
if init_uri:
init_url = urljoin(variant_url, init_uri)
init_filename = init_uri.rsplit("/", 1)[-1]
init_path = await _download_init_segment(
http, cache_dir, init_url, init_filename, logger
)
if init_path is None:
msg = "Could not download init segment."
raise RuntimeError(msg)
cache = ChunkCache(
http=http,
cache_dir=cache_dir,
cache_duration=cache_duration,
segment_id=segment_id,
extension=extension,
target_duration=poll_interval,
variant_url=variant_url,
logger=logger,
init_path=init_path,
)
cache.start()
logger.info("HLS caching started. Cache dir: %s", cache_dir)
except BaseException:
shutil.rmtree(cache_dir, ignore_errors=True)
raise
else:
return cache
async def _fetch_playlist(
http: aiohttp.ClientSession,
url: str,
label: str,
logger: logging.Logger,
) -> m3u8.M3U8 | None:
"""Fetch and parse an HLS playlist with retry logic.
:param http: HTTP client session.
:param url: URL of the m3u8 playlist.
:param label: Human-readable label for log messages.
:param logger: Logger instance.
:return: Parsed playlist, or ``None`` if all attempts are exhausted.
"""
for attempt in range(1, _MAX_RETRIES + 1):
reason: str | None = None
exc_info = False
try:
async with http.get(url) as resp:
if resp.status != HTTPStatus.OK:
reason = f"HTTP {resp.status}"
else:
content = await resp.text()
return m3u8.loads(content)
except asyncio.CancelledError:
raise
except aiohttp.ClientError as e:
reason = str(e)
exc_info = True
logger.warning(
"%s fetch failed (attempt %d/%d): %s. Retrying in %.0fs.",
label,
attempt,
_MAX_RETRIES,
reason,
_RETRY_DELAY,
exc_info=exc_info,
)
await asyncio.sleep(_RETRY_DELAY)
logger.error("%s failed after %d attempts.", label, _MAX_RETRIES)
return None
async def _download_init_segment(
http: aiohttp.ClientSession,
cache_dir: Path,
init_url: str,
filename: str,
logger: logging.Logger,
) -> Path | None:
"""Download the fMP4 initialization segment with retry logic.
:param http: HTTP client session.
:param cache_dir: Directory to save the init segment into.
:param init_url: URL to download the init segment from.
:param filename: Original filename to save as.
:param logger: Logger instance.
:return: Path to the downloaded init segment, or None on failure.
"""
for attempt in range(1, _MAX_RETRIES + 1):
reason: str | None = None
exc_info = False
try:
async with http.get(init_url) as resp:
if resp.status != HTTPStatus.OK:
reason = f"HTTP {resp.status}"
else:
data = await resp.read()
init_path = cache_dir / filename
await asyncio.to_thread(init_path.write_bytes, data)
logger.debug("Cached init segment (%d bytes).", len(data))
return init_path
except asyncio.CancelledError:
raise
except aiohttp.ClientError as e:
reason = str(e)
exc_info = True
logger.warning(
"Init segment fetch failed (attempt %d/%d): %s. Retrying in %.0fs.",
attempt,
_MAX_RETRIES,
reason,
_RETRY_DELAY,
exc_info=exc_info,
)
await asyncio.sleep(_RETRY_DELAY)
logger.error("Init segment failed after %d attempts.", _MAX_RETRIES)
return None