Added clips module.
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
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
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
# 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.")
|
||||
Reference in New Issue
Block a user