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:
@@ -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
|
||||
Reference in New Issue
Block a user