Files
Owlbot/owlbot/builtin_modules/clips/cache.py
T
LogalDeveloper 0ddffe5e09
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 20s
CI / Tests (Python 3.13) (push) Successful in 19s
CI / Tests (Python 3.14) (push) Successful in 16s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
Applied idiomatic Python improvements and micro-optimizations across registries and API layer.
2026-03-26 12:46:50 -04:00

413 lines
14 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 subsystem for the clips module.
Polls the Owncast HLS variant playlist, downloads new segments, and manages
a rolling window of cached chunks on disk.
"""
from __future__ import annotations
import asyncio
import contextlib
import shutil
import tempfile
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING
from urllib.parse import urljoin
import m3u8 # type: ignore[import-untyped]
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from owlbot.api import ModuleContext
from .types import get_state
_PLAYLIST_MAX_RETRIES = 10
_PLAYLIST_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, cache_dir: Path, cache_duration: int, min_clip_length: int
) -> None:
"""Initialize the chunk cache.
:param cache_dir: Directory to store cached .ts files.
:param cache_duration: Maximum cache window in seconds.
:param min_clip_length: Minimum clip duration in seconds.
"""
self._cache_dir = cache_dir
self._cache_duration = cache_duration
self._min_clip_length = min_clip_length
self._sequences: set[int] = set()
self._target_duration: float = 0.0
self._prune_guard_count: int = 0
def __len__(self) -> int:
"""Return the number of cached segments."""
return len(self._sequences)
@property
def total_duration(self) -> float:
"""Return total duration of cached chunks in seconds."""
return len(self._sequences) * self._target_duration
@property
def sequences(self) -> set[int]:
"""Return the set of cached sequence numbers."""
return self._sequences.copy()
def set_target_duration(self, duration: float) -> None:
"""Set the target segment duration from the HLS playlist.
:param duration: Target segment duration in seconds.
"""
self._target_duration = duration
def segment_path(self, sequence: int) -> Path:
"""Return the file path for a segment by sequence number.
:param sequence: HLS media sequence number.
:return: Path where the segment .ts file should be stored.
"""
return self._cache_dir / f"segment-{sequence}.ts"
def has_chunk(self, sequence: int) -> bool:
"""Check if a segment is in the cache.
:param sequence: HLS media sequence number.
:return: True if the segment is cached.
"""
return sequence in self._sequences
def add_chunk(self, sequence: int) -> None:
"""Add a segment to the cache.
:param sequence: HLS media sequence number.
"""
self._sequences.add(sequence)
def prune(self) -> bool:
"""Remove oldest segments until total duration is within the cache window.
:return: True if any segments were pruned, False otherwise.
"""
if self._prune_guard_count > 0:
return False
pruned = False
while self.total_duration > self._cache_duration and len(self._sequences) > 1:
oldest = min(self._sequences)
self._sequences.discard(oldest)
path = self.segment_path(oldest)
if path.exists():
path.unlink()
pruned = True
return pruned
@asynccontextmanager
async def suppress_pruning(self) -> AsyncIterator[None]:
"""Context manager that suppresses pruning while reading from the cache.
Use as:
async with cache.suppress_pruning():
# ffmpeg reads from cache here
Supports nested calls via reference counting.
"""
self._prune_guard_count += 1
try:
yield
finally:
self._prune_guard_count -= 1
@property
def enough_for_clipping(self) -> bool:
"""Check if cached data is sufficient for clipping.
Requires at least 2x the configured min clip length to ensure enough
buffered data.
:return: True if total cached duration >= 2 * min_clip_length.
"""
return self.total_duration >= self._min_clip_length * 2
def sorted_sequences(self) -> list[int]:
"""Return cached sequence numbers in ascending order.
:return: Sorted list of sequence numbers.
"""
return sorted(self._sequences)
def concat_protocol_string(self) -> str:
"""Build the ffmpeg concat protocol input string.
:return: Pipe-separated list of cached segment paths.
"""
return "|".join(str(self.segment_path(seq)) for seq in self.sorted_sequences())
def cleanup(self) -> None:
"""Remove the cache directory and all its contents."""
if self._cache_dir.exists():
shutil.rmtree(self._cache_dir)
self._sequences.clear()
async def start_caching(ctx: ModuleContext) -> None:
"""Start the HLS caching background task.
Creates a chunk cache and launches the background polling loop
that downloads new stream segments.
:param ctx: The module context.
"""
module_state = get_state(ctx)
if module_state.cache is not None:
ctx.logger.debug("Caching already active, skipping start.")
return
owncast_url = ctx.owncast_client.base_url
master_url = f"{owncast_url.rstrip('/')}/hls/stream.m3u8"
cache_duration = int(ctx.config.get("cache_duration"))
min_clip_length = int(ctx.config.get("min_clip_length"))
cache_dir_path = Path(tempfile.mkdtemp(prefix="owlbot-clips-"))
cache = ChunkCache(
cache_dir=cache_dir_path,
cache_duration=cache_duration,
min_clip_length=min_clip_length,
)
module_state.cache = cache
task = asyncio.create_task(
_polling_loop(ctx, cache, master_url),
)
module_state.cache_task = task
ctx.logger.info(f"HLS caching started. Cache dir: {cache_dir_path}")
async def stop_caching(ctx: ModuleContext) -> None:
"""Stop the HLS caching background task and clean up temp files.
:param ctx: The module context.
"""
module_state = get_state(ctx)
if module_state.cache_task is not None:
module_state.cache_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await module_state.cache_task
module_state.cache_task = None
if module_state.cache is not None:
module_state.cache.cleanup()
module_state.cache = None
ctx.logger.info("HLS cache stopped and cleaned up.")
async def _fetch_playlist(
ctx: ModuleContext,
url: str,
label: str,
) -> m3u8.M3U8 | None:
"""Fetch and parse an HLS playlist with retry logic.
Attempts up to `_PLAYLIST_MAX_RETRIES` fetches with a
`_PLAYLIST_RETRY_DELAY`-second backoff between failures.
Propagates `~asyncio.CancelledError` immediately.
:param ctx: The module context.
:param url: URL of the m3u8 playlist.
:param label: Human-readable label for log messages.
:return: Parsed playlist, or ``None`` if all attempts are exhausted.
"""
for attempt in range(1, _PLAYLIST_MAX_RETRIES + 1):
reason: str | None = None
exc_info = False
try:
async with ctx.http.get(url) as resp:
if resp.status != 200:
reason = f"HTTP {resp.status}"
else:
content = await resp.text()
return m3u8.loads(content)
except asyncio.CancelledError:
raise
except Exception as e:
reason = str(e)
exc_info = True
ctx.logger.warning(
f"{label} fetch failed (attempt {attempt}/{_PLAYLIST_MAX_RETRIES}): "
f"{reason}. Retrying in {_PLAYLIST_RETRY_DELAY:.0f}s.",
exc_info=exc_info,
)
await asyncio.sleep(_PLAYLIST_RETRY_DELAY)
ctx.logger.error(f"{label} failed after {_PLAYLIST_MAX_RETRIES} attempts.")
return None
async def _download_segment(
ctx: ModuleContext,
cache: ChunkCache,
segment_url: str,
seq: int,
) -> bool:
"""Download a single HLS segment and add it to the cache.
:param ctx: The module context.
:param cache: The ChunkCache to add the segment to.
:param segment_url: URL to download the .ts segment from.
:param seq: HLS media sequence number.
:return: True if the segment was downloaded successfully, False otherwise.
"""
try:
async with ctx.http.get(segment_url) as seg_resp:
if seg_resp.status != 200:
return False
data = await seg_resp.read()
except Exception:
ctx.logger.debug("Network error downloading segment %d.", seq, exc_info=True)
return False
chunk_path = cache.segment_path(seq)
await asyncio.to_thread(chunk_path.write_bytes, data)
cache.add_chunk(seq)
ctx.logger.debug("Cached segment %d (%d bytes).", seq, len(data))
return True
async def _polling_loop(
ctx: ModuleContext,
cache: ChunkCache,
master_url: str,
) -> None:
"""Background task that polls the HLS variant playlist for new segments.
:param ctx: The module context.
:param cache: The ChunkCache instance to populate.
:param master_url: URL to the master m3u8 playlist.
"""
master_playlist = await _fetch_playlist(ctx, master_url, "Master playlist")
if master_playlist is None or not master_playlist.playlists:
ctx.logger.error("Could not fetch master playlist. Caching disabled.")
module_state = get_state(ctx)
cache.cleanup()
module_state.cache = None
module_state.cache_task = None
return
best = max(
master_playlist.playlists,
key=lambda p: p.stream_info.bandwidth or 0,
)
variant_url = urljoin(master_url, best.uri)
ctx.logger.debug(
f"Selected variant: {variant_url} "
f"(bandwidth={best.stream_info.bandwidth}, "
f"resolution={best.stream_info.resolution})"
)
variant_playlist = await _fetch_playlist(ctx, variant_url, "Variant playlist")
if variant_playlist is None:
ctx.logger.error("Could not fetch variant playlist. Caching disabled.")
module_state = get_state(ctx)
cache.cleanup()
module_state.cache = None
module_state.cache_task = None
return
poll_interval = (
float(variant_playlist.target_duration)
if variant_playlist.target_duration
else 2.0
)
cache.set_target_duration(poll_interval)
ctx.logger.debug("Initial poll interval set to %.1fs.", poll_interval)
pending_retries: set[int] = set()
while True:
try:
async with ctx.http.get(variant_url) as resp:
if resp.status != 200:
ctx.logger.warning(f"Variant playlist returned HTTP {resp.status}.")
await asyncio.sleep(poll_interval)
continue
content = await resp.text()
playlist = m3u8.loads(content)
media_sequence = playlist.media_sequence or 0
playlist_sequences: dict[int, str] = {}
for i, segment in enumerate(playlist.segments):
playlist_sequences[media_sequence + i] = segment.uri
abandoned: set[int] = set()
for seq in list(pending_retries):
if seq not in playlist_sequences:
abandoned.add(seq)
continue
segment_url = urljoin(variant_url, playlist_sequences[seq])
if await _download_segment(ctx, cache, segment_url, seq):
pending_retries.discard(seq)
ctx.logger.info(f"Segment {seq} recovered after retry.")
for seq in abandoned:
pending_retries.discard(seq)
ctx.logger.warning(f"Segment {seq} no longer in playlist, giving up.")
for i, segment in enumerate(playlist.segments):
seq = media_sequence + i
if cache.has_chunk(seq):
continue
if seq in pending_retries:
continue
segment_url = urljoin(variant_url, segment.uri)
if not await _download_segment(ctx, cache, segment_url, seq):
pending_retries.add(seq)
ctx.logger.warning(
f"Segment {seq} download failed, queued for retry."
)
pre_prune_count = len(cache)
pruned = cache.prune()
if pruned:
ctx.logger.debug(
f"Pruned {pre_prune_count - len(cache)} segment(s). "
f"Cache: {len(cache)} chunks, "
f"{cache.total_duration:.1f}s total."
)
except asyncio.CancelledError:
raise
except Exception:
ctx.logger.error("Error in HLS polling loop.", exc_info=True)
await asyncio.sleep(poll_interval)