Refactored clips module into layered architecture.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 6s
CI / Tests (Python 3.12) (push) Successful in 15s
CI / Tests (Python 3.13) (push) Successful in 18s
CI / Tests (Python 3.14) (push) Successful in 12s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-04-13 13:11:20 -04:00
parent 5e712d2059
commit d3e3460ce8
10 changed files with 1142 additions and 909 deletions
+16 -94
View File
@@ -20,27 +20,17 @@ stream in real time and provides a browser-based clip editor.
from __future__ import annotations
import asyncio
import contextlib
import shutil
from pathlib import Path
from owlbot.api import (
EventContext,
EventType,
ModuleContext,
StreamStartedEvent,
StreamStoppedEvent,
on_event,
on_setup,
on_teardown,
)
from owlbot.api import ModuleContext, on_setup, on_teardown
from .cache import start_caching, stop_caching
from .commands import clip_command, clips_command, delclip_command
from .processing import ProcessingManager
from .events import on_stream_started, on_stream_stopped
from .manager import ClipManager, get_manager
from .processing import VideoProcessor
from .repository import ClipRepository
from .routes import (
cleanup_session,
clip_page,
clip_thumbnail,
clip_video,
@@ -50,7 +40,6 @@ from .routes import (
editor_preview_video,
editor_submit,
)
from .types import ModuleState, get_state
__all__ = [
"clip_command",
@@ -89,16 +78,6 @@ async def setup(ctx: ModuleContext) -> None:
)
raise RuntimeError(msg)
await ctx.storage.execute("""
CREATE TABLE IF NOT EXISTS clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
creator TEXT NOT NULL,
created_at TEXT NOT NULL,
duration REAL
)
""")
ctx.config.register_defaults(
{
"cache_duration": 300,
@@ -110,92 +89,35 @@ async def setup(ctx: ModuleContext) -> None:
}
)
# Create clips directory if it doesn't exist.
repo = ClipRepository(ctx.storage)
await repo.setup()
processor = VideoProcessor(ctx.logger)
clips_dir = Path(str(ctx.config.get("clips_dir")))
clips_dir.mkdir(parents=True, exist_ok=True) # noqa: ASYNC240
# Initialize runtime state.
ctx.state["clips"] = ModuleState()
module_state = get_state(ctx)
module_state.manager = ProcessingManager(ctx.logger)
manager = ClipManager(ctx, repo, processor, clips_dir)
ctx.state["manager"] = manager
# Check if stream is already live.
try:
status = await ctx.owncast_client.get_status()
if status.get("online", False):
ctx.logger.info("Stream is already live. Starting HLS cache.")
await start_caching(ctx)
await manager.start_caching()
except Exception:
ctx.logger.warning(
"Could not check Owncast status during setup.", exc_info=True
)
@on_event(EventType.STREAM_STARTED)
async def on_stream_started(ctx: EventContext[StreamStartedEvent]) -> None:
"""Start HLS caching when the stream goes live.
:param ctx: The event context.
"""
# If there's a grace period task running, cancel it and wait for it to finish.
module_state = get_state(ctx.module)
if module_state.grace_task is not None:
ctx.module.logger.debug("Cancelling active grace period task.")
module_state.grace_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await module_state.grace_task
module_state.grace_task = None
# Wipe old cache before starting fresh.
await stop_caching(ctx.module)
await start_caching(ctx.module)
@on_event(EventType.STREAM_STOPPED)
async def on_stream_stopped(ctx: EventContext[StreamStoppedEvent]) -> None:
"""Begin grace period when the stream goes offline.
:param ctx: The event context.
"""
async def _grace_period(module_ctx: ModuleContext) -> None:
grace = int(module_ctx.config.get("grace_period"))
module_ctx.logger.info(f"Stream stopped. Grace period: {grace}s.")
await asyncio.sleep(grace)
await stop_caching(module_ctx)
module_ctx.logger.info("Grace period ended. Cache cleaned up.")
ctx.module.logger.debug("Stream stopped event received. Scheduling grace period.")
get_state(ctx.module).grace_task = asyncio.create_task(_grace_period(ctx.module))
@on_teardown
async def teardown(ctx: ModuleContext) -> None:
"""Clean up the clips module.
Cancels grace/expiry tasks and cleans up temp files.
:param ctx: Module context.
"""
module_state = get_state(ctx)
# 1. Cancel grace period task if running.
if module_state.grace_task is not None:
ctx.logger.debug("Cancelling grace period task during teardown.")
module_state.grace_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await module_state.grace_task
# 2. Stop caching.
await stop_caching(ctx)
# 3. Cancel all session expiry tasks (prevents races during drain).
for session in module_state.sessions.values():
if session.expiry_task is not None:
session.expiry_task.cancel()
# 4. Clean up editor sessions and their working directories.
for token in list(module_state.sessions):
cleanup_session(module_state.sessions, token)
ctx.logger.info("Clips module cleaned up.")
manager = get_manager(ctx)
await manager.teardown()
ctx.state["manager"] = None
+268 -365
View File
@@ -12,10 +12,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""HLS stream caching subsystem for the clips module.
"""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.
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
@@ -30,15 +31,13 @@ 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
from owlbot.api import ModuleContext
from .types import get_state
_SEGMENT_URI_RE = re.compile(r"stream-([A-Za-z0-9]+)-(\d+)\.(ts|m4s)$")
_MAX_RETRIES = 10
@@ -55,113 +54,56 @@ class ChunkCache:
def __init__(
self,
http: aiohttp.ClientSession,
cache_dir: Path,
cache_duration: int,
min_clip_length: 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 min_clip_length: Minimum clip duration 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._min_clip_length = min_clip_length
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._target_duration: float = 0.0
self._prune_guard_count: int = 0
self._init_path: Path | None = None
def __len__(self) -> int:
"""Return the number of cached segments."""
return len(self._sequences)
self._task: asyncio.Task[None] | None = None
@property
def total_duration(self) -> float:
def buffered_duration(self) -> float:
"""Return total duration of cached chunks in seconds."""
return len(self._sequences) * self._target_duration
@property
def cache_dir(self) -> Path:
"""Return the cache directory path."""
return self._cache_dir
@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 set_init_path(self, path: Path) -> None:
"""Set the path to the fMP4 initialization segment.
:param path: Path to the downloaded init segment.
"""
self._init_path = path
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 file should be stored.
"""
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}"
)
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
@@ -170,24 +112,6 @@ class ChunkCache:
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.
@@ -198,276 +122,66 @@ class ChunkCache:
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 self.sorted_sequences())
parts.extend(str(self._segment_path(seq)) for seq in sorted(self._sequences))
return "|".join(parts)
def cleanup(self) -> None:
"""Remove the cache directory and all its contents."""
if self._cache_dir.exists():
shutil.rmtree(self._cache_dir)
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, True)
self._sequences.clear()
async def start_caching(ctx: ModuleContext) -> None:
"""Start the HLS caching background task.
Creates a cache directory 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-"))
task = asyncio.create_task(
_polling_loop(ctx, master_url, cache_dir_path, cache_duration, min_clip_length),
)
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 `_MAX_RETRIES` fetches with a
`_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, _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}/{_MAX_RETRIES}): "
f"{reason}. Retrying in {_RETRY_DELAY:.0f}s.",
exc_info=exc_info,
)
await asyncio.sleep(_RETRY_DELAY)
ctx.logger.error(f"{label} failed after {_MAX_RETRIES} attempts.")
return None
async def _download_segment(
ctx: ModuleContext,
cache: ChunkCache,
segment_url: str,
seq: int,
) -> bool:
async def _download_segment(self, 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 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 ctx.http.get(segment_url) as seg_resp:
async with self._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 _download_init_segment(
ctx: ModuleContext,
cache: ChunkCache,
init_url: str,
filename: str,
) -> bool:
"""Download the fMP4 initialization segment with retry logic.
Attempts up to `_MAX_RETRIES` downloads with a
`_RETRY_DELAY`-second backoff between failures.
Propagates `~asyncio.CancelledError` immediately.
:param ctx: The module context.
:param cache: The ChunkCache to set the init path on.
:param init_url: URL to download the init segment from.
:param filename: Original filename to save as.
:return: True if downloaded successfully, False otherwise.
"""
for attempt in range(1, _MAX_RETRIES + 1):
reason: str | None = None
exc_info = False
try:
async with ctx.http.get(init_url) as resp:
if resp.status != 200:
reason = f"HTTP {resp.status}"
else:
data = await resp.read()
init_path = cache.cache_dir / filename
await asyncio.to_thread(init_path.write_bytes, data)
cache.set_init_path(init_path)
ctx.logger.debug("Cached init segment (%d bytes).", len(data))
return True
except asyncio.CancelledError:
raise
except Exception as e:
reason = str(e)
exc_info = True
ctx.logger.warning(
f"Init segment fetch failed (attempt {attempt}/{_MAX_RETRIES}): "
f"{reason}. Retrying in {_RETRY_DELAY:.0f}s.",
exc_info=exc_info,
except aiohttp.ClientError:
self._logger.debug(
"Network error downloading segment %d.", seq, exc_info=True
)
await asyncio.sleep(_RETRY_DELAY)
ctx.logger.error(f"Init segment failed after {_MAX_RETRIES} attempts.")
return False
chunk_path = self._segment_path(seq)
await asyncio.to_thread(chunk_path.write_bytes, data)
async def _polling_loop(
ctx: ModuleContext,
master_url: str,
cache_dir: Path,
cache_duration: int,
min_clip_length: int,
) -> None:
"""Background task that polls the HLS variant playlist for new segments.
Constructs the :class:`ChunkCache` after the first successful playlist
fetch, since the segment identifier and extension are not known until then.
:param ctx: The module context.
:param master_url: URL to the master m3u8 playlist.
:param cache_dir: Directory for cached segment files.
:param cache_duration: Maximum cache window in seconds.
:param min_clip_length: Minimum clip duration in seconds.
"""
module_state = get_state(ctx)
try:
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.")
shutil.rmtree(cache_dir, ignore_errors=True)
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 or not variant_playlist.segments:
ctx.logger.error("Could not fetch variant playlist. Caching disabled.")
shutil.rmtree(cache_dir, ignore_errors=True)
module_state.cache_task = None
return
first_match = _SEGMENT_URI_RE.search(variant_playlist.segments[0].uri)
if first_match is None:
ctx.logger.error("Could not parse segment URI. Caching disabled.")
shutil.rmtree(cache_dir, ignore_errors=True)
module_state.cache_task = None
return
segment_id, extension = first_match.group(1), first_match.group(3)
cache = ChunkCache(
cache_dir=cache_dir,
cache_duration=cache_duration,
min_clip_length=min_clip_length,
segment_id=segment_id,
extension=extension,
)
module_state.cache = cache
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)
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]
if not await _download_init_segment(
ctx, cache, init_url, init_filename
):
ctx.logger.error(
"Could not download init segment. Caching disabled."
)
cache.cleanup()
module_state.cache = None
module_state.cache_task = None
return
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 ctx.http.get(variant_url) as resp:
async with self._http.get(self._variant_url) as resp:
if resp.status != 200:
ctx.logger.warning(
f"Variant playlist returned HTTP {resp.status}."
self._logger.warning(
"Variant playlist returned HTTP %d.", resp.status
)
await asyncio.sleep(poll_interval)
await asyncio.sleep(self._target_duration)
continue
content = await resp.text()
@@ -485,48 +199,237 @@ async def _polling_loop(
if seq not in playlist_segments:
abandoned.add(seq)
continue
segment_url = urljoin(variant_url, playlist_segments[seq])
if await _download_segment(ctx, cache, segment_url, seq):
segment_url = urljoin(self._variant_url, playlist_segments[seq])
if await self._download_segment(segment_url, seq):
pending_retries.discard(seq)
ctx.logger.info(f"Segment {seq} recovered after retry.")
self._logger.info("Segment %d recovered after retry.", seq)
for seq in abandoned:
pending_retries.discard(seq)
ctx.logger.warning(
f"Segment {seq} no longer in playlist, giving up."
self._logger.warning(
"Segment %d no longer in playlist, giving up.", seq
)
for seq, uri in playlist_segments.items():
if cache.has_chunk(seq):
if seq in self._sequences:
continue
if seq in pending_retries:
continue
segment_url = urljoin(variant_url, uri)
if not await _download_segment(ctx, cache, segment_url, seq):
segment_url = urljoin(self._variant_url, uri)
if not await self._download_segment(segment_url, seq):
pending_retries.add(seq)
ctx.logger.warning(
f"Segment {seq} download failed, queued for retry."
self._logger.warning(
"Segment %d download failed, queued for retry.", seq
)
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."
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, 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:
ctx.logger.error("Error in HLS polling loop.", exc_info=True)
self._logger.error("Error in HLS polling loop.", exc_info=True)
await asyncio.sleep(poll_interval)
await asyncio.sleep(self._target_duration)
except asyncio.CancelledError:
if module_state.cache is None:
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)
return cache
except BaseException:
shutil.rmtree(cache_dir, ignore_errors=True)
raise
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 != 200:
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 != 200:
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
+20 -69
View File
@@ -16,85 +16,46 @@
from __future__ import annotations
import secrets
import shutil
import tempfile
from pathlib import Path
from owlbot.api import CommandContext, on_command
from .routes import schedule_session_expiry
from .types import EditorSession, get_state
from .manager import get_manager
from .types import CacheUnavailableError, ClipNotFoundError, InsufficientCacheError
@on_command("clip", cooldown=15)
async def clip_command(ctx: CommandContext) -> None:
"""Create a clip from the current stream.
Uses the concat protocol to read directly from the HLS cache while
suppressing pruning, then sends the user a link to the clip editor.
Generates a preview from the HLS cache, creates an editor session,
and sends the user a link to the clip editor.
:param ctx: The command context.
"""
module = ctx.module
module_state = get_state(module)
if module_state.cache is None:
ctx.logger.debug("Clip command rejected: cache is None.")
try:
token = await get_manager(ctx.module).start_editor_session(
ctx.user.display_name
)
except CacheUnavailableError:
await ctx.owncast_client.send_message(
"Clipping is unavailable for this stream."
)
return
if not module_state.cache.enough_for_clipping:
ctx.logger.debug("Clip command rejected: not enough cached data.")
except InsufficientCacheError:
await ctx.owncast_client.send_message(
"Not enough stream data is available yet. Please try again later."
)
return
cache = module_state.cache
work_dir = Path(tempfile.mkdtemp(prefix="owlbot-clip-work-"))
preview_path = work_dir / "preview.mp4"
try:
manager = module_state.manager
duration = await manager.generate_preview_from_cache(preview_path, cache)
except Exception:
module.logger.error("Failed to generate clip preview.", exc_info=True)
shutil.rmtree(work_dir, ignore_errors=True)
await ctx.owncast_client.send_message(
"Failed to create clip preview. Please try again."
)
return
token = secrets.token_urlsafe(32)
session_expiry = int(module.config.get("session_expiry"))
session = EditorSession(
preview_path=preview_path,
work_dir=work_dir,
duration=duration,
creator=ctx.user.display_name,
)
module_state.sessions[token] = session
schedule_session_expiry(
module_state.sessions,
token,
session,
session_expiry,
module.logger,
)
url = ctx.routes.url_for(f"/edit/{token}")
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id,
f"Your clip preview is ready! Edit it here: {url}",
)
module.logger.info(
f"Clip preview generated for {ctx.user.display_name} "
f"({duration:.1f}s, token={token[:8]}...)"
f'<a href="{url}"><u>Click here to create a clip</u></a>.',
unsanitized=True,
)
@@ -105,7 +66,10 @@ async def clips_command(ctx: CommandContext) -> None:
:param ctx: The command context.
"""
url = ctx.routes.url_for("/list")
await ctx.owncast_client.send_message(f"Clips: {url}")
await ctx.owncast_client.send_message(
f'<a href="{url}"><u>Click here to view clips</u></a>.',
unsanitized=True,
)
@on_command("delclip", requires_moderator=True)
@@ -125,23 +89,10 @@ async def delclip_command(ctx: CommandContext) -> None:
await ctx.owncast_client.send_message("Usage: !delclip <clip_id>")
return
row = await ctx.storage.fetch_one("SELECT id FROM clips WHERE id = ?", (clip_id,))
if not row:
ctx.logger.debug("Clip %s not found for deletion.", clip_id)
await ctx.owncast_client.send_message(f"Clip {clip_id} not found.")
try:
await get_manager(ctx.module).delete_clip(clip_id)
except ClipNotFoundError as e:
await ctx.owncast_client.send_message(f"Clip {e.clip_id} not found.")
return
# Delete files from disk.
clips_dir = Path(str(ctx.module.config.get("clips_dir")))
clip_path = clips_dir / f"{clip_id}.mp4"
if clip_path.exists():
clip_path.unlink()
thumbnail_path = clips_dir / f"{clip_id}.webp"
if thumbnail_path.exists():
thumbnail_path.unlink()
# Delete DB record.
await ctx.storage.execute("DELETE FROM clips WHERE id = ?", (clip_id,))
ctx.logger.info(f"Clip {clip_id} deleted by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Clip {clip_id} deleted.")
+45
View File
@@ -0,0 +1,45 @@
# 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.
"""Event handlers for the clips module."""
from __future__ import annotations
from owlbot.api import (
EventContext,
EventType,
StreamStartedEvent,
StreamStoppedEvent,
on_event,
)
from .manager import get_manager
@on_event(EventType.STREAM_STARTED)
async def on_stream_started(ctx: EventContext[StreamStartedEvent]) -> None:
"""Start HLS caching when the stream goes live.
:param ctx: The event context.
"""
await get_manager(ctx.module).handle_stream_started()
@on_event(EventType.STREAM_STOPPED)
async def on_stream_stopped(ctx: EventContext[StreamStoppedEvent]) -> None:
"""Begin grace period when the stream goes offline.
:param ctx: The event context.
"""
await get_manager(ctx.module).handle_stream_stopped()
+438
View File
@@ -0,0 +1,438 @@
# 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.
"""Manager layer for the clips module.
Central coordinator that owns runtime state and enforces all business rules.
Delegates persistence to the repository, video processing to the
VideoProcessor, and HLS caching to the ChunkCache collaborator.
"""
from __future__ import annotations
import asyncio
import contextlib
import math
import secrets
import shutil
import tempfile
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
from .cache import start_caching
from .types import (
CacheUnavailableError,
Clip,
InsufficientCacheError,
InvalidClipParamsError,
SessionNotFoundError,
)
if TYPE_CHECKING:
from owlbot.api import ModuleContext
from .cache import ChunkCache
from .processing import VideoProcessor
from .repository import ClipRepository
def validate_clip_params(
*,
start: float,
end: float,
preview_duration: float,
min_length: int,
max_length: int,
) -> None:
"""Validate clip start/end parameters.
:param start: Start time in seconds.
:param end: End time in seconds.
:param preview_duration: Duration of the preview in seconds.
:param min_length: Minimum clip length in seconds.
:param max_length: Maximum clip length in seconds.
:raises InvalidClipParamsError: If any constraint is violated.
"""
if not math.isfinite(start) or not math.isfinite(end):
raise InvalidClipParamsError("Invalid start or end time.")
if start < 0:
raise InvalidClipParamsError("Start time cannot be negative.")
if end <= start:
raise InvalidClipParamsError("End time must be after start time.")
if end > preview_duration:
raise InvalidClipParamsError(
f"End time exceeds preview duration ({preview_duration:.1f}s)."
)
duration = end - start
if duration < min_length:
raise InvalidClipParamsError(
f"Clip is too short. Minimum length is {min_length} seconds."
)
if duration > max_length:
raise InvalidClipParamsError(
f"Clip is too long. Maximum length is {max_length} seconds."
)
@dataclass(slots=True)
class EditorSession:
"""A clip editor session.
Not frozen because expiry_task is mutable. Internal to the manager.
"""
preview_path: Path
work_dir: Path
duration: float
creator: str
expiry_task: asyncio.Task[None] | None = None
class ClipManager:
"""Central coordinator for the clips module.
:param ctx: Module context with config, logger, and HTTP client.
:param repo: Clip persistence layer.
:param processor: ffmpeg/ffprobe collaborator.
:param clips_dir: Directory for persisted clip files.
"""
def __init__(
self,
ctx: ModuleContext,
repo: ClipRepository,
processor: VideoProcessor,
clips_dir: Path,
) -> None:
"""Initialize the ClipManager.
:param ctx: Module context with config, logger, and HTTP client.
:param repo: Clip persistence layer.
:param processor: ffmpeg/ffprobe collaborator.
:param clips_dir: Directory for persisted clip files.
"""
self._ctx = ctx
self._repo = repo
self._processor = processor
self._clips_dir = clips_dir
self._sessions: dict[str, EditorSession] = {}
self._cache: ChunkCache | None = None
self._grace_task: asyncio.Task[None] | None = None
@property
def clips_dir(self) -> Path:
"""Directory where persisted clip files are stored."""
return self._clips_dir
async def get_clip(self, clip_id: int) -> Clip:
"""Fetch a clip by ID.
:param clip_id: The clip's primary key.
:return: The Clip snapshot.
:raises ClipNotFoundError: If no clip with that ID exists.
"""
return await self._repo.get(clip_id)
async def delete_clip(self, clip_id: int) -> Clip:
"""Delete a clip, its video file, and its thumbnail.
:param clip_id: The clip's primary key.
:return: The deleted Clip snapshot.
:raises ClipNotFoundError: If no clip with that ID exists.
"""
clip = await self._repo.delete(clip_id)
clip_path = self._clips_dir / f"{clip.id}.mp4"
if clip_path.exists():
clip_path.unlink()
thumbnail_path = self._clips_dir / f"{clip.id}.webp"
if thumbnail_path.exists():
thumbnail_path.unlink()
self._ctx.logger.info("Clip %d deleted.", clip.id)
return clip
async def list_clips(self) -> list[Clip]:
"""Return all clips ordered by creation date descending.
:return: List of Clip snapshots.
"""
return await self._repo.list_all()
async def start_editor_session(self, creator: str) -> str:
"""Generate a preview from the HLS cache and create an editor session.
:param creator: Display name of the user creating the clip.
:return: The editor session token.
:raises CacheUnavailableError: If no HLS cache is active.
:raises InsufficientCacheError: If not enough data is cached.
"""
if self._cache is None:
raise CacheUnavailableError
min_clip_length = int(self._ctx.config.get("min_clip_length"))
if self._cache.buffered_duration < min_clip_length * 2:
raise InsufficientCacheError
work_dir = Path(tempfile.mkdtemp(prefix="owlbot-clip-work-"))
preview_path = work_dir / "preview.mp4"
try:
duration = await self._processor.generate_preview_from_cache(
preview_path, self._cache
)
except Exception:
self._ctx.logger.error("Failed to generate clip preview.", exc_info=True)
await asyncio.to_thread(shutil.rmtree, work_dir, True)
raise
token = secrets.token_urlsafe(32)
session = EditorSession(
preview_path=preview_path,
work_dir=work_dir,
duration=duration,
creator=creator,
)
self._sessions[token] = session
delay = int(self._ctx.config.get("session_expiry"))
async def _expire() -> None:
await asyncio.sleep(delay)
if self._sessions.get(token) is not session:
return
self._ctx.logger.debug("Session expired: token=%s...", token[:8])
await self.cleanup_session(token)
session.expiry_task = asyncio.create_task(
_expire(), name=f"Clips Module - Session expiry ({token[:8]})"
)
self._ctx.logger.info(
"Editor session started for %s (token=%s...).",
creator,
token[:8],
)
return token
def get_session(self, token: str) -> EditorSession:
"""Look up an editor session by token.
:param token: The editor session token.
:return: The EditorSession.
:raises SessionNotFoundError: If the token is invalid or expired.
"""
session = self._sessions.get(token)
if session is None:
raise SessionNotFoundError(token)
return session
async def create_clip(
self,
token: str,
start: float,
end: float,
title: str | None,
) -> Clip:
"""Finalize a clip from an editor session.
Validates parameters, cuts the clip, persists metadata, generates a
thumbnail, and cleans up the session.
:param token: The editor session token.
:param start: Start time in seconds.
:param end: End time in seconds.
:param title: Optional clip title.
:return: The created Clip snapshot.
:raises SessionNotFoundError: If the token is invalid or expired.
:raises InvalidClipParamsError: If clip parameters are invalid.
"""
session = self.get_session(token)
validate_clip_params(
start=start,
end=end,
preview_duration=session.duration,
min_length=int(self._ctx.config.get("min_clip_length")),
max_length=int(self._ctx.config.get("max_clip_length")),
)
# Remove session to prevent double-submission.
self._sessions.pop(token, None)
if session.expiry_task is not None:
session.expiry_task.cancel()
clip_id: int | None = None
clip_path: Path | None = None
try:
temp_clip_path = session.work_dir / "clip.mp4"
actual_duration = await self._processor.create_clip(
session.preview_path,
temp_clip_path,
start,
end,
)
clip = await self._repo.create(
title=title,
creator=session.creator,
created_at=datetime.now(UTC).isoformat(),
duration=actual_duration,
)
clip_id = clip.id
clip_path = self._clips_dir / f"{clip.id}.mp4"
await asyncio.to_thread(shutil.move, temp_clip_path, clip_path)
# Generate thumbnail (best-effort).
thumbnail_path = self._clips_dir / f"{clip.id}.webp"
try:
await self._processor.generate_thumbnail(
clip_path,
thumbnail_path,
duration=actual_duration,
)
except Exception:
self._ctx.logger.warning(
"Thumbnail generation failed for clip %d.",
clip.id,
exc_info=True,
)
thumbnail_path.unlink(missing_ok=True)
return clip
except Exception:
# Clean up partial state to avoid orphaned DB rows or files.
if clip_id is not None:
try:
await self._repo.delete(clip_id)
except Exception:
self._ctx.logger.warning(
"Failed to clean up DB row for clip %d.",
clip_id,
exc_info=True,
)
if clip_path is not None:
clip_path.unlink(missing_ok=True)
raise
finally:
await asyncio.to_thread(shutil.rmtree, session.work_dir, True)
async def cleanup_session(self, token: str) -> None:
"""Remove a session and clean up its working directory.
:param token: The session token to remove.
"""
session = self._sessions.pop(token, None)
if session is not None:
if session.expiry_task is not None:
session.expiry_task.cancel()
await asyncio.to_thread(shutil.rmtree, session.work_dir, True)
async def start_caching(self) -> None:
"""Start HLS caching from the Owncast stream.
If caching is already active, this is a no-op.
"""
if self._cache is not None:
self._ctx.logger.debug("Caching already active, skipping start.")
return
try:
self._cache = await start_caching(
http=self._ctx.http.session,
base_url=self._ctx.owncast_client.base_url,
cache_duration=int(self._ctx.config.get("cache_duration")),
logger=self._ctx.logger,
)
except Exception:
self._ctx.logger.error("Failed to start HLS caching.", exc_info=True)
async def stop_caching(self) -> None:
"""Stop HLS caching and clean up cached files."""
if self._cache is not None:
await self._cache.stop()
self._cache = None
async def handle_stream_started(self) -> None:
"""Handle a stream-started event: cancel grace period and restart cache."""
if self._grace_task is not None:
self._ctx.logger.debug("Cancelling active grace period task.")
self._grace_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._grace_task
self._grace_task = None
await self.stop_caching()
await self.start_caching()
async def handle_stream_stopped(self) -> None:
"""Handle a stream-stopped event: schedule grace period before cleanup."""
if self._grace_task is not None:
self._ctx.logger.debug("Cancelling active grace period task.")
self._grace_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._grace_task
self._grace_task = None
async def _grace_period() -> None:
grace = int(self._ctx.config.get("grace_period"))
self._ctx.logger.info("Stream stopped. Grace period: %ds.", grace)
await asyncio.sleep(grace)
await self.stop_caching()
self._ctx.logger.info("Grace period ended. Cache cleaned up.")
self._ctx.logger.debug(
"Stream stopped event received. Scheduling grace period."
)
self._grace_task = asyncio.create_task(
_grace_period(), name="Clips Module - Stream grace period"
)
async def teardown(self) -> None:
"""Clean up all module state: grace task, cache, and sessions."""
# 1. Cancel grace period task if running.
if self._grace_task is not None:
self._ctx.logger.debug("Cancelling grace period task during teardown.")
self._grace_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._grace_task
# 2. Stop caching.
await self.stop_caching()
# 3. Cancel all session expiry tasks (prevents races during drain).
for session in self._sessions.values():
if session.expiry_task is not None:
session.expiry_task.cancel()
# 4. Clean up editor sessions and their working directories.
for token in list(self._sessions):
await self.cleanup_session(token)
self._ctx.logger.info("Clips module cleaned up.")
def get_manager(ctx: ModuleContext) -> ClipManager:
"""Retrieve the ClipManager from the module context.
:param ctx: The module context.
:return: The ClipManager instance.
:raises RuntimeError: If ClipManager has not been initialized.
"""
manager = ctx.state.get("manager")
if not isinstance(manager, ClipManager):
msg = "ClipManager is not initialized."
raise RuntimeError(msg)
return manager
+56 -32
View File
@@ -31,7 +31,7 @@ if TYPE_CHECKING:
from .cache import ChunkCache
class ProcessingManager:
class VideoProcessor:
"""Manages ffmpeg/ffprobe subprocess execution with concurrency control.
Provides semaphore-gated methods for preview generation, clip
@@ -63,17 +63,11 @@ class ProcessingManager:
:raises RuntimeError: If no segments are cached.
"""
async with cache.suppress_pruning():
if len(cache) == 0:
msg = "ffmpeg: no cached segments available."
raise RuntimeError(msg)
concat_string = cache.concat_protocol_string()
async with self._ffmpeg_semaphore:
self._logger.debug(
f"Generating preview via concat protocol -> {output_path}."
"Generating preview via concat protocol -> %s.", output_path
)
await self._run_ffprocess(
"ffmpeg",
await self._run_ffmpeg(
"-y",
"-i",
f"concat:{concat_string}",
@@ -84,9 +78,7 @@ class ProcessingManager:
str(output_path),
)
async with self._ffprobe_semaphore:
duration = await self._probe_duration(output_path)
self._logger.debug("Preview generated (%.1fs).", duration)
return duration
@@ -99,8 +91,8 @@ class ProcessingManager:
) -> float:
"""Cut a clip from a preview MP4.
Acquires the ffmpeg semaphore for the cut step, then the ffprobe
semaphore for the duration probe.
Cuts the specified time range via ffmpeg, then probes the
output duration via ffprobe.
:param preview_path: Path to the preview MP4.
:param output_path: Where to write the final clip MP4.
@@ -109,7 +101,6 @@ class ProcessingManager:
:return: Duration of the created clip in seconds.
:raises RuntimeError: If ffmpeg fails.
"""
async with self._ffmpeg_semaphore:
self._logger.debug(
"Cutting clip %.1f-%.1fs from %s -> %s.",
start,
@@ -117,8 +108,7 @@ class ProcessingManager:
preview_path,
output_path,
)
await self._run_ffprocess(
"ffmpeg",
await self._run_ffmpeg(
"-y",
"-ss",
str(start),
@@ -133,9 +123,7 @@ class ProcessingManager:
str(output_path),
)
async with self._ffprobe_semaphore:
duration = await self._probe_duration(output_path)
self._logger.debug("Clip created (%.1fs).", duration)
return duration
@@ -148,8 +136,6 @@ class ProcessingManager:
) -> None:
"""Extract a single frame from the middle of a clip as a WebP thumbnail.
Acquires the ffmpeg semaphore for the extraction step.
:param clip_path: Path to the source MP4 clip.
:param output_path: Where to write the output WebP image.
:param duration: Duration of the clip in seconds (used to find midpoint).
@@ -157,12 +143,10 @@ class ProcessingManager:
"""
midpoint = duration / 2
async with self._ffmpeg_semaphore:
self._logger.debug(
f"Generating thumbnail at {midpoint:.1f}s -> {output_path}."
"Generating thumbnail at %.1fs -> %s.", midpoint, output_path
)
await self._run_ffprocess(
"ffmpeg",
await self._run_ffmpeg(
"-y",
"-ss",
str(midpoint),
@@ -173,6 +157,7 @@ class ProcessingManager:
"-f",
"webp",
str(output_path),
time_limit=30.0,
)
self._logger.debug("Thumbnail generated: %s.", output_path)
@@ -180,15 +165,19 @@ class ProcessingManager:
async def _run_ffprocess(
self,
*args: str,
time_limit: float,
) -> tuple[bytes, bytes]:
"""Run an ffmpeg/ffprobe subprocess with cancellation safety.
"""Run an ffmpeg/ffprobe subprocess with timeout and cancellation safety.
If the calling coroutine is cancelled (e.g. by a handler timeout),
the subprocess is killed before re-raising.
If the process exceeds *time_limit* seconds it is killed and a
`RuntimeError` is raised. If the calling coroutine is cancelled
(e.g. by a handler timeout), the subprocess is killed before
re-raising.
:param args: Command and arguments (e.g. "ffmpeg", "-y", ...).
:param time_limit: Maximum seconds to wait for the process.
:return: Tuple of (stdout, stderr) bytes.
:raises RuntimeError: If the process returns non-zero.
:raises RuntimeError: If the process returns non-zero or times out.
"""
proc = await asyncio.create_subprocess_exec(
*args,
@@ -196,7 +185,14 @@ class ProcessingManager:
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await proc.communicate()
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=time_limit
)
except TimeoutError:
proc.kill()
await proc.wait()
msg = f"{args[0]} timed out after {time_limit}s"
raise RuntimeError(msg) from None
except asyncio.CancelledError:
proc.kill()
await proc.wait()
@@ -211,6 +207,36 @@ class ProcessingManager:
return stdout, stderr
async def _run_ffmpeg(
self,
*args: str,
time_limit: float = 120.0,
) -> tuple[bytes, bytes]:
"""Run ffmpeg with semaphore gating and a timeout.
:param args: Arguments passed after ``ffmpeg``.
:param time_limit: Maximum seconds to wait (default 120).
:return: Tuple of (stdout, stderr) bytes.
:raises RuntimeError: If the process fails or times out.
"""
async with self._ffmpeg_semaphore:
return await self._run_ffprocess("ffmpeg", *args, time_limit=time_limit)
async def _run_ffprobe(
self,
*args: str,
time_limit: float = 30.0,
) -> tuple[bytes, bytes]:
"""Run ffprobe with semaphore gating and a timeout.
:param args: Arguments passed after ``ffprobe``.
:param time_limit: Maximum seconds to wait (default 30).
:return: Tuple of (stdout, stderr) bytes.
:raises RuntimeError: If the process fails or times out.
"""
async with self._ffprobe_semaphore:
return await self._run_ffprocess("ffprobe", *args, time_limit=time_limit)
async def _probe_duration(
self,
file_path: Path,
@@ -221,8 +247,7 @@ class ProcessingManager:
:return: Duration in seconds.
:raises RuntimeError: If ffprobe fails.
"""
stdout, _ = await self._run_ffprocess(
"ffprobe",
stdout, _ = await self._run_ffprobe(
"-v",
"quiet",
"-print_format",
@@ -230,6 +255,5 @@ class ProcessingManager:
"-show_format",
str(file_path),
)
data = orjson.loads(stdout)
return float(data["format"]["duration"])
+117
View File
@@ -0,0 +1,117 @@
# 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.
"""Persistence layer for the clips module."""
from __future__ import annotations
from typing import TYPE_CHECKING
from owlbot.api import StorageError
from .types import Clip, ClipNotFoundError
if TYPE_CHECKING:
from owlbot.api import ModuleStorage
class ClipRepository:
"""All database operations for clip entities.
:param storage: Module-scoped SQLite storage instance.
"""
def __init__(self, storage: ModuleStorage) -> None:
"""Initialize with a module-scoped storage instance.
:param storage: The SQLite storage for this module.
"""
self._storage = storage
async def setup(self) -> None:
"""Create the clips table if it does not exist."""
await self._storage.execute(
"""
CREATE TABLE IF NOT EXISTS clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
creator TEXT NOT NULL,
created_at TEXT NOT NULL,
duration REAL
)
"""
)
async def create(
self,
title: str | None,
creator: str,
created_at: str,
duration: float,
) -> Clip:
"""Insert a new clip and return its snapshot.
:param title: Optional clip title.
:param creator: Display name of the clip creator.
:param created_at: ISO 8601 timestamp.
:param duration: Clip duration in seconds.
:return: The created Clip.
"""
row = await self._storage.fetch_one(
"INSERT INTO clips (title, creator, created_at, duration) "
"VALUES (?, ?, ?, ?) RETURNING *",
(title, creator, created_at, duration),
)
if row is None:
msg = "INSERT ... RETURNING * returned no row."
raise StorageError(msg)
return Clip.from_row(row)
async def get(self, clip_id: int) -> Clip:
"""Fetch a clip by ID.
:param clip_id: The clip's primary key.
:return: The Clip snapshot.
:raises ClipNotFoundError: If no clip with that ID exists.
"""
row = await self._storage.fetch_one(
"SELECT * FROM clips WHERE id = ?", (clip_id,)
)
if row is None:
raise ClipNotFoundError(clip_id)
return Clip.from_row(row)
async def delete(self, clip_id: int) -> Clip:
"""Delete a clip by ID and return its snapshot.
:param clip_id: The clip's primary key.
:return: The deleted Clip snapshot.
:raises ClipNotFoundError: If no clip with that ID exists.
"""
row = await self._storage.fetch_one(
"DELETE FROM clips WHERE id = ? RETURNING *", (clip_id,)
)
if row is None:
raise ClipNotFoundError(clip_id)
return Clip.from_row(row)
async def list_all(self) -> list[Clip]:
"""Return all clips ordered by creation date descending.
:return: List of Clip snapshots.
"""
rows = await self._storage.fetch_all(
"SELECT * FROM clips ORDER BY created_at DESC"
)
return [Clip.from_row(row) for row in rows]
+54 -238
View File
@@ -16,116 +16,26 @@
from __future__ import annotations
import asyncio
import math
import shutil
from datetime import UTC, datetime
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
from aiohttp import web
from owlbot.api import RouteContext, on_route
from .types import EditorSession, get_state
if TYPE_CHECKING:
import logging
from .manager import get_manager
from .types import ClipNotFoundError, InvalidClipParamsError, SessionNotFoundError
_EDITOR_JS_PATH = Path(__file__).resolve().parent / "static" / "editor.js"
def _validate_clip_params(
*,
start: float,
end: float,
preview_duration: float,
min_length: int,
max_length: int,
) -> str | None:
"""Validate clip start/end parameters.
def _format_date(iso: str) -> str:
"""Format an ISO date string for display.
:param start: Start time in seconds.
:param end: End time in seconds.
:param preview_duration: Duration of the preview in seconds.
:param min_length: Minimum clip length in seconds.
:param max_length: Maximum clip length in seconds.
:return: Error message string, or None if valid.
:param iso: ISO 8601 date string.
:return: Human-readable date like "April 13, 2026".
"""
if not math.isfinite(start) or not math.isfinite(end):
return "Invalid start or end time."
if start < 0:
return "Start time cannot be negative."
if end <= start:
return "End time must be after start time."
if end > preview_duration:
return f"End time exceeds preview duration ({preview_duration:.1f}s)."
duration = end - start
if duration < min_length:
return f"Clip is too short. Minimum length is {min_length} seconds."
if duration > max_length:
return f"Clip is too long. Maximum length is {max_length} seconds."
return None
def _get_session(ctx: RouteContext, token: str) -> EditorSession | None:
"""Look up an editor session by token, returning None if missing.
:param ctx: The route context.
:param token: The editor session token.
:return: The session, or None.
"""
sessions = get_state(ctx.module).sessions
session = sessions.get(token)
if session is None:
ctx.module.logger.debug(
f"Session lookup failed: token={token[:8]}... not found."
)
return None
return session
def cleanup_session(sessions: dict[str, EditorSession], token: str) -> None:
"""Remove a session and clean up its working directory.
:param sessions: The sessions dict from module state.
:param token: The session token to remove.
"""
session = sessions.pop(token, None)
if session is not None:
if session.expiry_task is not None:
session.expiry_task.cancel()
shutil.rmtree(session.work_dir, ignore_errors=True)
def schedule_session_expiry(
sessions: dict[str, EditorSession],
token: str,
session: EditorSession,
delay: float,
logger: logging.Logger,
) -> None:
"""Schedule a task that cleans up the session after *delay* seconds.
Any existing expiry task on *session* is cancelled first.
:param sessions: The sessions dict from module state.
:param token: The session token.
:param session: The editor session.
:param delay: Seconds until expiry.
:param logger: Logger instance for debug messages.
"""
if session.expiry_task is not None:
session.expiry_task.cancel()
async def _expire() -> None:
await asyncio.sleep(delay)
if sessions.get(token) is not session:
return
logger.debug("Session expired: token=%s...", token[:8])
cleanup_session(sessions, token)
session.expiry_task = asyncio.create_task(_expire())
return datetime.fromisoformat(iso).strftime("%B %-d, %Y")
def _error_page(ctx: RouteContext, status: int, message: str) -> web.Response:
@@ -152,9 +62,11 @@ async def editor_page(ctx: RouteContext) -> web.Response:
:return: HTML response with the editor, or error page.
"""
token = ctx.match_info["token"]
session = _get_session(ctx, token)
manager = get_manager(ctx.module)
if session is None:
try:
session = manager.get_session(token)
except SessionNotFoundError:
return _error_page(ctx, 404, "Session not found or expired.")
preview_url = ctx.routes.url_for(f"/edit/{token}/preview")
@@ -188,9 +100,10 @@ async def editor_preview_video(ctx: RouteContext) -> web.StreamResponse:
:return: The preview MP4 as a streaming response.
"""
token = ctx.match_info["token"]
session = _get_session(ctx, token)
if session is None:
try:
session = get_manager(ctx.module).get_session(token)
except SessionNotFoundError:
return web.Response(status=404)
if not session.preview_path.exists():
@@ -216,17 +129,14 @@ async def editor_js(ctx: RouteContext) -> web.StreamResponse:
async def editor_submit(ctx: RouteContext) -> web.Response:
"""Handle clip editor form submission.
Validates parameters, processes the clip inline with ffmpeg, and
redirects to the finished clip page.
Validates parameters, processes the clip, and redirects to the
finished clip page.
:param ctx: The route context.
:return: Redirect to the clip page, or error response.
"""
token = ctx.match_info["token"]
session = _get_session(ctx, token)
if session is None:
return _error_page(ctx, 404, "Session not found or expired.")
manager = get_manager(ctx.module)
data = await ctx.request.post()
@@ -237,101 +147,21 @@ async def editor_submit(ctx: RouteContext) -> web.Response:
return _error_page(ctx, 400, "Invalid start or end time.")
title = str(data.get("title", "")).strip()[:200] or None
preview_duration = session.duration
min_length = int(ctx.config.get("min_clip_length"))
max_length = int(ctx.config.get("max_clip_length"))
error = _validate_clip_params(
start=start,
end=end,
preview_duration=preview_duration,
min_length=min_length,
max_length=max_length,
)
if error is not None:
ctx.logger.debug("Clip submit validation failed: %s", error)
return _error_page(ctx, 400, error)
# Remove session to prevent double-submission.
sessions = get_state(ctx.module).sessions
sessions.pop(token, None)
if session.expiry_task is not None:
session.expiry_task.cancel()
clips_dir = Path(str(ctx.config.get("clips_dir")))
work_dir = session.work_dir
manager = get_state(ctx.module).manager
clip_id: int | None = None
clip_path: Path | None = None
try:
# Cut clip from preview.
temp_clip_path = work_dir / "clip.mp4"
actual_duration = await manager.create_clip(
session.preview_path,
temp_clip_path,
start,
end,
)
# Insert DB row.
cursor = await ctx.storage.execute(
"INSERT INTO clips (title, creator, created_at, duration) "
"VALUES (?, ?, ?, ?)",
(title, session.creator, datetime.now(UTC).isoformat(), actual_duration),
)
clip_id = cursor.lastrowid
if clip_id is None:
msg = "Failed to retrieve last inserted row ID."
raise RuntimeError(msg)
# Move to final location.
clip_path = clips_dir / f"{clip_id}.mp4"
shutil.move(temp_clip_path, clip_path)
# Generate thumbnail (best-effort).
thumbnail_path = clips_dir / f"{clip_id}.webp"
try:
await manager.generate_thumbnail(
clip_path,
thumbnail_path,
duration=actual_duration,
)
except Exception:
ctx.logger.warning(
f"Thumbnail generation failed for clip {clip_id}.",
exc_info=True,
)
thumbnail_path.unlink(missing_ok=True)
ctx.logger.info(f"Clip {clip_id} ready ({actual_duration:.1f}s).")
clip_url = ctx.routes.url_for(f"/view/{clip_id}")
await ctx.owncast_client.send_message(
f"{session.creator} created a clip: {clip_url}"
)
return web.HTTPFound(clip_url)
clip = await manager.create_clip(token, start, end, title)
except SessionNotFoundError:
return _error_page(ctx, 404, "Session not found or expired.")
except InvalidClipParamsError as e:
return _error_page(ctx, 400, e.reason)
except Exception:
ctx.logger.error("Failed to create clip.", exc_info=True)
# Clean up partial state to avoid orphaned DB rows or files.
if clip_id is not None:
try:
await ctx.storage.execute("DELETE FROM clips WHERE id = ?", (clip_id,))
except Exception:
ctx.logger.warning(
f"Failed to clean up DB row for clip {clip_id}.",
exc_info=True,
)
if clip_path is not None:
clip_path.unlink(missing_ok=True)
return _error_page(ctx, 500, "Clip processing failed.")
finally:
shutil.rmtree(work_dir, ignore_errors=True)
clip_url = ctx.routes.url_for(f"/view/{clip.id}")
await ctx.owncast_client.send_message(f"{clip.creator} created a clip: {clip_url}")
return web.HTTPFound(clip_url)
@on_route("/view/{clip_id}", methods=["GET"])
@@ -346,21 +176,20 @@ async def clip_page(ctx: RouteContext) -> web.Response:
except ValueError:
return _error_page(ctx, 404, "Clip not found.")
row = await ctx.storage.fetch_one(
"SELECT id, title, creator, created_at FROM clips WHERE id = ?",
(clip_id,),
)
if row is None:
manager = get_manager(ctx.module)
try:
clip = await manager.get_clip(clip_id)
except ClipNotFoundError:
return _error_page(ctx, 404, "Clip not found.")
video_url = ctx.routes.url_for(f"/view/{clip_id}/video")
page = ctx.templates.render(
"clip.html",
clip_id=row["id"],
title=row["title"],
creator=row["creator"],
created_at=row["created_at"],
clip_id=clip.id,
title=clip.title,
creator=clip.creator,
created_at=clip.created_at,
video_url=video_url,
list_url=ctx.routes.url_for("/list"),
)
@@ -380,15 +209,13 @@ async def clip_video(ctx: RouteContext) -> web.StreamResponse:
except ValueError:
return web.Response(status=404)
row = await ctx.storage.fetch_one(
"SELECT id FROM clips WHERE id = ?",
(clip_id,),
)
if row is None:
manager = get_manager(ctx.module)
try:
await manager.get_clip(clip_id)
except ClipNotFoundError:
return web.Response(status=404)
clips_dir = Path(str(ctx.config.get("clips_dir")))
clip_path = clips_dir / f"{clip_id}.mp4"
clip_path = manager.clips_dir / f"{clip_id}.mp4"
if not clip_path.exists():
return web.Response(status=404)
@@ -407,15 +234,13 @@ async def clip_thumbnail(ctx: RouteContext) -> web.StreamResponse:
except ValueError:
return web.Response(status=404)
row = await ctx.storage.fetch_one(
"SELECT id FROM clips WHERE id = ?",
(clip_id,),
)
if row is None:
manager = get_manager(ctx.module)
try:
await manager.get_clip(clip_id)
except ClipNotFoundError:
return web.Response(status=404)
clips_dir = Path(str(ctx.config.get("clips_dir")))
thumbnail_path = clips_dir / f"{clip_id}.webp"
thumbnail_path = manager.clips_dir / f"{clip_id}.webp"
if not thumbnail_path.exists():
return web.Response(status=404)
@@ -429,23 +254,14 @@ async def clips_list_page(ctx: RouteContext) -> web.Response:
:param ctx: The route context.
:return: HTML response with the clips list.
"""
rows = await ctx.storage.fetch_all(
"SELECT id, title, created_at FROM clips ORDER BY created_at DESC"
manager = get_manager(ctx.module)
clips = await manager.list_clips()
page = ctx.templates.render(
"list.html",
clips=clips,
url_for=ctx.routes.url_for,
format_date=_format_date,
)
clips = [
{
"id": row["id"],
"title": row["title"] or f"Clip #{row['id']}",
"created_at": datetime.fromisoformat(row["created_at"]).strftime(
"%B %-d, %Y"
),
"url": ctx.routes.url_for(f"/view/{row['id']}"),
"thumbnail_url": ctx.routes.url_for(f"/view/{row['id']}/thumbnail"),
}
for row in rows
]
page = ctx.templates.render("list.html", clips=clips)
return web.Response(text=page, content_type="text/html")
@@ -14,12 +14,12 @@
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for clip in clips %}
<div class="col">
<a href="{{ clip.url }}" class="text-decoration-none">
<a href="{{ url_for('/view/' ~ clip.id) }}" class="text-decoration-none">
<div class="card h-100">
<img src="{{ clip.thumbnail_url }}" class="card-img-top clip-thumb" alt="{{ clip.title }}">
<img src="{{ url_for('/view/' ~ clip.id ~ '/thumbnail') }}" class="card-img-top clip-thumb" alt="{{ clip.title or 'Clip #' ~ clip.id }}">
<div class="card-body">
<h5 class="card-title">{{ clip.title }}</h5>
<p class="card-text text-body-secondary">{{ clip.created_at }}</p>
<h5 class="card-title">{{ clip.title or 'Clip #' ~ clip.id }}</h5>
<p class="card-text text-body-secondary">{{ format_date(clip.created_at) }}</p>
</div>
</div>
</a>
+66 -49
View File
@@ -12,69 +12,86 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Typed state containers for the clips module."""
"""Domain types and errors for the clips module."""
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import asyncio
from pathlib import Path
from owlbot.api import ModuleContext
from .cache import ChunkCache
from .processing import ProcessingManager
import aiosqlite
@dataclass(slots=True)
class EditorSession:
"""A clip editor session."""
@dataclass(frozen=True, slots=True)
class Clip:
"""Immutable snapshot of a persisted clip."""
preview_path: Path
work_dir: Path
duration: float
id: int
title: str | None
creator: str
expiry_task: asyncio.Task[None] | None = None
created_at: str
duration: float | None
@classmethod
def from_row(cls, row: aiosqlite.Row) -> Clip:
"""Construct a Clip from a database row.
@dataclass(slots=True)
class ModuleState:
"""Runtime state for the clips module."""
cache: ChunkCache | None = None
sessions: dict[str, EditorSession] = field(default_factory=dict)
grace_task: asyncio.Task[None] | None = None
cache_task: asyncio.Task[None] | None = None
_manager: ProcessingManager | None = None
@property
def manager(self) -> ProcessingManager:
"""Return the processing manager, raising if not yet initialized.
:return: The active ProcessingManager.
:raises RuntimeError: If the manager has not been set.
:param row: A database row with clip columns.
:return: A new Clip instance.
"""
if self._manager is None:
msg = "ProcessingManager is not initialized."
raise RuntimeError(msg)
return self._manager
@manager.setter
def manager(self, value: ProcessingManager | None) -> None:
self._manager = value
return cls(
id=row["id"],
title=row["title"],
creator=row["creator"],
created_at=row["created_at"],
duration=row["duration"],
)
def get_state(ctx: ModuleContext) -> ModuleState:
"""Retrieve the ModuleState from the module context.
class ClipError(Exception):
"""Base class for clip module domain errors."""
:param ctx: The module context.
:return: The ModuleState instance.
:raises RuntimeError: If ModuleState has not been initialized.
class ClipNotFoundError(ClipError):
"""Raised when a clip ID does not exist."""
def __init__(self, clip_id: int) -> None:
"""Initialize with the missing clip ID.
:param clip_id: The ID that was not found.
"""
state = ctx.state.get("clips")
if not isinstance(state, ModuleState):
raise RuntimeError("ModuleState is not initialized.")
return state
self.clip_id = clip_id
super().__init__(f"clip not found: {clip_id}")
class SessionNotFoundError(ClipError):
"""Raised when an editor session token is invalid or expired."""
def __init__(self, token: str) -> None:
"""Initialize with the invalid session token.
:param token: The token that was not found.
"""
self.token = token
super().__init__(f"session not found: {token}")
class CacheUnavailableError(ClipError):
"""No active HLS cache (stream is offline)."""
class InsufficientCacheError(ClipError):
"""Cache exists but does not have enough data for clipping."""
class InvalidClipParamsError(ClipError):
"""Raised when clip start/end/duration validation fails."""
def __init__(self, reason: str) -> None:
"""Initialize with the validation failure reason.
:param reason: Human-readable description of why validation failed.
"""
self.reason = reason
super().__init__(reason)