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