Dependency Audit / Dependency Audit (push) Successful in 18s
CI / Formatting (push) Successful in 14s
CI / Linting (push) Successful in 15s
CI / Tests (Python 3.12) (push) Successful in 29s
CI / Tests (Python 3.13) (push) Successful in 28s
CI / Tests (Python 3.14) (push) Successful in 27s
CI / Type Checking (push) Successful in 25s
CI / Spelling (push) Successful in 15s
202 lines
6.0 KiB
Python
202 lines
6.0 KiB
Python
# 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.
|
|
|
|
"""Clips module for Owlbot.
|
|
|
|
Allows chat users to create clips from the live Owncast stream. Caches the HLS
|
|
stream in real time and provides a browser-based clip editor.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from owlbot.api import (
|
|
EventContext,
|
|
EventType,
|
|
ModuleContext,
|
|
StreamStartedEvent,
|
|
StreamStoppedEvent,
|
|
on_event,
|
|
on_setup,
|
|
on_teardown,
|
|
)
|
|
|
|
from .cache import start_caching, stop_caching
|
|
from .commands import clip_command, clips_command, delclip_command
|
|
from .processing import ProcessingManager
|
|
from .routes import (
|
|
cleanup_session,
|
|
clip_page,
|
|
clip_thumbnail,
|
|
clip_video,
|
|
clips_list_page,
|
|
editor_js,
|
|
editor_page,
|
|
editor_preview_video,
|
|
editor_submit,
|
|
)
|
|
from .types import ModuleState, get_state
|
|
|
|
__all__ = [
|
|
"clip_command",
|
|
"clip_page",
|
|
"clip_thumbnail",
|
|
"clip_video",
|
|
"clips_command",
|
|
"clips_list_page",
|
|
"delclip_command",
|
|
"editor_js",
|
|
"editor_page",
|
|
"editor_preview_video",
|
|
"editor_submit",
|
|
"on_stream_started",
|
|
"on_stream_stopped",
|
|
"setup",
|
|
"teardown",
|
|
]
|
|
|
|
|
|
@on_setup
|
|
async def setup(ctx: ModuleContext) -> None:
|
|
"""Initialize the clips module.
|
|
|
|
Validates ffmpeg availability, creates the database schema, registers
|
|
config defaults, creates the clips storage directory, and starts caching
|
|
if a stream is already live.
|
|
|
|
:param ctx: Module context with config, storage, and other services.
|
|
"""
|
|
for binary in ("ffmpeg", "ffprobe"):
|
|
if shutil.which(binary) is None:
|
|
msg = (
|
|
f"'{binary}' not found in PATH. "
|
|
f"The clips module requires ffmpeg to be installed."
|
|
)
|
|
raise RuntimeError(msg)
|
|
|
|
await ctx.storage.execute("""
|
|
CREATE TABLE IF NOT EXISTS clips (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT,
|
|
creator TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
duration REAL
|
|
)
|
|
""")
|
|
|
|
ctx.config.register_defaults(
|
|
{
|
|
"cache_duration": 300,
|
|
"min_clip_length": 5,
|
|
"max_clip_length": 120,
|
|
"clips_dir": "data/clips",
|
|
"session_expiry": 900,
|
|
"grace_period": 120,
|
|
}
|
|
)
|
|
|
|
# Create clips directory if it doesn't exist.
|
|
clips_dir = Path(str(ctx.config.get("clips_dir")))
|
|
clips_dir.mkdir(parents=True, exist_ok=True) # noqa: ASYNC240
|
|
|
|
# Initialize runtime state.
|
|
ctx.state["clips"] = ModuleState()
|
|
module_state = get_state(ctx)
|
|
module_state.manager = ProcessingManager(ctx.logger)
|
|
|
|
# Check if stream is already live.
|
|
try:
|
|
status = await ctx.owncast_client.get_status()
|
|
if status.get("online", False):
|
|
ctx.logger.info("Stream is already live. Starting HLS cache.")
|
|
await start_caching(ctx)
|
|
except Exception:
|
|
ctx.logger.warning(
|
|
"Could not check Owncast status during setup.", exc_info=True
|
|
)
|
|
|
|
|
|
@on_event(EventType.STREAM_STARTED)
|
|
async def on_stream_started(ctx: EventContext[StreamStartedEvent]) -> None:
|
|
"""Start HLS caching when the stream goes live.
|
|
|
|
:param ctx: The event context.
|
|
"""
|
|
# If there's a grace period task running, cancel it and wait for it to finish.
|
|
module_state = get_state(ctx.module)
|
|
if module_state.grace_task is not None:
|
|
ctx.module.logger.debug("Cancelling active grace period task.")
|
|
module_state.grace_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await module_state.grace_task
|
|
module_state.grace_task = None
|
|
|
|
# Wipe old cache before starting fresh.
|
|
await stop_caching(ctx.module)
|
|
await start_caching(ctx.module)
|
|
|
|
|
|
@on_event(EventType.STREAM_STOPPED)
|
|
async def on_stream_stopped(ctx: EventContext[StreamStoppedEvent]) -> None:
|
|
"""Begin grace period when the stream goes offline.
|
|
|
|
:param ctx: The event context.
|
|
"""
|
|
|
|
async def _grace_period(module_ctx: ModuleContext) -> None:
|
|
grace = int(module_ctx.config.get("grace_period"))
|
|
module_ctx.logger.info(f"Stream stopped. Grace period: {grace}s.")
|
|
await asyncio.sleep(grace)
|
|
await stop_caching(module_ctx)
|
|
module_ctx.logger.info("Grace period ended. Cache cleaned up.")
|
|
|
|
ctx.module.logger.debug("Stream stopped event received. Scheduling grace period.")
|
|
get_state(ctx.module).grace_task = asyncio.create_task(_grace_period(ctx.module))
|
|
|
|
|
|
@on_teardown
|
|
async def teardown(ctx: ModuleContext) -> None:
|
|
"""Clean up the clips module.
|
|
|
|
Cancels grace/expiry tasks and cleans up temp files.
|
|
|
|
:param ctx: Module context.
|
|
"""
|
|
module_state = get_state(ctx)
|
|
|
|
# 1. Cancel grace period task if running.
|
|
if module_state.grace_task is not None:
|
|
ctx.logger.debug("Cancelling grace period task during teardown.")
|
|
module_state.grace_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await module_state.grace_task
|
|
|
|
# 2. Stop caching.
|
|
await stop_caching(ctx)
|
|
|
|
# 3. Cancel all session expiry tasks (prevents races during drain).
|
|
for session in module_state.sessions.values():
|
|
if session.expiry_task is not None:
|
|
session.expiry_task.cancel()
|
|
|
|
# 4. Clean up editor sessions and their working directories.
|
|
for token in list(module_state.sessions):
|
|
cleanup_session(module_state.sessions, token)
|
|
|
|
ctx.logger.info("Clips module cleaned up.")
|