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:
@@ -16,85 +16,46 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from owlbot.api import CommandContext, on_command
|
||||
|
||||
from .routes import schedule_session_expiry
|
||||
from .types import EditorSession, get_state
|
||||
from .manager import get_manager
|
||||
from .types import CacheUnavailableError, ClipNotFoundError, InsufficientCacheError
|
||||
|
||||
|
||||
@on_command("clip", cooldown=15)
|
||||
async def clip_command(ctx: CommandContext) -> None:
|
||||
"""Create a clip from the current stream.
|
||||
|
||||
Uses the concat protocol to read directly from the HLS cache while
|
||||
suppressing pruning, then sends the user a link to the clip editor.
|
||||
Generates a preview from the HLS cache, creates an editor session,
|
||||
and sends the user a link to the clip editor.
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
module = ctx.module
|
||||
module_state = get_state(module)
|
||||
|
||||
if module_state.cache is None:
|
||||
ctx.logger.debug("Clip command rejected: cache is None.")
|
||||
try:
|
||||
token = await get_manager(ctx.module).start_editor_session(
|
||||
ctx.user.display_name
|
||||
)
|
||||
except CacheUnavailableError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Clipping is unavailable for this stream."
|
||||
)
|
||||
return
|
||||
|
||||
if not module_state.cache.enough_for_clipping:
|
||||
ctx.logger.debug("Clip command rejected: not enough cached data.")
|
||||
except InsufficientCacheError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Not enough stream data is available yet. Please try again later."
|
||||
)
|
||||
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:
|
||||
module.logger.error("Failed to generate clip preview.", exc_info=True)
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
await ctx.owncast_client.send_message(
|
||||
"Failed to create clip preview. Please try again."
|
||||
)
|
||||
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}")
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
ctx.chat_event.client_id,
|
||||
f"Your clip preview is ready! Edit it here: {url}",
|
||||
)
|
||||
|
||||
module.logger.info(
|
||||
f"Clip preview generated for {ctx.user.display_name} "
|
||||
f"({duration:.1f}s, token={token[:8]}...)"
|
||||
f'<a href="{url}"><u>Click here to create a clip</u></a>.',
|
||||
unsanitized=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -105,7 +66,10 @@ async def clips_command(ctx: CommandContext) -> None:
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
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)
|
||||
@@ -125,23 +89,10 @@ async def delclip_command(ctx: CommandContext) -> None:
|
||||
await ctx.owncast_client.send_message("Usage: !delclip <clip_id>")
|
||||
return
|
||||
|
||||
row = await ctx.storage.fetch_one("SELECT id FROM clips WHERE id = ?", (clip_id,))
|
||||
if not row:
|
||||
ctx.logger.debug("Clip %s not found for deletion.", clip_id)
|
||||
await ctx.owncast_client.send_message(f"Clip {clip_id} not found.")
|
||||
try:
|
||||
await get_manager(ctx.module).delete_clip(clip_id)
|
||||
except ClipNotFoundError as e:
|
||||
await ctx.owncast_client.send_message(f"Clip {e.clip_id} not found.")
|
||||
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.")
|
||||
|
||||
Reference in New Issue
Block a user