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 from __future__ import annotations
import asyncio
import contextlib
import shutil import shutil
from pathlib import Path from pathlib import Path
from owlbot.api import ( from owlbot.api import ModuleContext, on_setup, on_teardown
EventContext,
EventType,
ModuleContext,
StreamStartedEvent,
StreamStoppedEvent,
on_event,
on_setup,
on_teardown,
)
from .cache import start_caching, stop_caching
from .commands import clip_command, clips_command, delclip_command 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 ( from .routes import (
cleanup_session,
clip_page, clip_page,
clip_thumbnail, clip_thumbnail,
clip_video, clip_video,
@@ -50,7 +40,6 @@ from .routes import (
editor_preview_video, editor_preview_video,
editor_submit, editor_submit,
) )
from .types import ModuleState, get_state
__all__ = [ __all__ = [
"clip_command", "clip_command",
@@ -89,16 +78,6 @@ async def setup(ctx: ModuleContext) -> None:
) )
raise RuntimeError(msg) 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( ctx.config.register_defaults(
{ {
"cache_duration": 300, "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 = Path(str(ctx.config.get("clips_dir")))
clips_dir.mkdir(parents=True, exist_ok=True) # noqa: ASYNC240 clips_dir.mkdir(parents=True, exist_ok=True) # noqa: ASYNC240
# Initialize runtime state. manager = ClipManager(ctx, repo, processor, clips_dir)
ctx.state["clips"] = ModuleState() ctx.state["manager"] = manager
module_state = get_state(ctx)
module_state.manager = ProcessingManager(ctx.logger)
# Check if stream is already live. # Check if stream is already live.
try: try:
status = await ctx.owncast_client.get_status() status = await ctx.owncast_client.get_status()
if status.get("online", False): if status.get("online", False):
ctx.logger.info("Stream is already live. Starting HLS cache.") ctx.logger.info("Stream is already live. Starting HLS cache.")
await start_caching(ctx) await manager.start_caching()
except Exception: except Exception:
ctx.logger.warning( ctx.logger.warning(
"Could not check Owncast status during setup.", exc_info=True "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 @on_teardown
async def teardown(ctx: ModuleContext) -> None: async def teardown(ctx: ModuleContext) -> None:
"""Clean up the clips module. """Clean up the clips module.
Cancels grace/expiry tasks and cleans up temp files.
:param ctx: Module context. :param ctx: Module context.
""" """
module_state = get_state(ctx) manager = get_manager(ctx)
await manager.teardown()
# 1. Cancel grace period task if running. ctx.state["manager"] = None
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.")
+268 -365
View File
@@ -12,10 +12,11 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # 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 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 from __future__ import annotations
@@ -30,15 +31,13 @@ from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.parse import urljoin from urllib.parse import urljoin
import aiohttp
import m3u8 # type: ignore[import-untyped] import m3u8 # type: ignore[import-untyped]
if TYPE_CHECKING: if TYPE_CHECKING:
import logging
from collections.abc import AsyncIterator 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)$") _SEGMENT_URI_RE = re.compile(r"stream-([A-Za-z0-9]+)-(\d+)\.(ts|m4s)$")
_MAX_RETRIES = 10 _MAX_RETRIES = 10
@@ -55,113 +54,56 @@ class ChunkCache:
def __init__( def __init__(
self, self,
http: aiohttp.ClientSession,
cache_dir: Path, cache_dir: Path,
cache_duration: int, cache_duration: int,
min_clip_length: int,
segment_id: str, segment_id: str,
extension: str, extension: str,
target_duration: float,
variant_url: str,
logger: logging.Logger,
init_path: Path | None = None,
) -> None: ) -> None:
"""Initialize the chunk cache. """Initialize the chunk cache.
:param http: HTTP client session for polling.
:param cache_dir: Directory to store cached segment files. :param cache_dir: Directory to store cached segment files.
:param cache_duration: Maximum cache window in seconds. :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 segment_id: Unique stream segment identifier (e.g. ``jdofFGg``).
:param extension: Segment file extension without dot (e.g. ``ts`` or ``m4s``). :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_dir = cache_dir
self._cache_duration = cache_duration self._cache_duration = cache_duration
self._min_clip_length = min_clip_length
self._segment_id = segment_id self._segment_id = segment_id
self._extension = extension 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._sequences: set[int] = set()
self._target_duration: float = 0.0
self._prune_guard_count: int = 0 self._prune_guard_count: int = 0
self._init_path: Path | None = None self._task: asyncio.Task[None] | None = None
def __len__(self) -> int:
"""Return the number of cached segments."""
return len(self._sequences)
@property @property
def total_duration(self) -> float: def buffered_duration(self) -> float:
"""Return total duration of cached chunks in seconds.""" """Return total duration of cached chunks in seconds."""
return len(self._sequences) * self._target_duration return len(self._sequences) * self._target_duration
@property def _segment_path(self, sequence: int) -> Path:
def cache_dir(self) -> Path: """Return the file path for a segment by sequence number."""
"""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.
"""
return ( return (
self._cache_dir / f"stream-{self._segment_id}-{sequence}.{self._extension}" 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 @asynccontextmanager
async def suppress_pruning(self) -> AsyncIterator[None]: async def suppress_pruning(self) -> AsyncIterator[None]:
"""Context manager that suppresses pruning while reading from the cache. """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. Supports nested calls via reference counting.
""" """
self._prune_guard_count += 1 self._prune_guard_count += 1
@@ -170,24 +112,6 @@ class ChunkCache:
finally: finally:
self._prune_guard_count -= 1 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: def concat_protocol_string(self) -> str:
"""Build the ffmpeg concat protocol input string. """Build the ffmpeg concat protocol input string.
@@ -198,276 +122,66 @@ class ChunkCache:
parts: list[str] = [] parts: list[str] = []
if self._init_path is not None: if self._init_path is not None:
parts.append(str(self._init_path)) 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) return "|".join(parts)
def cleanup(self) -> None: def start(self) -> None:
"""Remove the cache directory and all its contents.""" """Start the background polling task."""
if self._cache_dir.exists(): if self._task is not None and not self._task.done():
shutil.rmtree(self._cache_dir) 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() self._sequences.clear()
async def _download_segment(self, segment_url: str, seq: int) -> bool:
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:
"""Download a single HLS segment and add it to the cache. """Download a single HLS segment and add it to the cache.
:param ctx: The module context. :param segment_url: URL to download the segment from.
: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. :param seq: HLS media sequence number.
:return: True if the segment was downloaded successfully, False otherwise. :return: True if the segment was downloaded successfully, False otherwise.
""" """
try: 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: if seg_resp.status != 200:
return False return False
data = await seg_resp.read() 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: except asyncio.CancelledError:
raise raise
except Exception as e: except aiohttp.ClientError:
reason = str(e) self._logger.debug(
exc_info = True "Network error downloading segment %d.", seq, 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,
) )
await asyncio.sleep(_RETRY_DELAY)
ctx.logger.error(f"Init segment failed after {_MAX_RETRIES} attempts.")
return False return False
chunk_path = self._segment_path(seq)
await asyncio.to_thread(chunk_path.write_bytes, data)
async def _polling_loop( self._sequences.add(seq)
ctx: ModuleContext, self._logger.debug("Cached segment %d (%d bytes).", seq, len(data))
master_url: str, return True
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
async def _polling_loop(self) -> None:
"""Background task that polls the HLS variant playlist for new segments."""
pending_retries: set[int] = set() pending_retries: set[int] = set()
while True: while True:
try: try:
async with ctx.http.get(variant_url) as resp: async with self._http.get(self._variant_url) as resp:
if resp.status != 200: if resp.status != 200:
ctx.logger.warning( self._logger.warning(
f"Variant playlist returned HTTP {resp.status}." "Variant playlist returned HTTP %d.", resp.status
) )
await asyncio.sleep(poll_interval) await asyncio.sleep(self._target_duration)
continue continue
content = await resp.text() content = await resp.text()
@@ -485,48 +199,237 @@ async def _polling_loop(
if seq not in playlist_segments: if seq not in playlist_segments:
abandoned.add(seq) abandoned.add(seq)
continue continue
segment_url = urljoin(variant_url, playlist_segments[seq]) segment_url = urljoin(self._variant_url, playlist_segments[seq])
if await _download_segment(ctx, cache, segment_url, seq): if await self._download_segment(segment_url, seq):
pending_retries.discard(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: for seq in abandoned:
pending_retries.discard(seq) pending_retries.discard(seq)
ctx.logger.warning( self._logger.warning(
f"Segment {seq} no longer in playlist, giving up." "Segment %d no longer in playlist, giving up.", seq
) )
for seq, uri in playlist_segments.items(): for seq, uri in playlist_segments.items():
if cache.has_chunk(seq): if seq in self._sequences:
continue continue
if seq in pending_retries: if seq in pending_retries:
continue continue
segment_url = urljoin(variant_url, uri) segment_url = urljoin(self._variant_url, uri)
if not await _download_segment(ctx, cache, segment_url, seq): if not await self._download_segment(segment_url, seq):
pending_retries.add(seq) pending_retries.add(seq)
ctx.logger.warning( self._logger.warning(
f"Segment {seq} download failed, queued for retry." "Segment %d download failed, queued for retry.", seq
) )
pre_prune_count = len(cache) if self._prune_guard_count == 0:
pruned = cache.prune() pre_prune = len(self._sequences)
if pruned: while (
ctx.logger.debug( self.buffered_duration > self._cache_duration
f"Pruned {pre_prune_count - len(cache)} segment(s). " and len(self._sequences) > 1
f"Cache: {len(cache)} chunks, " ):
f"{cache.total_duration:.1f}s total." 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: except asyncio.CancelledError:
raise raise
except Exception: 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) shutil.rmtree(cache_dir, ignore_errors=True)
raise 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 from __future__ import annotations
import secrets
import shutil
import tempfile
from pathlib import Path
from owlbot.api import CommandContext, on_command from owlbot.api import CommandContext, on_command
from .routes import schedule_session_expiry from .manager import get_manager
from .types import EditorSession, get_state from .types import CacheUnavailableError, ClipNotFoundError, InsufficientCacheError
@on_command("clip", cooldown=15) @on_command("clip", cooldown=15)
async def clip_command(ctx: CommandContext) -> None: async def clip_command(ctx: CommandContext) -> None:
"""Create a clip from the current stream. """Create a clip from the current stream.
Uses the concat protocol to read directly from the HLS cache while Generates a preview from the HLS cache, creates an editor session,
suppressing pruning, then sends the user a link to the clip editor. and sends the user a link to the clip editor.
:param ctx: The command context. :param ctx: The command context.
""" """
module = ctx.module try:
module_state = get_state(module) token = await get_manager(ctx.module).start_editor_session(
ctx.user.display_name
if module_state.cache is None: )
ctx.logger.debug("Clip command rejected: cache is None.") except CacheUnavailableError:
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
"Clipping is unavailable for this stream." "Clipping is unavailable for this stream."
) )
return return
except InsufficientCacheError:
if not module_state.cache.enough_for_clipping:
ctx.logger.debug("Clip command rejected: not enough cached data.")
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
"Not enough stream data is available yet. Please try again later." "Not enough stream data is available yet. Please try again later."
) )
return 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: 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( await ctx.owncast_client.send_message(
"Failed to create clip preview. Please try again." "Failed to create clip preview. Please try again."
) )
return 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}") url = ctx.routes.url_for(f"/edit/{token}")
await ctx.owncast_client.send_system_message_to_client( await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id, ctx.chat_event.client_id,
f"Your clip preview is ready! Edit it here: {url}", f'<a href="{url}"><u>Click here to create a clip</u></a>.',
) unsanitized=True,
module.logger.info(
f"Clip preview generated for {ctx.user.display_name} "
f"({duration:.1f}s, token={token[:8]}...)"
) )
@@ -105,7 +66,10 @@ async def clips_command(ctx: CommandContext) -> None:
:param ctx: The command context. :param ctx: The command context.
""" """
url = ctx.routes.url_for("/list") 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) @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>") await ctx.owncast_client.send_message("Usage: !delclip <clip_id>")
return return
row = await ctx.storage.fetch_one("SELECT id FROM clips WHERE id = ?", (clip_id,)) try:
if not row: await get_manager(ctx.module).delete_clip(clip_id)
ctx.logger.debug("Clip %s not found for deletion.", clip_id) except ClipNotFoundError as e:
await ctx.owncast_client.send_message(f"Clip {clip_id} not found.") await ctx.owncast_client.send_message(f"Clip {e.clip_id} not found.")
return 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.") 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 from .cache import ChunkCache
class ProcessingManager: class VideoProcessor:
"""Manages ffmpeg/ffprobe subprocess execution with concurrency control. """Manages ffmpeg/ffprobe subprocess execution with concurrency control.
Provides semaphore-gated methods for preview generation, clip Provides semaphore-gated methods for preview generation, clip
@@ -63,17 +63,11 @@ class ProcessingManager:
:raises RuntimeError: If no segments are cached. :raises RuntimeError: If no segments are cached.
""" """
async with cache.suppress_pruning(): async with cache.suppress_pruning():
if len(cache) == 0:
msg = "ffmpeg: no cached segments available."
raise RuntimeError(msg)
concat_string = cache.concat_protocol_string() concat_string = cache.concat_protocol_string()
async with self._ffmpeg_semaphore:
self._logger.debug( self._logger.debug(
f"Generating preview via concat protocol -> {output_path}." "Generating preview via concat protocol -> %s.", output_path
) )
await self._run_ffprocess( await self._run_ffmpeg(
"ffmpeg",
"-y", "-y",
"-i", "-i",
f"concat:{concat_string}", f"concat:{concat_string}",
@@ -84,9 +78,7 @@ class ProcessingManager:
str(output_path), str(output_path),
) )
async with self._ffprobe_semaphore:
duration = await self._probe_duration(output_path) duration = await self._probe_duration(output_path)
self._logger.debug("Preview generated (%.1fs).", duration) self._logger.debug("Preview generated (%.1fs).", duration)
return duration return duration
@@ -99,8 +91,8 @@ class ProcessingManager:
) -> float: ) -> float:
"""Cut a clip from a preview MP4. """Cut a clip from a preview MP4.
Acquires the ffmpeg semaphore for the cut step, then the ffprobe Cuts the specified time range via ffmpeg, then probes the
semaphore for the duration probe. output duration via ffprobe.
:param preview_path: Path to the preview MP4. :param preview_path: Path to the preview MP4.
:param output_path: Where to write the final clip 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. :return: Duration of the created clip in seconds.
:raises RuntimeError: If ffmpeg fails. :raises RuntimeError: If ffmpeg fails.
""" """
async with self._ffmpeg_semaphore:
self._logger.debug( self._logger.debug(
"Cutting clip %.1f-%.1fs from %s -> %s.", "Cutting clip %.1f-%.1fs from %s -> %s.",
start, start,
@@ -117,8 +108,7 @@ class ProcessingManager:
preview_path, preview_path,
output_path, output_path,
) )
await self._run_ffprocess( await self._run_ffmpeg(
"ffmpeg",
"-y", "-y",
"-ss", "-ss",
str(start), str(start),
@@ -133,9 +123,7 @@ class ProcessingManager:
str(output_path), str(output_path),
) )
async with self._ffprobe_semaphore:
duration = await self._probe_duration(output_path) duration = await self._probe_duration(output_path)
self._logger.debug("Clip created (%.1fs).", duration) self._logger.debug("Clip created (%.1fs).", duration)
return duration return duration
@@ -148,8 +136,6 @@ class ProcessingManager:
) -> None: ) -> None:
"""Extract a single frame from the middle of a clip as a WebP thumbnail. """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 clip_path: Path to the source MP4 clip.
:param output_path: Where to write the output WebP image. :param output_path: Where to write the output WebP image.
:param duration: Duration of the clip in seconds (used to find midpoint). :param duration: Duration of the clip in seconds (used to find midpoint).
@@ -157,12 +143,10 @@ class ProcessingManager:
""" """
midpoint = duration / 2 midpoint = duration / 2
async with self._ffmpeg_semaphore:
self._logger.debug( self._logger.debug(
f"Generating thumbnail at {midpoint:.1f}s -> {output_path}." "Generating thumbnail at %.1fs -> %s.", midpoint, output_path
) )
await self._run_ffprocess( await self._run_ffmpeg(
"ffmpeg",
"-y", "-y",
"-ss", "-ss",
str(midpoint), str(midpoint),
@@ -173,6 +157,7 @@ class ProcessingManager:
"-f", "-f",
"webp", "webp",
str(output_path), str(output_path),
time_limit=30.0,
) )
self._logger.debug("Thumbnail generated: %s.", output_path) self._logger.debug("Thumbnail generated: %s.", output_path)
@@ -180,15 +165,19 @@ class ProcessingManager:
async def _run_ffprocess( async def _run_ffprocess(
self, self,
*args: str, *args: str,
time_limit: float,
) -> tuple[bytes, bytes]: ) -> 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), If the process exceeds *time_limit* seconds it is killed and a
the subprocess is killed before re-raising. `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 args: Command and arguments (e.g. "ffmpeg", "-y", ...).
:param time_limit: Maximum seconds to wait for the process.
:return: Tuple of (stdout, stderr) bytes. :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( proc = await asyncio.create_subprocess_exec(
*args, *args,
@@ -196,7 +185,14 @@ class ProcessingManager:
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
try: 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: except asyncio.CancelledError:
proc.kill() proc.kill()
await proc.wait() await proc.wait()
@@ -211,6 +207,36 @@ class ProcessingManager:
return stdout, stderr 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( async def _probe_duration(
self, self,
file_path: Path, file_path: Path,
@@ -221,8 +247,7 @@ class ProcessingManager:
:return: Duration in seconds. :return: Duration in seconds.
:raises RuntimeError: If ffprobe fails. :raises RuntimeError: If ffprobe fails.
""" """
stdout, _ = await self._run_ffprocess( stdout, _ = await self._run_ffprobe(
"ffprobe",
"-v", "-v",
"quiet", "quiet",
"-print_format", "-print_format",
@@ -230,6 +255,5 @@ class ProcessingManager:
"-show_format", "-show_format",
str(file_path), str(file_path),
) )
data = orjson.loads(stdout) data = orjson.loads(stdout)
return float(data["format"]["duration"]) 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 from __future__ import annotations
import asyncio from datetime import datetime
import math
import shutil
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
from aiohttp import web from aiohttp import web
from owlbot.api import RouteContext, on_route from owlbot.api import RouteContext, on_route
from .types import EditorSession, get_state from .manager import get_manager
from .types import ClipNotFoundError, InvalidClipParamsError, SessionNotFoundError
if TYPE_CHECKING:
import logging
_EDITOR_JS_PATH = Path(__file__).resolve().parent / "static" / "editor.js" _EDITOR_JS_PATH = Path(__file__).resolve().parent / "static" / "editor.js"
def _validate_clip_params( def _format_date(iso: str) -> str:
*, """Format an ISO date string for display.
start: float,
end: float,
preview_duration: float,
min_length: int,
max_length: int,
) -> str | None:
"""Validate clip start/end parameters.
:param start: Start time in seconds. :param iso: ISO 8601 date string.
:param end: End time in seconds. :return: Human-readable date like "April 13, 2026".
: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.
""" """
if not math.isfinite(start) or not math.isfinite(end): return datetime.fromisoformat(iso).strftime("%B %-d, %Y")
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())
def _error_page(ctx: RouteContext, status: int, message: str) -> web.Response: 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. :return: HTML response with the editor, or error page.
""" """
token = ctx.match_info["token"] 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.") return _error_page(ctx, 404, "Session not found or expired.")
preview_url = ctx.routes.url_for(f"/edit/{token}/preview") 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. :return: The preview MP4 as a streaming response.
""" """
token = ctx.match_info["token"] 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) return web.Response(status=404)
if not session.preview_path.exists(): 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: async def editor_submit(ctx: RouteContext) -> web.Response:
"""Handle clip editor form submission. """Handle clip editor form submission.
Validates parameters, processes the clip inline with ffmpeg, and Validates parameters, processes the clip, and redirects to the
redirects to the finished clip page. finished clip page.
:param ctx: The route context. :param ctx: The route context.
:return: Redirect to the clip page, or error response. :return: Redirect to the clip page, or error response.
""" """
token = ctx.match_info["token"] token = ctx.match_info["token"]
session = _get_session(ctx, token) manager = get_manager(ctx.module)
if session is None:
return _error_page(ctx, 404, "Session not found or expired.")
data = await ctx.request.post() 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.") return _error_page(ctx, 400, "Invalid start or end time.")
title = str(data.get("title", "")).strip()[:200] or None 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: try:
# Cut clip from preview. clip = await manager.create_clip(token, start, end, title)
temp_clip_path = work_dir / "clip.mp4" except SessionNotFoundError:
actual_duration = await manager.create_clip( return _error_page(ctx, 404, "Session not found or expired.")
session.preview_path, except InvalidClipParamsError as e:
temp_clip_path, return _error_page(ctx, 400, e.reason)
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)
except Exception: except Exception:
ctx.logger.error("Failed to create clip.", exc_info=True) 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.") 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"]) @on_route("/view/{clip_id}", methods=["GET"])
@@ -346,21 +176,20 @@ async def clip_page(ctx: RouteContext) -> web.Response:
except ValueError: except ValueError:
return _error_page(ctx, 404, "Clip not found.") return _error_page(ctx, 404, "Clip not found.")
row = await ctx.storage.fetch_one( manager = get_manager(ctx.module)
"SELECT id, title, creator, created_at FROM clips WHERE id = ?", try:
(clip_id,), clip = await manager.get_clip(clip_id)
) except ClipNotFoundError:
if row is None:
return _error_page(ctx, 404, "Clip not found.") return _error_page(ctx, 404, "Clip not found.")
video_url = ctx.routes.url_for(f"/view/{clip_id}/video") video_url = ctx.routes.url_for(f"/view/{clip_id}/video")
page = ctx.templates.render( page = ctx.templates.render(
"clip.html", "clip.html",
clip_id=row["id"], clip_id=clip.id,
title=row["title"], title=clip.title,
creator=row["creator"], creator=clip.creator,
created_at=row["created_at"], created_at=clip.created_at,
video_url=video_url, video_url=video_url,
list_url=ctx.routes.url_for("/list"), list_url=ctx.routes.url_for("/list"),
) )
@@ -380,15 +209,13 @@ async def clip_video(ctx: RouteContext) -> web.StreamResponse:
except ValueError: except ValueError:
return web.Response(status=404) return web.Response(status=404)
row = await ctx.storage.fetch_one( manager = get_manager(ctx.module)
"SELECT id FROM clips WHERE id = ?", try:
(clip_id,), await manager.get_clip(clip_id)
) except ClipNotFoundError:
if row is None:
return web.Response(status=404) return web.Response(status=404)
clips_dir = Path(str(ctx.config.get("clips_dir"))) clip_path = manager.clips_dir / f"{clip_id}.mp4"
clip_path = clips_dir / f"{clip_id}.mp4"
if not clip_path.exists(): if not clip_path.exists():
return web.Response(status=404) return web.Response(status=404)
@@ -407,15 +234,13 @@ async def clip_thumbnail(ctx: RouteContext) -> web.StreamResponse:
except ValueError: except ValueError:
return web.Response(status=404) return web.Response(status=404)
row = await ctx.storage.fetch_one( manager = get_manager(ctx.module)
"SELECT id FROM clips WHERE id = ?", try:
(clip_id,), await manager.get_clip(clip_id)
) except ClipNotFoundError:
if row is None:
return web.Response(status=404) return web.Response(status=404)
clips_dir = Path(str(ctx.config.get("clips_dir"))) thumbnail_path = manager.clips_dir / f"{clip_id}.webp"
thumbnail_path = clips_dir / f"{clip_id}.webp"
if not thumbnail_path.exists(): if not thumbnail_path.exists():
return web.Response(status=404) return web.Response(status=404)
@@ -429,23 +254,14 @@ async def clips_list_page(ctx: RouteContext) -> web.Response:
:param ctx: The route context. :param ctx: The route context.
:return: HTML response with the clips list. :return: HTML response with the clips list.
""" """
rows = await ctx.storage.fetch_all( manager = get_manager(ctx.module)
"SELECT id, title, created_at FROM clips ORDER BY created_at DESC" 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") 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"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for clip in clips %} {% for clip in clips %}
<div class="col"> <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"> <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"> <div class="card-body">
<h5 class="card-title">{{ clip.title }}</h5> <h5 class="card-title">{{ clip.title or 'Clip #' ~ clip.id }}</h5>
<p class="card-text text-body-secondary">{{ clip.created_at }}</p> <p class="card-text text-body-secondary">{{ format_date(clip.created_at) }}</p>
</div> </div>
</div> </div>
</a> </a>
+66 -49
View File
@@ -12,69 +12,86 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""Typed state containers for the clips module.""" """Domain types and errors for the clips module."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
import asyncio import aiosqlite
from pathlib import Path
from owlbot.api import ModuleContext
from .cache import ChunkCache
from .processing import ProcessingManager
@dataclass(slots=True) @dataclass(frozen=True, slots=True)
class EditorSession: class Clip:
"""A clip editor session.""" """Immutable snapshot of a persisted clip."""
preview_path: Path id: int
work_dir: Path title: str | None
duration: float
creator: str 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) :param row: A database row with clip columns.
class ModuleState: :return: A new Clip instance.
"""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.
""" """
if self._manager is None: return cls(
msg = "ProcessingManager is not initialized." id=row["id"],
raise RuntimeError(msg) title=row["title"],
return self._manager creator=row["creator"],
created_at=row["created_at"],
@manager.setter duration=row["duration"],
def manager(self, value: ProcessingManager | None) -> None: )
self._manager = value
def get_state(ctx: ModuleContext) -> ModuleState: class ClipError(Exception):
"""Retrieve the ModuleState from the module context. """Base class for clip module domain errors."""
:param ctx: The module context.
:return: The ModuleState instance. class ClipNotFoundError(ClipError):
:raises RuntimeError: If ModuleState has not been initialized. """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") self.clip_id = clip_id
if not isinstance(state, ModuleState): super().__init__(f"clip not found: {clip_id}")
raise RuntimeError("ModuleState is not initialized.")
return state
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)