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

This commit is contained in:
2026-04-13 13:11:20 -04:00
parent 5e712d2059
commit d3e3460ce8
10 changed files with 1142 additions and 909 deletions
+54 -238
View File
@@ -16,116 +16,26 @@
from __future__ import annotations
import asyncio
import math
import shutil
from datetime import UTC, datetime
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
from aiohttp import web
from owlbot.api import RouteContext, on_route
from .types import EditorSession, get_state
if TYPE_CHECKING:
import logging
from .manager import get_manager
from .types import ClipNotFoundError, InvalidClipParamsError, SessionNotFoundError
_EDITOR_JS_PATH = Path(__file__).resolve().parent / "static" / "editor.js"
def _validate_clip_params(
*,
start: float,
end: float,
preview_duration: float,
min_length: int,
max_length: int,
) -> str | None:
"""Validate clip start/end parameters.
def _format_date(iso: str) -> str:
"""Format an ISO date string for display.
:param start: Start time in seconds.
:param end: End time in seconds.
:param preview_duration: Duration of the preview in seconds.
:param min_length: Minimum clip length in seconds.
:param max_length: Maximum clip length in seconds.
:return: Error message string, or None if valid.
:param iso: ISO 8601 date string.
:return: Human-readable date like "April 13, 2026".
"""
if not math.isfinite(start) or not math.isfinite(end):
return "Invalid start or end time."
if start < 0:
return "Start time cannot be negative."
if end <= start:
return "End time must be after start time."
if end > preview_duration:
return f"End time exceeds preview duration ({preview_duration:.1f}s)."
duration = end - start
if duration < min_length:
return f"Clip is too short. Minimum length is {min_length} seconds."
if duration > max_length:
return f"Clip is too long. Maximum length is {max_length} seconds."
return None
def _get_session(ctx: RouteContext, token: str) -> EditorSession | None:
"""Look up an editor session by token, returning None if missing.
:param ctx: The route context.
:param token: The editor session token.
:return: The session, or None.
"""
sessions = get_state(ctx.module).sessions
session = sessions.get(token)
if session is None:
ctx.module.logger.debug(
f"Session lookup failed: token={token[:8]}... not found."
)
return None
return session
def cleanup_session(sessions: dict[str, EditorSession], token: str) -> None:
"""Remove a session and clean up its working directory.
:param sessions: The sessions dict from module state.
:param token: The session token to remove.
"""
session = sessions.pop(token, None)
if session is not None:
if session.expiry_task is not None:
session.expiry_task.cancel()
shutil.rmtree(session.work_dir, ignore_errors=True)
def schedule_session_expiry(
sessions: dict[str, EditorSession],
token: str,
session: EditorSession,
delay: float,
logger: logging.Logger,
) -> None:
"""Schedule a task that cleans up the session after *delay* seconds.
Any existing expiry task on *session* is cancelled first.
:param sessions: The sessions dict from module state.
:param token: The session token.
:param session: The editor session.
:param delay: Seconds until expiry.
:param logger: Logger instance for debug messages.
"""
if session.expiry_task is not None:
session.expiry_task.cancel()
async def _expire() -> None:
await asyncio.sleep(delay)
if sessions.get(token) is not session:
return
logger.debug("Session expired: token=%s...", token[:8])
cleanup_session(sessions, token)
session.expiry_task = asyncio.create_task(_expire())
return datetime.fromisoformat(iso).strftime("%B %-d, %Y")
def _error_page(ctx: RouteContext, status: int, message: str) -> web.Response:
@@ -152,9 +62,11 @@ async def editor_page(ctx: RouteContext) -> web.Response:
:return: HTML response with the editor, or error page.
"""
token = ctx.match_info["token"]
session = _get_session(ctx, token)
manager = get_manager(ctx.module)
if session is None:
try:
session = manager.get_session(token)
except SessionNotFoundError:
return _error_page(ctx, 404, "Session not found or expired.")
preview_url = ctx.routes.url_for(f"/edit/{token}/preview")
@@ -188,9 +100,10 @@ async def editor_preview_video(ctx: RouteContext) -> web.StreamResponse:
:return: The preview MP4 as a streaming response.
"""
token = ctx.match_info["token"]
session = _get_session(ctx, token)
if session is None:
try:
session = get_manager(ctx.module).get_session(token)
except SessionNotFoundError:
return web.Response(status=404)
if not session.preview_path.exists():
@@ -216,17 +129,14 @@ async def editor_js(ctx: RouteContext) -> web.StreamResponse:
async def editor_submit(ctx: RouteContext) -> web.Response:
"""Handle clip editor form submission.
Validates parameters, processes the clip inline with ffmpeg, and
redirects to the finished clip page.
Validates parameters, processes the clip, and redirects to the
finished clip page.
:param ctx: The route context.
:return: Redirect to the clip page, or error response.
"""
token = ctx.match_info["token"]
session = _get_session(ctx, token)
if session is None:
return _error_page(ctx, 404, "Session not found or expired.")
manager = get_manager(ctx.module)
data = await ctx.request.post()
@@ -237,101 +147,21 @@ async def editor_submit(ctx: RouteContext) -> web.Response:
return _error_page(ctx, 400, "Invalid start or end time.")
title = str(data.get("title", "")).strip()[:200] or None
preview_duration = session.duration
min_length = int(ctx.config.get("min_clip_length"))
max_length = int(ctx.config.get("max_clip_length"))
error = _validate_clip_params(
start=start,
end=end,
preview_duration=preview_duration,
min_length=min_length,
max_length=max_length,
)
if error is not None:
ctx.logger.debug("Clip submit validation failed: %s", error)
return _error_page(ctx, 400, error)
# Remove session to prevent double-submission.
sessions = get_state(ctx.module).sessions
sessions.pop(token, None)
if session.expiry_task is not None:
session.expiry_task.cancel()
clips_dir = Path(str(ctx.config.get("clips_dir")))
work_dir = session.work_dir
manager = get_state(ctx.module).manager
clip_id: int | None = None
clip_path: Path | None = None
try:
# Cut clip from preview.
temp_clip_path = work_dir / "clip.mp4"
actual_duration = await manager.create_clip(
session.preview_path,
temp_clip_path,
start,
end,
)
# Insert DB row.
cursor = await ctx.storage.execute(
"INSERT INTO clips (title, creator, created_at, duration) "
"VALUES (?, ?, ?, ?)",
(title, session.creator, datetime.now(UTC).isoformat(), actual_duration),
)
clip_id = cursor.lastrowid
if clip_id is None:
msg = "Failed to retrieve last inserted row ID."
raise RuntimeError(msg)
# Move to final location.
clip_path = clips_dir / f"{clip_id}.mp4"
shutil.move(temp_clip_path, clip_path)
# Generate thumbnail (best-effort).
thumbnail_path = clips_dir / f"{clip_id}.webp"
try:
await manager.generate_thumbnail(
clip_path,
thumbnail_path,
duration=actual_duration,
)
except Exception:
ctx.logger.warning(
f"Thumbnail generation failed for clip {clip_id}.",
exc_info=True,
)
thumbnail_path.unlink(missing_ok=True)
ctx.logger.info(f"Clip {clip_id} ready ({actual_duration:.1f}s).")
clip_url = ctx.routes.url_for(f"/view/{clip_id}")
await ctx.owncast_client.send_message(
f"{session.creator} created a clip: {clip_url}"
)
return web.HTTPFound(clip_url)
clip = await manager.create_clip(token, start, end, title)
except SessionNotFoundError:
return _error_page(ctx, 404, "Session not found or expired.")
except InvalidClipParamsError as e:
return _error_page(ctx, 400, e.reason)
except Exception:
ctx.logger.error("Failed to create clip.", exc_info=True)
# Clean up partial state to avoid orphaned DB rows or files.
if clip_id is not None:
try:
await ctx.storage.execute("DELETE FROM clips WHERE id = ?", (clip_id,))
except Exception:
ctx.logger.warning(
f"Failed to clean up DB row for clip {clip_id}.",
exc_info=True,
)
if clip_path is not None:
clip_path.unlink(missing_ok=True)
return _error_page(ctx, 500, "Clip processing failed.")
finally:
shutil.rmtree(work_dir, ignore_errors=True)
clip_url = ctx.routes.url_for(f"/view/{clip.id}")
await ctx.owncast_client.send_message(f"{clip.creator} created a clip: {clip_url}")
return web.HTTPFound(clip_url)
@on_route("/view/{clip_id}", methods=["GET"])
@@ -346,21 +176,20 @@ async def clip_page(ctx: RouteContext) -> web.Response:
except ValueError:
return _error_page(ctx, 404, "Clip not found.")
row = await ctx.storage.fetch_one(
"SELECT id, title, creator, created_at FROM clips WHERE id = ?",
(clip_id,),
)
if row is None:
manager = get_manager(ctx.module)
try:
clip = await manager.get_clip(clip_id)
except ClipNotFoundError:
return _error_page(ctx, 404, "Clip not found.")
video_url = ctx.routes.url_for(f"/view/{clip_id}/video")
page = ctx.templates.render(
"clip.html",
clip_id=row["id"],
title=row["title"],
creator=row["creator"],
created_at=row["created_at"],
clip_id=clip.id,
title=clip.title,
creator=clip.creator,
created_at=clip.created_at,
video_url=video_url,
list_url=ctx.routes.url_for("/list"),
)
@@ -380,15 +209,13 @@ async def clip_video(ctx: RouteContext) -> web.StreamResponse:
except ValueError:
return web.Response(status=404)
row = await ctx.storage.fetch_one(
"SELECT id FROM clips WHERE id = ?",
(clip_id,),
)
if row is None:
manager = get_manager(ctx.module)
try:
await manager.get_clip(clip_id)
except ClipNotFoundError:
return web.Response(status=404)
clips_dir = Path(str(ctx.config.get("clips_dir")))
clip_path = clips_dir / f"{clip_id}.mp4"
clip_path = manager.clips_dir / f"{clip_id}.mp4"
if not clip_path.exists():
return web.Response(status=404)
@@ -407,15 +234,13 @@ async def clip_thumbnail(ctx: RouteContext) -> web.StreamResponse:
except ValueError:
return web.Response(status=404)
row = await ctx.storage.fetch_one(
"SELECT id FROM clips WHERE id = ?",
(clip_id,),
)
if row is None:
manager = get_manager(ctx.module)
try:
await manager.get_clip(clip_id)
except ClipNotFoundError:
return web.Response(status=404)
clips_dir = Path(str(ctx.config.get("clips_dir")))
thumbnail_path = clips_dir / f"{clip_id}.webp"
thumbnail_path = manager.clips_dir / f"{clip_id}.webp"
if not thumbnail_path.exists():
return web.Response(status=404)
@@ -429,23 +254,14 @@ async def clips_list_page(ctx: RouteContext) -> web.Response:
:param ctx: The route context.
:return: HTML response with the clips list.
"""
rows = await ctx.storage.fetch_all(
"SELECT id, title, created_at FROM clips ORDER BY created_at DESC"
manager = get_manager(ctx.module)
clips = await manager.list_clips()
page = ctx.templates.render(
"list.html",
clips=clips,
url_for=ctx.routes.url_for,
format_date=_format_date,
)
clips = [
{
"id": row["id"],
"title": row["title"] or f"Clip #{row['id']}",
"created_at": datetime.fromisoformat(row["created_at"]).strftime(
"%B %-d, %Y"
),
"url": ctx.routes.url_for(f"/view/{row['id']}"),
"thumbnail_url": ctx.routes.url_for(f"/view/{row['id']}/thumbnail"),
}
for row in rows
]
page = ctx.templates.render("list.html", clips=clips)
return web.Response(text=page, content_type="text/html")