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
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
|
||||
@@ -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]
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user