CI / Formatting (push) Successful in 21s
CI / Linting (push) Successful in 4s
CI / Tests (Python 3.12) (push) Successful in 2m53s
CI / Tests (Python 3.13) (push) Successful in 2m52s
CI / Tests (Python 3.14) (push) Successful in 2m42s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
268 lines
7.9 KiB
Python
268 lines
7.9 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.
|
|
|
|
"""HTTP routes for the clips module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from aiohttp import web
|
|
|
|
from owlbot.api import RouteContext, on_route
|
|
|
|
from .manager import get_manager
|
|
from .types import ClipNotFoundError, InvalidClipParamsError, SessionNotFoundError
|
|
|
|
_EDITOR_JS_PATH = Path(__file__).resolve().parent / "static" / "editor.js"
|
|
|
|
|
|
def _format_date(iso: str) -> str:
|
|
"""Format an ISO date string for display.
|
|
|
|
:param iso: ISO 8601 date string.
|
|
:return: Human-readable date like "April 13, 2026".
|
|
"""
|
|
return datetime.fromisoformat(iso).strftime("%B %-d, %Y")
|
|
|
|
|
|
def _error_page(ctx: RouteContext, status: int, message: str) -> web.Response:
|
|
"""Render an error page with navigation back to the clips list.
|
|
|
|
:param ctx: The route context.
|
|
:param status: HTTP status code.
|
|
:param message: Error message to display.
|
|
:return: HTML error response.
|
|
"""
|
|
page = ctx.templates.render(
|
|
"error.html",
|
|
message=message,
|
|
list_url=ctx.routes.url_for("/list"),
|
|
)
|
|
return web.Response(status=status, text=page, content_type="text/html")
|
|
|
|
|
|
@on_route("/edit/{token}", methods=["GET"])
|
|
async def editor_page(ctx: RouteContext) -> web.Response:
|
|
"""Serve the clip editor page.
|
|
|
|
:param ctx: The route context.
|
|
:return: HTML response with the editor, or error page.
|
|
"""
|
|
token = ctx.match_info["token"]
|
|
manager = get_manager(ctx.module)
|
|
|
|
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")
|
|
min_length = int(ctx.config.get("min_clip_length"))
|
|
max_length = int(ctx.config.get("max_clip_length"))
|
|
duration = session.duration
|
|
|
|
# Clamp max_length to actual preview duration.
|
|
effective_max = min(max_length, int(duration))
|
|
|
|
page = ctx.templates.render(
|
|
"editor.html",
|
|
preview_url=preview_url,
|
|
duration=duration,
|
|
min_length=min_length,
|
|
max_length=effective_max,
|
|
token=token,
|
|
submit_url=ctx.routes.url_for(f"/edit/{token}"),
|
|
list_url=ctx.routes.url_for("/list"),
|
|
editor_js_url=ctx.routes.url_for("/static/editor.js"),
|
|
)
|
|
|
|
return web.Response(text=page, content_type="text/html")
|
|
|
|
|
|
@on_route("/edit/{token}/preview", methods=["GET"])
|
|
async def editor_preview_video(ctx: RouteContext) -> web.StreamResponse:
|
|
"""Serve the preview video file for the clip editor.
|
|
|
|
:param ctx: The route context.
|
|
:return: The preview MP4 as a streaming response.
|
|
"""
|
|
token = ctx.match_info["token"]
|
|
|
|
try:
|
|
session = get_manager(ctx.module).get_session(token)
|
|
except SessionNotFoundError:
|
|
return web.Response(status=404)
|
|
|
|
if not session.preview_path.exists():
|
|
return web.Response(status=404)
|
|
|
|
return web.FileResponse(session.preview_path)
|
|
|
|
|
|
@on_route("/static/editor.js", methods=["GET"])
|
|
async def editor_js(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
|
|
"""Serve the clip editor JavaScript.
|
|
|
|
:param ctx: The route context.
|
|
:return: The editor.js file with caching headers.
|
|
"""
|
|
return web.FileResponse(
|
|
_EDITOR_JS_PATH,
|
|
headers={"Cache-Control": "max-age=86400"},
|
|
)
|
|
|
|
|
|
@on_route("/edit/{token}", methods=["POST"])
|
|
async def editor_submit(ctx: RouteContext) -> web.Response:
|
|
"""Handle clip editor form submission.
|
|
|
|
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"]
|
|
manager = get_manager(ctx.module)
|
|
|
|
data = await ctx.request.post()
|
|
|
|
try:
|
|
start = float(str(data.get("start", "0")))
|
|
end = float(str(data.get("end", "0")))
|
|
except ValueError:
|
|
return _error_page(ctx, 400, "Invalid start or end time.")
|
|
|
|
title = str(data.get("title", "")).strip()[:200] or None
|
|
|
|
try:
|
|
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.exception("Failed to create clip.")
|
|
return _error_page(ctx, 500, "Clip processing failed.")
|
|
|
|
clip_url = ctx.routes.url_for(f"/view/{clip.id}")
|
|
await ctx.owncast_client.send_message(f"{clip.creator} created a clip: {clip_url}")
|
|
|
|
raise web.HTTPFound(clip_url)
|
|
|
|
|
|
@on_route("/view/{clip_id}", methods=["GET"])
|
|
async def clip_page(ctx: RouteContext) -> web.Response:
|
|
"""Serve an individual clip page.
|
|
|
|
:param ctx: The route context.
|
|
:return: HTML response with the clip page.
|
|
"""
|
|
try:
|
|
clip_id = int(ctx.match_info["clip_id"])
|
|
except ValueError:
|
|
return _error_page(ctx, 404, "Clip not found.")
|
|
|
|
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=clip.id,
|
|
title=clip.title,
|
|
creator=clip.creator,
|
|
created_at=clip.created_at,
|
|
video_url=video_url,
|
|
list_url=ctx.routes.url_for("/list"),
|
|
)
|
|
|
|
return web.Response(text=page, content_type="text/html")
|
|
|
|
|
|
@on_route("/view/{clip_id}/video", methods=["GET"])
|
|
async def clip_video(ctx: RouteContext) -> web.StreamResponse:
|
|
"""Serve a clip video file.
|
|
|
|
:param ctx: The route context.
|
|
:return: The MP4 file as a streaming response.
|
|
"""
|
|
try:
|
|
clip_id = int(ctx.match_info["clip_id"])
|
|
except ValueError:
|
|
return web.Response(status=404)
|
|
|
|
manager = get_manager(ctx.module)
|
|
try:
|
|
await manager.get_clip(clip_id)
|
|
except ClipNotFoundError:
|
|
return web.Response(status=404)
|
|
|
|
clip_path = manager.clips_dir / f"{clip_id}.mp4"
|
|
if not clip_path.exists():
|
|
return web.Response(status=404)
|
|
|
|
return web.FileResponse(clip_path)
|
|
|
|
|
|
@on_route("/view/{clip_id}/thumbnail", methods=["GET"])
|
|
async def clip_thumbnail(ctx: RouteContext) -> web.StreamResponse:
|
|
"""Serve a clip thumbnail image.
|
|
|
|
:param ctx: The route context.
|
|
:return: The WebP image as a response, or 404.
|
|
"""
|
|
try:
|
|
clip_id = int(ctx.match_info["clip_id"])
|
|
except ValueError:
|
|
return web.Response(status=404)
|
|
|
|
manager = get_manager(ctx.module)
|
|
try:
|
|
await manager.get_clip(clip_id)
|
|
except ClipNotFoundError:
|
|
return web.Response(status=404)
|
|
|
|
thumbnail_path = manager.clips_dir / f"{clip_id}.webp"
|
|
if not thumbnail_path.exists():
|
|
return web.Response(status=404)
|
|
|
|
return web.FileResponse(thumbnail_path)
|
|
|
|
|
|
@on_route("/list", methods=["GET"])
|
|
async def clips_list_page(ctx: RouteContext) -> web.Response:
|
|
"""Serve a page listing all clips.
|
|
|
|
:param ctx: The route context.
|
|
:return: HTML response with the clips list.
|
|
"""
|
|
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,
|
|
)
|
|
|
|
return web.Response(text=page, content_type="text/html")
|