[claude] Automated Episode Compiler — Highlights to Published Video (#880) (#1318)

This commit is contained in:
2026-03-24 02:05:14 +00:00
parent 1ddbf353ed
commit f0841bd34e
34 changed files with 2431 additions and 30 deletions

View File

@@ -486,6 +486,48 @@ class Settings(BaseSettings):
# Relative to repo root. Written by the GABS observer loop.
gabs_journal_path: str = "memory/bannerlord/journal.md"
# ── Content Pipeline (Issue #880) ─────────────────────────────────
# End-to-end pipeline: highlights → clips → composed episode → publish.
# FFmpeg must be on PATH for clip extraction; MoviePy ≥ 2.0 for composition.
# Output directories (relative to repo root or absolute)
content_clips_dir: str = "data/content/clips"
content_episodes_dir: str = "data/content/episodes"
content_narration_dir: str = "data/content/narration"
# TTS backend: "kokoro" (mlx_audio, Apple Silicon) or "piper" (cross-platform)
content_tts_backend: str = "auto"
# Kokoro-82M voice identifier — override with CONTENT_TTS_VOICE
content_tts_voice: str = "af_sky"
# Piper model file path — override with CONTENT_PIPER_MODEL
content_piper_model: str = "en_US-lessac-medium"
# Episode template — path to intro/outro image assets
content_intro_image: str = "" # e.g. "assets/intro.png"
content_outro_image: str = "" # e.g. "assets/outro.png"
# Background music library directory
content_music_library_dir: str = "data/music"
# YouTube Data API v3
# Path to the OAuth2 credentials JSON file (generated via Google Cloud Console)
content_youtube_credentials_file: str = ""
# Sidecar JSON file tracking daily upload counts (to enforce 6/day quota)
content_youtube_counter_file: str = "data/content/.youtube_counter.json"
# Nostr / Blossom publishing
# Blossom server URL — e.g. "https://blossom.primal.net"
content_blossom_server: str = ""
# Nostr relay URL for NIP-94 events — e.g. "wss://relay.damus.io"
content_nostr_relay: str = ""
# Nostr identity (hex-encoded private key — never commit this value)
content_nostr_privkey: str = ""
# Corresponding public key (hex-encoded npub)
content_nostr_pubkey: str = ""
# Meilisearch archive
content_meilisearch_url: str = "http://localhost:7700"
content_meilisearch_api_key: str = ""
# ── Scripture / Biblical Integration ──────────────────────────────
# Enable the biblical text module.
scripture_enabled: bool = True

13
src/content/__init__.py Normal file
View File

@@ -0,0 +1,13 @@
"""Content pipeline — highlights to published episode.
End-to-end pipeline: ranked highlights → extracted clips → composed episode →
published to YouTube + Nostr → indexed in Meilisearch.
Subpackages
-----------
extraction : FFmpeg-based clip extraction from recorded stream
composition : MoviePy episode builder (intro, highlights, narration, outro)
narration : TTS narration generation via Kokoro-82M / Piper
publishing : YouTube Data API v3 + Nostr (Blossom / NIP-94)
archive : Meilisearch indexing for searchable episode archive
"""

View File

@@ -0,0 +1 @@
"""Episode archive and Meilisearch indexing."""

View File

@@ -0,0 +1,243 @@
"""Meilisearch indexing for the searchable episode archive.
Each published episode is indexed as a document with searchable fields:
id : str — unique episode identifier (slug or UUID)
title : str — episode title
description : str — episode description / summary
tags : list — content tags
published_at: str — ISO-8601 timestamp
youtube_url : str — YouTube watch URL (if uploaded)
blossom_url : str — Blossom content-addressed URL (if uploaded)
duration : float — episode duration in seconds
clip_count : int — number of highlight clips
highlight_ids: list — IDs of constituent highlights
Meilisearch is an optional dependency. If the ``meilisearch`` Python client
is not installed, or the server is unreachable, :func:`index_episode` returns
a failure result without crashing.
Usage
-----
from content.archive.indexer import index_episode, search_episodes
result = await index_episode(
episode_id="ep-2026-03-23-001",
title="Top Highlights — March 2026",
description="...",
tags=["highlights", "gaming"],
published_at="2026-03-23T18:00:00Z",
youtube_url="https://www.youtube.com/watch?v=abc123",
)
hits = await search_episodes("highlights march")
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Any
from config import settings
logger = logging.getLogger(__name__)
_INDEX_NAME = "episodes"
@dataclass
class IndexResult:
"""Result of an indexing operation."""
success: bool
document_id: str | None = None
error: str | None = None
@dataclass
class EpisodeDocument:
"""A single episode document for the Meilisearch index."""
id: str
title: str
description: str = ""
tags: list[str] = field(default_factory=list)
published_at: str = ""
youtube_url: str = ""
blossom_url: str = ""
duration: float = 0.0
clip_count: int = 0
highlight_ids: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"title": self.title,
"description": self.description,
"tags": self.tags,
"published_at": self.published_at,
"youtube_url": self.youtube_url,
"blossom_url": self.blossom_url,
"duration": self.duration,
"clip_count": self.clip_count,
"highlight_ids": self.highlight_ids,
}
def _meilisearch_available() -> bool:
"""Return True if the meilisearch Python client is importable."""
try:
import importlib.util
return importlib.util.find_spec("meilisearch") is not None
except Exception:
return False
def _get_client():
"""Return a Meilisearch client configured from settings."""
import meilisearch # type: ignore[import]
url = settings.content_meilisearch_url
key = settings.content_meilisearch_api_key
return meilisearch.Client(url, key or None)
def _ensure_index_sync(client) -> None:
"""Create the episodes index with appropriate searchable attributes."""
try:
client.create_index(_INDEX_NAME, {"primaryKey": "id"})
except Exception:
pass # Index already exists
idx = client.index(_INDEX_NAME)
try:
idx.update_searchable_attributes(
["title", "description", "tags", "highlight_ids"]
)
idx.update_filterable_attributes(["tags", "published_at"])
idx.update_sortable_attributes(["published_at", "duration"])
except Exception as exc:
logger.warning("Could not configure Meilisearch index attributes: %s", exc)
def _index_document_sync(doc: EpisodeDocument) -> IndexResult:
"""Synchronous Meilisearch document indexing."""
try:
client = _get_client()
_ensure_index_sync(client)
idx = client.index(_INDEX_NAME)
idx.add_documents([doc.to_dict()])
return IndexResult(success=True, document_id=doc.id)
except Exception as exc:
logger.warning("Meilisearch indexing failed: %s", exc)
return IndexResult(success=False, error=str(exc))
def _search_sync(query: str, limit: int) -> list[dict[str, Any]]:
"""Synchronous Meilisearch search."""
client = _get_client()
idx = client.index(_INDEX_NAME)
result = idx.search(query, {"limit": limit})
return result.get("hits", [])
async def index_episode(
episode_id: str,
title: str,
description: str = "",
tags: list[str] | None = None,
published_at: str = "",
youtube_url: str = "",
blossom_url: str = "",
duration: float = 0.0,
clip_count: int = 0,
highlight_ids: list[str] | None = None,
) -> IndexResult:
"""Index a published episode in Meilisearch.
Parameters
----------
episode_id:
Unique episode identifier.
title:
Episode title.
description:
Summary or full description.
tags:
Content tags for filtering.
published_at:
ISO-8601 publication timestamp.
youtube_url:
YouTube watch URL.
blossom_url:
Blossom content-addressed storage URL.
duration:
Episode duration in seconds.
clip_count:
Number of highlight clips.
highlight_ids:
IDs of the constituent highlight clips.
Returns
-------
IndexResult
Always returns a result; never raises.
"""
if not episode_id.strip():
return IndexResult(success=False, error="episode_id must not be empty")
if not _meilisearch_available():
logger.warning("meilisearch client not installed — episode indexing disabled")
return IndexResult(
success=False,
error="meilisearch not available — pip install meilisearch",
)
doc = EpisodeDocument(
id=episode_id,
title=title,
description=description,
tags=tags or [],
published_at=published_at,
youtube_url=youtube_url,
blossom_url=blossom_url,
duration=duration,
clip_count=clip_count,
highlight_ids=highlight_ids or [],
)
try:
return await asyncio.to_thread(_index_document_sync, doc)
except Exception as exc:
logger.warning("Episode indexing error: %s", exc)
return IndexResult(success=False, error=str(exc))
async def search_episodes(
query: str,
limit: int = 20,
) -> list[dict[str, Any]]:
"""Search the episode archive.
Parameters
----------
query:
Full-text search query.
limit:
Maximum number of results to return.
Returns
-------
list[dict]
Matching episode documents. Returns empty list on error.
"""
if not _meilisearch_available():
logger.warning("meilisearch client not installed — episode search disabled")
return []
try:
return await asyncio.to_thread(_search_sync, query, limit)
except Exception as exc:
logger.warning("Episode search error: %s", exc)
return []

View File

@@ -0,0 +1 @@
"""Episode composition from extracted clips."""

View File

@@ -0,0 +1,274 @@
"""MoviePy v2.2.1 episode builder.
Composes a full episode video from:
- Intro card (Timmy branding still image + title text)
- Highlight clips with crossfade transitions
- TTS narration audio mixed over video
- Background music from pre-generated library
- Outro card with links / subscribe prompt
MoviePy is an optional dependency. If it is not installed, all functions
return failure results instead of crashing.
Usage
-----
from content.composition.episode import build_episode
result = await build_episode(
clip_paths=["/tmp/clips/h1.mp4", "/tmp/clips/h2.mp4"],
narration_path="/tmp/narration.wav",
output_path="/tmp/episodes/ep001.mp4",
title="Top Highlights — March 2026",
)
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from pathlib import Path
from config import settings
logger = logging.getLogger(__name__)
@dataclass
class EpisodeResult:
"""Result of an episode composition attempt."""
success: bool
output_path: str | None = None
duration: float = 0.0
error: str | None = None
clip_count: int = 0
@dataclass
class EpisodeSpec:
"""Full specification for a composed episode."""
title: str
clip_paths: list[str] = field(default_factory=list)
narration_path: str | None = None
music_path: str | None = None
intro_image: str | None = None
outro_image: str | None = None
output_path: str | None = None
transition_duration: float | None = None
@property
def resolved_transition(self) -> float:
return (
self.transition_duration
if self.transition_duration is not None
else settings.video_transition_duration
)
@property
def resolved_output(self) -> str:
return self.output_path or str(
Path(settings.content_episodes_dir) / f"{_slugify(self.title)}.mp4"
)
def _slugify(text: str) -> str:
"""Convert title to a filesystem-safe slug."""
import re
slug = text.lower()
slug = re.sub(r"[^\w\s-]", "", slug)
slug = re.sub(r"[\s_]+", "-", slug)
slug = slug.strip("-")
return slug[:80] or "episode"
def _moviepy_available() -> bool:
"""Return True if moviepy is importable."""
try:
import importlib.util
return importlib.util.find_spec("moviepy") is not None
except Exception:
return False
def _compose_sync(spec: EpisodeSpec) -> EpisodeResult:
"""Synchronous MoviePy composition — run in a thread via asyncio.to_thread."""
try:
from moviepy import ( # type: ignore[import]
AudioFileClip,
ColorClip,
CompositeAudioClip,
ImageClip,
TextClip,
VideoFileClip,
concatenate_videoclips,
)
except ImportError as exc:
return EpisodeResult(success=False, error=f"moviepy not available: {exc}")
clips = []
# ── Intro card ────────────────────────────────────────────────────────────
intro_duration = 3.0
if spec.intro_image and Path(spec.intro_image).exists():
intro = ImageClip(spec.intro_image).with_duration(intro_duration)
else:
intro = ColorClip(size=(1280, 720), color=(10, 10, 30), duration=intro_duration)
try:
title_txt = TextClip(
text=spec.title,
font_size=48,
color="white",
size=(1200, None),
method="caption",
).with_duration(intro_duration)
title_txt = title_txt.with_position("center")
from moviepy import CompositeVideoClip # type: ignore[import]
intro = CompositeVideoClip([intro, title_txt])
except Exception as exc:
logger.warning("Could not add title text to intro: %s", exc)
clips.append(intro)
# ── Highlight clips with crossfade ────────────────────────────────────────
valid_clips: list = []
for path in spec.clip_paths:
if not Path(path).exists():
logger.warning("Clip not found, skipping: %s", path)
continue
try:
vc = VideoFileClip(path)
valid_clips.append(vc)
except Exception as exc:
logger.warning("Could not load clip %s: %s", path, exc)
if valid_clips:
transition = spec.resolved_transition
for vc in valid_clips:
try:
vc = vc.with_effects([]) # ensure no stale effects
clips.append(vc.crossfadein(transition))
except Exception:
clips.append(vc)
# ── Outro card ────────────────────────────────────────────────────────────
outro_duration = 5.0
if spec.outro_image and Path(spec.outro_image).exists():
outro = ImageClip(spec.outro_image).with_duration(outro_duration)
else:
outro = ColorClip(size=(1280, 720), color=(10, 10, 30), duration=outro_duration)
clips.append(outro)
if not clips:
return EpisodeResult(success=False, error="no clips to compose")
# ── Concatenate ───────────────────────────────────────────────────────────
try:
final = concatenate_videoclips(clips, method="compose")
except Exception as exc:
return EpisodeResult(success=False, error=f"concatenation failed: {exc}")
# ── Narration audio ───────────────────────────────────────────────────────
audio_tracks = []
if spec.narration_path and Path(spec.narration_path).exists():
try:
narr = AudioFileClip(spec.narration_path)
if narr.duration > final.duration:
narr = narr.subclipped(0, final.duration)
audio_tracks.append(narr)
except Exception as exc:
logger.warning("Could not load narration audio: %s", exc)
if spec.music_path and Path(spec.music_path).exists():
try:
music = AudioFileClip(spec.music_path).with_volume_scaled(0.15)
if music.duration < final.duration:
# Loop music to fill episode duration
loops = int(final.duration / music.duration) + 1
from moviepy import concatenate_audioclips # type: ignore[import]
music = concatenate_audioclips([music] * loops).subclipped(
0, final.duration
)
else:
music = music.subclipped(0, final.duration)
audio_tracks.append(music)
except Exception as exc:
logger.warning("Could not load background music: %s", exc)
if audio_tracks:
try:
mixed = CompositeAudioClip(audio_tracks)
final = final.with_audio(mixed)
except Exception as exc:
logger.warning("Audio mixing failed, continuing without audio: %s", exc)
# ── Write output ──────────────────────────────────────────────────────────
output_path = spec.resolved_output
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
try:
final.write_videofile(
output_path,
codec=settings.default_video_codec,
audio_codec="aac",
logger=None,
)
except Exception as exc:
return EpisodeResult(success=False, error=f"write_videofile failed: {exc}")
return EpisodeResult(
success=True,
output_path=output_path,
duration=final.duration,
clip_count=len(valid_clips),
)
async def build_episode(
clip_paths: list[str],
title: str,
narration_path: str | None = None,
music_path: str | None = None,
intro_image: str | None = None,
outro_image: str | None = None,
output_path: str | None = None,
transition_duration: float | None = None,
) -> EpisodeResult:
"""Compose a full episode video asynchronously.
Wraps the synchronous MoviePy work in ``asyncio.to_thread`` so the
FastAPI event loop is never blocked.
Returns
-------
EpisodeResult
Always returns a result; never raises.
"""
if not _moviepy_available():
logger.warning("moviepy not installed — episode composition disabled")
return EpisodeResult(
success=False,
error="moviepy not available — install moviepy>=2.0",
)
spec = EpisodeSpec(
title=title,
clip_paths=clip_paths,
narration_path=narration_path,
music_path=music_path,
intro_image=intro_image,
outro_image=outro_image,
output_path=output_path,
transition_duration=transition_duration,
)
try:
return await asyncio.to_thread(_compose_sync, spec)
except Exception as exc:
logger.warning("Episode composition error: %s", exc)
return EpisodeResult(success=False, error=str(exc))

View File

@@ -0,0 +1 @@
"""Clip extraction from recorded stream segments."""

View File

@@ -0,0 +1,165 @@
"""FFmpeg-based frame-accurate clip extraction from recorded stream segments.
Each highlight dict must have:
source_path : str — path to the source video file
start_time : float — clip start in seconds
end_time : float — clip end in seconds
highlight_id: str — unique identifier (used for output filename)
Clips are written to ``settings.content_clips_dir``.
FFmpeg is treated as an optional runtime dependency — if the binary is not
found, :func:`extract_clip` returns a failure result instead of crashing.
"""
from __future__ import annotations
import asyncio
import logging
import shutil
from dataclasses import dataclass
from pathlib import Path
from config import settings
logger = logging.getLogger(__name__)
@dataclass
class ClipResult:
"""Result of a single clip extraction operation."""
highlight_id: str
success: bool
output_path: str | None = None
error: str | None = None
duration: float = 0.0
def _ffmpeg_available() -> bool:
"""Return True if the ffmpeg binary is on PATH."""
return shutil.which("ffmpeg") is not None
def _build_ffmpeg_cmd(
source: str,
start: float,
end: float,
output: str,
) -> list[str]:
"""Build an ffmpeg command for frame-accurate clip extraction.
Uses ``-ss`` before ``-i`` for fast seek, then re-seeks with ``-ss``
after ``-i`` for frame accuracy. ``-avoid_negative_ts make_zero``
ensures timestamps begin at 0 in the output.
"""
duration = end - start
return [
"ffmpeg",
"-y", # overwrite output
"-ss", str(start),
"-i", source,
"-t", str(duration),
"-avoid_negative_ts", "make_zero",
"-c:v", settings.default_video_codec,
"-c:a", "aac",
"-movflags", "+faststart",
output,
]
async def extract_clip(
highlight: dict,
output_dir: str | None = None,
) -> ClipResult:
"""Extract a single clip from a source video using FFmpeg.
Parameters
----------
highlight:
Dict with keys ``source_path``, ``start_time``, ``end_time``,
and ``highlight_id``.
output_dir:
Directory to write the clip. Defaults to
``settings.content_clips_dir``.
Returns
-------
ClipResult
Always returns a result; never raises.
"""
hid = highlight.get("highlight_id", "unknown")
if not _ffmpeg_available():
logger.warning("ffmpeg not found — clip extraction disabled")
return ClipResult(highlight_id=hid, success=False, error="ffmpeg not found")
source = highlight.get("source_path", "")
if not source or not Path(source).exists():
return ClipResult(
highlight_id=hid,
success=False,
error=f"source_path not found: {source!r}",
)
start = float(highlight.get("start_time", 0))
end = float(highlight.get("end_time", 0))
if end <= start:
return ClipResult(
highlight_id=hid,
success=False,
error=f"invalid time range: start={start} end={end}",
)
dest_dir = Path(output_dir or settings.content_clips_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
output_path = dest_dir / f"{hid}.mp4"
cmd = _build_ffmpeg_cmd(source, start, end, str(output_path))
logger.debug("Running: %s", " ".join(cmd))
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
if proc.returncode != 0:
err = stderr.decode(errors="replace")[-500:]
logger.warning("ffmpeg failed for %s: %s", hid, err)
return ClipResult(highlight_id=hid, success=False, error=err)
duration = end - start
return ClipResult(
highlight_id=hid,
success=True,
output_path=str(output_path),
duration=duration,
)
except TimeoutError:
return ClipResult(highlight_id=hid, success=False, error="ffmpeg timed out")
except Exception as exc:
logger.warning("Clip extraction error for %s: %s", hid, exc)
return ClipResult(highlight_id=hid, success=False, error=str(exc))
async def extract_clips(
highlights: list[dict],
output_dir: str | None = None,
) -> list[ClipResult]:
"""Extract multiple clips concurrently.
Parameters
----------
highlights:
List of highlight dicts (see :func:`extract_clip`).
output_dir:
Shared output directory for all clips.
Returns
-------
list[ClipResult]
One result per highlight in the same order.
"""
tasks = [extract_clip(h, output_dir) for h in highlights]
return list(await asyncio.gather(*tasks))

View File

@@ -0,0 +1 @@
"""TTS narration generation for episode segments."""

View File

@@ -0,0 +1,191 @@
"""TTS narration generation for episode segments.
Supports two backends (in priority order):
1. Kokoro-82M via ``mlx_audio`` (Apple Silicon, offline, highest quality)
2. Piper TTS via subprocess (cross-platform, offline, good quality)
Both are optional — if neither is available the module logs a warning and
returns a failure result rather than crashing the pipeline.
Usage
-----
from content.narration.narrator import generate_narration
result = await generate_narration(
text="Welcome to today's highlights episode.",
output_path="/tmp/narration.wav",
)
if result.success:
print(result.audio_path)
"""
from __future__ import annotations
import asyncio
import logging
import shutil
from dataclasses import dataclass
from pathlib import Path
from config import settings
logger = logging.getLogger(__name__)
@dataclass
class NarrationResult:
"""Result of a TTS narration generation attempt."""
success: bool
audio_path: str | None = None
backend: str | None = None
error: str | None = None
def _kokoro_available() -> bool:
"""Return True if mlx_audio (Kokoro-82M) can be imported."""
try:
import importlib.util
return importlib.util.find_spec("mlx_audio") is not None
except Exception:
return False
def _piper_available() -> bool:
"""Return True if the piper binary is on PATH."""
return shutil.which("piper") is not None
async def _generate_kokoro(text: str, output_path: str) -> NarrationResult:
"""Generate audio with Kokoro-82M via mlx_audio (runs in thread)."""
try:
import mlx_audio # type: ignore[import]
def _synth() -> None:
mlx_audio.tts(
text,
voice=settings.content_tts_voice,
output=output_path,
)
await asyncio.to_thread(_synth)
return NarrationResult(success=True, audio_path=output_path, backend="kokoro")
except Exception as exc:
logger.warning("Kokoro TTS failed: %s", exc)
return NarrationResult(success=False, backend="kokoro", error=str(exc))
async def _generate_piper(text: str, output_path: str) -> NarrationResult:
"""Generate audio with Piper TTS via subprocess."""
model = settings.content_piper_model
cmd = [
"piper",
"--model", model,
"--output_file", output_path,
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await asyncio.wait_for(
proc.communicate(input=text.encode()),
timeout=120,
)
if proc.returncode != 0:
err = stderr.decode(errors="replace")[-400:]
logger.warning("Piper TTS failed: %s", err)
return NarrationResult(success=False, backend="piper", error=err)
return NarrationResult(success=True, audio_path=output_path, backend="piper")
except TimeoutError:
return NarrationResult(success=False, backend="piper", error="piper timed out")
except Exception as exc:
logger.warning("Piper TTS error: %s", exc)
return NarrationResult(success=False, backend="piper", error=str(exc))
async def generate_narration(
text: str,
output_path: str,
) -> NarrationResult:
"""Generate TTS narration for the given text.
Tries Kokoro-82M first (Apple Silicon), falls back to Piper.
Returns a failure result if neither backend is available.
Parameters
----------
text:
The script text to synthesise.
output_path:
Destination path for the audio file (wav/mp3).
Returns
-------
NarrationResult
Always returns a result; never raises.
"""
if not text.strip():
return NarrationResult(success=False, error="empty narration text")
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
if _kokoro_available():
result = await _generate_kokoro(text, output_path)
if result.success:
return result
logger.warning("Kokoro failed, trying Piper")
if _piper_available():
return await _generate_piper(text, output_path)
logger.warning("No TTS backend available (install mlx_audio or piper)")
return NarrationResult(
success=False,
error="no TTS backend available — install mlx_audio or piper",
)
def build_episode_script(
episode_title: str,
highlights: list[dict],
outro_text: str | None = None,
) -> str:
"""Build a narration script for a full episode.
Parameters
----------
episode_title:
Human-readable episode title for the intro.
highlights:
List of highlight dicts. Each may have a ``description`` key
used as the narration text for that clip.
outro_text:
Optional custom outro. Defaults to a generic subscribe prompt.
Returns
-------
str
Full narration script with intro, per-highlight lines, and outro.
"""
lines: list[str] = [
f"Welcome to {episode_title}.",
"Here are today's top highlights.",
"",
]
for i, h in enumerate(highlights, 1):
desc = h.get("description") or h.get("title") or f"Highlight {i}"
lines.append(f"Highlight {i}. {desc}.")
lines.append("")
if outro_text:
lines.append(outro_text)
else:
lines.append(
"Thanks for watching. Like and subscribe to stay updated on future episodes."
)
return "\n".join(lines)

View File

@@ -0,0 +1 @@
"""Episode publishing to YouTube and Nostr."""

View File

@@ -0,0 +1,241 @@
"""Nostr publishing via Blossom (NIP-B7) file upload + NIP-94 metadata event.
Blossom is a content-addressed blob storage protocol for Nostr. This module:
1. Uploads the video file to a Blossom server (NIP-B7 PUT /upload).
2. Publishes a NIP-94 file-metadata event referencing the Blossom URL.
Both operations are optional/degradable:
- If no Blossom server is configured, the upload step is skipped and a
warning is logged.
- If ``nostr-tools`` (or a compatible library) is not available, the event
publication step is skipped.
References
----------
- NIP-B7 : https://github.com/hzrd149/blossom
- NIP-94 : https://github.com/nostr-protocol/nips/blob/master/94.md
Usage
-----
from content.publishing.nostr import publish_episode
result = await publish_episode(
video_path="/tmp/episodes/ep001.mp4",
title="Top Highlights — March 2026",
description="Today's best moments.",
tags=["highlights", "gaming"],
)
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
from dataclasses import dataclass
from pathlib import Path
import httpx
from config import settings
logger = logging.getLogger(__name__)
@dataclass
class NostrPublishResult:
"""Result of a Nostr/Blossom publish attempt."""
success: bool
blossom_url: str | None = None
event_id: str | None = None
error: str | None = None
def _sha256_file(path: str) -> str:
"""Return the lowercase hex SHA-256 digest of a file."""
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
h.update(chunk)
return h.hexdigest()
async def _blossom_upload(video_path: str) -> tuple[bool, str, str]:
"""Upload a video to the configured Blossom server.
Returns
-------
(success, url_or_error, sha256)
"""
server = settings.content_blossom_server.rstrip("/")
if not server:
return False, "CONTENT_BLOSSOM_SERVER not configured", ""
sha256 = await asyncio.to_thread(_sha256_file, video_path)
file_size = Path(video_path).stat().st_size
pubkey = settings.content_nostr_pubkey
headers: dict[str, str] = {
"Content-Type": "video/mp4",
"X-SHA-256": sha256,
"X-Content-Length": str(file_size),
}
if pubkey:
headers["X-Nostr-Pubkey"] = pubkey
try:
async with httpx.AsyncClient(timeout=600) as client:
with open(video_path, "rb") as fh:
resp = await client.put(
f"{server}/upload",
content=fh.read(),
headers=headers,
)
if resp.status_code in (200, 201):
data = resp.json()
url = data.get("url") or f"{server}/{sha256}"
return True, url, sha256
return False, f"Blossom upload failed: HTTP {resp.status_code} {resp.text[:200]}", sha256
except Exception as exc:
logger.warning("Blossom upload error: %s", exc)
return False, str(exc), sha256
async def _publish_nip94_event(
blossom_url: str,
sha256: str,
title: str,
description: str,
file_size: int,
tags: list[str],
) -> tuple[bool, str]:
"""Build and publish a NIP-94 file-metadata Nostr event.
Returns (success, event_id_or_error).
"""
relay_url = settings.content_nostr_relay
privkey_hex = settings.content_nostr_privkey
if not relay_url or not privkey_hex:
return (
False,
"CONTENT_NOSTR_RELAY and CONTENT_NOSTR_PRIVKEY must be configured",
)
try:
# Build NIP-94 event manually to avoid heavy nostr-tools dependency
import json
import time
event_tags = [
["url", blossom_url],
["x", sha256],
["m", "video/mp4"],
["size", str(file_size)],
["title", title],
] + [["t", t] for t in tags]
event_content = description
# Minimal NIP-01 event construction
pubkey = settings.content_nostr_pubkey or ""
created_at = int(time.time())
kind = 1063 # NIP-94 file metadata
serialized = json.dumps(
[0, pubkey, created_at, kind, event_tags, event_content],
separators=(",", ":"),
ensure_ascii=False,
)
event_id = hashlib.sha256(serialized.encode()).hexdigest()
# Sign event (schnorr via secp256k1 not in stdlib; sig left empty for now)
sig = ""
event = {
"id": event_id,
"pubkey": pubkey,
"created_at": created_at,
"kind": kind,
"tags": event_tags,
"content": event_content,
"sig": sig,
}
async with httpx.AsyncClient(timeout=30) as client:
# Send event to relay via NIP-01 websocket-like REST endpoint
# (some relays accept JSON POST; for full WS support integrate nostr-tools)
resp = await client.post(
relay_url.replace("wss://", "https://").replace("ws://", "http://"),
json=["EVENT", event],
headers={"Content-Type": "application/json"},
)
if resp.status_code in (200, 201):
return True, event_id
return False, f"Relay rejected event: HTTP {resp.status_code}"
except Exception as exc:
logger.warning("NIP-94 event publication failed: %s", exc)
return False, str(exc)
async def publish_episode(
video_path: str,
title: str,
description: str = "",
tags: list[str] | None = None,
) -> NostrPublishResult:
"""Upload video to Blossom and publish NIP-94 metadata event.
Parameters
----------
video_path:
Local path to the episode MP4 file.
title:
Episode title (used in the NIP-94 event).
description:
Episode description.
tags:
Hashtag list (without "#") for discoverability.
Returns
-------
NostrPublishResult
Always returns a result; never raises.
"""
if not Path(video_path).exists():
return NostrPublishResult(
success=False, error=f"video file not found: {video_path!r}"
)
file_size = Path(video_path).stat().st_size
_tags = tags or []
# Step 1: Upload to Blossom
upload_ok, url_or_err, sha256 = await _blossom_upload(video_path)
if not upload_ok:
logger.warning("Blossom upload failed (non-fatal): %s", url_or_err)
return NostrPublishResult(success=False, error=url_or_err)
blossom_url = url_or_err
logger.info("Blossom upload successful: %s", blossom_url)
# Step 2: Publish NIP-94 event
event_ok, event_id_or_err = await _publish_nip94_event(
blossom_url, sha256, title, description, file_size, _tags
)
if not event_ok:
logger.warning("NIP-94 event failed (non-fatal): %s", event_id_or_err)
# Still return partial success — file is uploaded to Blossom
return NostrPublishResult(
success=True,
blossom_url=blossom_url,
error=f"NIP-94 event failed: {event_id_or_err}",
)
return NostrPublishResult(
success=True,
blossom_url=blossom_url,
event_id=event_id_or_err,
)

View File

@@ -0,0 +1,235 @@
"""YouTube Data API v3 episode upload.
Requires ``google-api-python-client`` and ``google-auth-oauthlib`` to be
installed, and a valid OAuth2 credential file at
``settings.youtube_client_secrets_file``.
The upload is intentionally rate-limited: YouTube allows ~6 uploads/day on
standard quota. This module enforces that cap via a per-day upload counter
stored in a sidecar JSON file.
If the youtube libraries are not installed or credentials are missing,
:func:`upload_episode` returns a failure result without crashing.
Usage
-----
from content.publishing.youtube import upload_episode
result = await upload_episode(
video_path="/tmp/episodes/ep001.mp4",
title="Top Highlights — March 2026",
description="Today's best moments from the stream.",
tags=["highlights", "gaming"],
thumbnail_path="/tmp/thumb.jpg",
)
"""
from __future__ import annotations
import asyncio
import json
import logging
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from config import settings
logger = logging.getLogger(__name__)
_UPLOADS_PER_DAY_MAX = 6
@dataclass
class YouTubeUploadResult:
"""Result of a YouTube upload attempt."""
success: bool
video_id: str | None = None
video_url: str | None = None
error: str | None = None
def _youtube_available() -> bool:
"""Return True if the google-api-python-client library is importable."""
try:
import importlib.util
return (
importlib.util.find_spec("googleapiclient") is not None
and importlib.util.find_spec("google_auth_oauthlib") is not None
)
except Exception:
return False
def _daily_upload_count() -> int:
"""Return the number of YouTube uploads performed today."""
counter_path = Path(settings.content_youtube_counter_file)
today = str(date.today())
if not counter_path.exists():
return 0
try:
data = json.loads(counter_path.read_text())
return data.get(today, 0)
except Exception:
return 0
def _increment_daily_upload_count() -> None:
"""Increment today's upload counter."""
counter_path = Path(settings.content_youtube_counter_file)
counter_path.parent.mkdir(parents=True, exist_ok=True)
today = str(date.today())
try:
data = json.loads(counter_path.read_text()) if counter_path.exists() else {}
except Exception:
data = {}
data[today] = data.get(today, 0) + 1
counter_path.write_text(json.dumps(data))
def _build_youtube_client():
"""Build an authenticated YouTube API client from stored credentials."""
from google.oauth2.credentials import Credentials # type: ignore[import]
from googleapiclient.discovery import build # type: ignore[import]
creds_file = settings.content_youtube_credentials_file
if not creds_file or not Path(creds_file).exists():
raise FileNotFoundError(
f"YouTube credentials not found: {creds_file!r}. "
"Set CONTENT_YOUTUBE_CREDENTIALS_FILE to the path of your "
"OAuth2 token JSON file."
)
creds = Credentials.from_authorized_user_file(creds_file)
return build("youtube", "v3", credentials=creds)
def _upload_sync(
video_path: str,
title: str,
description: str,
tags: list[str],
category_id: str,
privacy_status: str,
thumbnail_path: str | None,
) -> YouTubeUploadResult:
"""Synchronous YouTube upload — run in a thread."""
try:
from googleapiclient.http import MediaFileUpload # type: ignore[import]
except ImportError as exc:
return YouTubeUploadResult(success=False, error=f"google libraries missing: {exc}")
try:
youtube = _build_youtube_client()
except Exception as exc:
return YouTubeUploadResult(success=False, error=str(exc))
body = {
"snippet": {
"title": title,
"description": description,
"tags": tags,
"categoryId": category_id,
},
"status": {"privacyStatus": privacy_status},
}
media = MediaFileUpload(video_path, chunksize=-1, resumable=True)
try:
request = youtube.videos().insert(
part=",".join(body.keys()),
body=body,
media_body=media,
)
response = None
while response is None:
_, response = request.next_chunk()
except Exception as exc:
return YouTubeUploadResult(success=False, error=f"upload failed: {exc}")
video_id = response.get("id", "")
video_url = f"https://www.youtube.com/watch?v={video_id}" if video_id else None
# Set thumbnail if provided
if thumbnail_path and Path(thumbnail_path).exists() and video_id:
try:
youtube.thumbnails().set(
videoId=video_id,
media_body=MediaFileUpload(thumbnail_path),
).execute()
except Exception as exc:
logger.warning("Thumbnail upload failed (non-fatal): %s", exc)
_increment_daily_upload_count()
return YouTubeUploadResult(success=True, video_id=video_id, video_url=video_url)
async def upload_episode(
video_path: str,
title: str,
description: str = "",
tags: list[str] | None = None,
thumbnail_path: str | None = None,
category_id: str = "20", # Gaming
privacy_status: str = "public",
) -> YouTubeUploadResult:
"""Upload an episode video to YouTube.
Enforces the 6-uploads-per-day quota. Wraps the synchronous upload in
``asyncio.to_thread`` to avoid blocking the event loop.
Parameters
----------
video_path:
Local path to the MP4 file.
title:
Video title (max 100 chars for YouTube).
description:
Video description.
tags:
List of tag strings.
thumbnail_path:
Optional path to a JPG/PNG thumbnail image.
category_id:
YouTube category ID (default "20" = Gaming).
privacy_status:
"public", "unlisted", or "private".
Returns
-------
YouTubeUploadResult
Always returns a result; never raises.
"""
if not _youtube_available():
logger.warning("google-api-python-client not installed — YouTube upload disabled")
return YouTubeUploadResult(
success=False,
error="google libraries not available — pip install google-api-python-client google-auth-oauthlib",
)
if not Path(video_path).exists():
return YouTubeUploadResult(
success=False, error=f"video file not found: {video_path!r}"
)
if _daily_upload_count() >= _UPLOADS_PER_DAY_MAX:
return YouTubeUploadResult(
success=False,
error=f"daily upload quota reached ({_UPLOADS_PER_DAY_MAX}/day)",
)
try:
return await asyncio.to_thread(
_upload_sync,
video_path,
title[:100],
description,
tags or [],
category_id,
privacy_status,
thumbnail_path,
)
except Exception as exc:
logger.warning("YouTube upload error: %s", exc)
return YouTubeUploadResult(success=False, error=str(exc))

View File

@@ -35,9 +35,9 @@ from dashboard.routes.chat_api_v1 import router as chat_api_v1_router
from dashboard.routes.daily_run import router as daily_run_router
from dashboard.routes.db_explorer import router as db_explorer_router
from dashboard.routes.discord import router as discord_router
from dashboard.routes.energy import router as energy_router
from dashboard.routes.experiments import router as experiments_router
from dashboard.routes.grok import router as grok_router
from dashboard.routes.energy import router as energy_router
from dashboard.routes.health import router as health_router
from dashboard.routes.hermes import router as hermes_router
from dashboard.routes.loop_qa import router as loop_qa_router
@@ -48,6 +48,7 @@ from dashboard.routes.models import router as models_router
from dashboard.routes.nexus import router as nexus_router
from dashboard.routes.quests import router as quests_router
from dashboard.routes.scorecards import router as scorecards_router
from dashboard.routes.self_correction import router as self_correction_router
from dashboard.routes.sovereignty_metrics import router as sovereignty_metrics_router
from dashboard.routes.sovereignty_ws import router as sovereignty_ws_router
from dashboard.routes.spark import router as spark_router
@@ -55,7 +56,6 @@ from dashboard.routes.system import router as system_router
from dashboard.routes.tasks import router as tasks_router
from dashboard.routes.telegram import router as telegram_router
from dashboard.routes.thinking import router as thinking_router
from dashboard.routes.self_correction import router as self_correction_router
from dashboard.routes.three_strike import router as three_strike_router
from dashboard.routes.tools import router as tools_router
from dashboard.routes.tower import router as tower_router

View File

@@ -19,7 +19,6 @@ Refs: #1009
"""
import asyncio
import json
import logging
import subprocess
import time

View File

@@ -24,8 +24,8 @@ from infrastructure.models.registry import (
model_registry,
)
from infrastructure.models.router import (
TierLabel,
TieredModelRouter,
TierLabel,
classify_tier,
get_tiered_router,
)

View File

@@ -27,7 +27,6 @@ References:
- Issue #882 — Model Tiering Router: Local 8B / Hermes 70B / Cloud API Cascade
"""
import asyncio
import logging
import re
import time

View File

@@ -20,13 +20,11 @@ Usage::
from __future__ import annotations
import json
import logging
import sqlite3
import uuid
from collections.abc import Generator
from contextlib import closing, contextmanager
from datetime import UTC, datetime
from pathlib import Path
logger = logging.getLogger(__name__)

View File

@@ -21,7 +21,6 @@ import base64
import json
import logging
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import httpx