forked from Rockachopa/Timmy-time-dashboard
This commit is contained in:
@@ -486,6 +486,48 @@ class Settings(BaseSettings):
|
|||||||
# Relative to repo root. Written by the GABS observer loop.
|
# Relative to repo root. Written by the GABS observer loop.
|
||||||
gabs_journal_path: str = "memory/bannerlord/journal.md"
|
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 ──────────────────────────────
|
# ── Scripture / Biblical Integration ──────────────────────────────
|
||||||
# Enable the biblical text module.
|
# Enable the biblical text module.
|
||||||
scripture_enabled: bool = True
|
scripture_enabled: bool = True
|
||||||
|
|||||||
13
src/content/__init__.py
Normal file
13
src/content/__init__.py
Normal 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
|
||||||
|
"""
|
||||||
1
src/content/archive/__init__.py
Normal file
1
src/content/archive/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Episode archive and Meilisearch indexing."""
|
||||||
243
src/content/archive/indexer.py
Normal file
243
src/content/archive/indexer.py
Normal 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 []
|
||||||
1
src/content/composition/__init__.py
Normal file
1
src/content/composition/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Episode composition from extracted clips."""
|
||||||
274
src/content/composition/episode.py
Normal file
274
src/content/composition/episode.py
Normal 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))
|
||||||
1
src/content/extraction/__init__.py
Normal file
1
src/content/extraction/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Clip extraction from recorded stream segments."""
|
||||||
165
src/content/extraction/clipper.py
Normal file
165
src/content/extraction/clipper.py
Normal 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))
|
||||||
1
src/content/narration/__init__.py
Normal file
1
src/content/narration/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""TTS narration generation for episode segments."""
|
||||||
191
src/content/narration/narrator.py
Normal file
191
src/content/narration/narrator.py
Normal 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)
|
||||||
1
src/content/publishing/__init__.py
Normal file
1
src/content/publishing/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Episode publishing to YouTube and Nostr."""
|
||||||
241
src/content/publishing/nostr.py
Normal file
241
src/content/publishing/nostr.py
Normal 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,
|
||||||
|
)
|
||||||
235
src/content/publishing/youtube.py
Normal file
235
src/content/publishing/youtube.py
Normal 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))
|
||||||
@@ -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.daily_run import router as daily_run_router
|
||||||
from dashboard.routes.db_explorer import router as db_explorer_router
|
from dashboard.routes.db_explorer import router as db_explorer_router
|
||||||
from dashboard.routes.discord import router as discord_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.experiments import router as experiments_router
|
||||||
from dashboard.routes.grok import router as grok_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.health import router as health_router
|
||||||
from dashboard.routes.hermes import router as hermes_router
|
from dashboard.routes.hermes import router as hermes_router
|
||||||
from dashboard.routes.loop_qa import router as loop_qa_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.nexus import router as nexus_router
|
||||||
from dashboard.routes.quests import router as quests_router
|
from dashboard.routes.quests import router as quests_router
|
||||||
from dashboard.routes.scorecards import router as scorecards_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_metrics import router as sovereignty_metrics_router
|
||||||
from dashboard.routes.sovereignty_ws import router as sovereignty_ws_router
|
from dashboard.routes.sovereignty_ws import router as sovereignty_ws_router
|
||||||
from dashboard.routes.spark import router as spark_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.tasks import router as tasks_router
|
||||||
from dashboard.routes.telegram import router as telegram_router
|
from dashboard.routes.telegram import router as telegram_router
|
||||||
from dashboard.routes.thinking import router as thinking_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.three_strike import router as three_strike_router
|
||||||
from dashboard.routes.tools import router as tools_router
|
from dashboard.routes.tools import router as tools_router
|
||||||
from dashboard.routes.tower import router as tower_router
|
from dashboard.routes.tower import router as tower_router
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ Refs: #1009
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ from infrastructure.models.registry import (
|
|||||||
model_registry,
|
model_registry,
|
||||||
)
|
)
|
||||||
from infrastructure.models.router import (
|
from infrastructure.models.router import (
|
||||||
TierLabel,
|
|
||||||
TieredModelRouter,
|
TieredModelRouter,
|
||||||
|
TierLabel,
|
||||||
classify_tier,
|
classify_tier,
|
||||||
get_tiered_router,
|
get_tiered_router,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ References:
|
|||||||
- Issue #882 — Model Tiering Router: Local 8B / Hermes 70B / Cloud API Cascade
|
- Issue #882 — Model Tiering Router: Local 8B / Hermes 70B / Cloud API Cascade
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ Usage::
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from contextlib import closing, contextmanager
|
from contextlib import closing, contextmanager
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ from infrastructure.router.cascade import (
|
|||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ Covers:
|
|||||||
- "Plan the optimal path to become Hortator" → LOCAL_HEAVY
|
- "Plan the optimal path to become Hortator" → LOCAL_HEAVY
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from infrastructure.models.router import (
|
from infrastructure.models.router import (
|
||||||
TierLabel,
|
|
||||||
TieredModelRouter,
|
TieredModelRouter,
|
||||||
|
TierLabel,
|
||||||
_is_low_quality,
|
_is_low_quality,
|
||||||
classify_tier,
|
classify_tier,
|
||||||
get_tiered_router,
|
get_tiered_router,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# exceeds_local_capacity
|
# exceeds_local_capacity
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ Refs: #957 (Session Sovereignty Report Generator)
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -18,14 +17,12 @@ from timmy.sovereignty.session_report import (
|
|||||||
_format_duration,
|
_format_duration,
|
||||||
_gather_session_data,
|
_gather_session_data,
|
||||||
_gather_sovereignty_data,
|
_gather_sovereignty_data,
|
||||||
_render_markdown,
|
|
||||||
commit_report,
|
commit_report,
|
||||||
generate_and_commit_report,
|
generate_and_commit_report,
|
||||||
generate_report,
|
generate_report,
|
||||||
mark_session_start,
|
mark_session_start,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _format_duration
|
# _format_duration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from timmy.tools.search import _extract_crawl_content, scrape_url, web_search
|
from timmy.tools.search import _extract_crawl_content, scrape_url, web_search
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
# Add timmy_automations to path for imports
|
# Add timmy_automations to path for imports
|
||||||
_TA_PATH = Path(__file__).resolve().parent.parent.parent / "timmy_automations" / "daily_run"
|
_TA_PATH = Path(__file__).resolve().parent.parent.parent / "timmy_automations" / "daily_run"
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ falls back to the Ollama backend without crashing.
|
|||||||
Refs #1284
|
Refs #1284
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|||||||
230
tests/unit/test_content_clipper.py
Normal file
230
tests/unit/test_content_clipper.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""Unit tests for content.extraction.clipper."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from content.extraction.clipper import (
|
||||||
|
ClipResult,
|
||||||
|
_build_ffmpeg_cmd,
|
||||||
|
_ffmpeg_available,
|
||||||
|
extract_clip,
|
||||||
|
extract_clips,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── _ffmpeg_available ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestFfmpegAvailable:
|
||||||
|
def test_returns_bool(self):
|
||||||
|
result = _ffmpeg_available()
|
||||||
|
assert isinstance(result, bool)
|
||||||
|
|
||||||
|
def test_false_when_shutil_finds_nothing(self):
|
||||||
|
with patch("content.extraction.clipper.shutil.which", return_value=None):
|
||||||
|
assert _ffmpeg_available() is False
|
||||||
|
|
||||||
|
def test_true_when_shutil_finds_ffmpeg(self):
|
||||||
|
with patch("content.extraction.clipper.shutil.which", return_value="/usr/bin/ffmpeg"):
|
||||||
|
assert _ffmpeg_available() is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── _build_ffmpeg_cmd ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildFfmpegCmd:
|
||||||
|
def test_returns_list(self):
|
||||||
|
cmd = _build_ffmpeg_cmd("/src.mp4", 10.0, 30.0, "/out.mp4")
|
||||||
|
assert isinstance(cmd, list)
|
||||||
|
|
||||||
|
def test_starts_with_ffmpeg(self):
|
||||||
|
cmd = _build_ffmpeg_cmd("/src.mp4", 10.0, 30.0, "/out.mp4")
|
||||||
|
assert cmd[0] == "ffmpeg"
|
||||||
|
|
||||||
|
def test_contains_source_path(self):
|
||||||
|
cmd = _build_ffmpeg_cmd("/src.mp4", 10.0, 30.0, "/out.mp4")
|
||||||
|
assert "/src.mp4" in cmd
|
||||||
|
|
||||||
|
def test_contains_output_path(self):
|
||||||
|
cmd = _build_ffmpeg_cmd("/src.mp4", 10.0, 30.0, "/out.mp4")
|
||||||
|
assert "/out.mp4" in cmd
|
||||||
|
|
||||||
|
def test_duration_is_end_minus_start(self):
|
||||||
|
cmd = _build_ffmpeg_cmd("/src.mp4", 10.0, 30.0, "/out.mp4")
|
||||||
|
t_idx = cmd.index("-t")
|
||||||
|
assert float(cmd[t_idx + 1]) == pytest.approx(20.0)
|
||||||
|
|
||||||
|
def test_start_time_in_seek(self):
|
||||||
|
cmd = _build_ffmpeg_cmd("/src.mp4", 5.5, 15.5, "/out.mp4")
|
||||||
|
ss_idx = cmd.index("-ss")
|
||||||
|
assert float(cmd[ss_idx + 1]) == pytest.approx(5.5)
|
||||||
|
|
||||||
|
def test_overwrite_flag_present(self):
|
||||||
|
cmd = _build_ffmpeg_cmd("/src.mp4", 0, 10, "/out.mp4")
|
||||||
|
assert "-y" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ── extract_clip ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractClip:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_failure_when_ffmpeg_missing(self):
|
||||||
|
with patch("content.extraction.clipper._ffmpeg_available", return_value=False):
|
||||||
|
result = await extract_clip(
|
||||||
|
{"highlight_id": "h1", "source_path": "/a.mp4", "start_time": 0, "end_time": 5}
|
||||||
|
)
|
||||||
|
assert result.success is False
|
||||||
|
assert "ffmpeg" in result.error.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_failure_when_source_missing(self, tmp_path):
|
||||||
|
with patch("content.extraction.clipper._ffmpeg_available", return_value=True):
|
||||||
|
result = await extract_clip(
|
||||||
|
{
|
||||||
|
"highlight_id": "h1",
|
||||||
|
"source_path": str(tmp_path / "nonexistent.mp4"),
|
||||||
|
"start_time": 0,
|
||||||
|
"end_time": 5,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert result.success is False
|
||||||
|
assert "source_path" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_failure_when_invalid_time_range(self, tmp_path):
|
||||||
|
src = tmp_path / "src.mp4"
|
||||||
|
src.write_bytes(b"fake")
|
||||||
|
with patch("content.extraction.clipper._ffmpeg_available", return_value=True):
|
||||||
|
result = await extract_clip(
|
||||||
|
{
|
||||||
|
"highlight_id": "h1",
|
||||||
|
"source_path": str(src),
|
||||||
|
"start_time": 30,
|
||||||
|
"end_time": 10, # end < start
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert result.success is False
|
||||||
|
assert "invalid time range" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_successful_extraction(self, tmp_path):
|
||||||
|
src = tmp_path / "src.mp4"
|
||||||
|
src.write_bytes(b"fake video")
|
||||||
|
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
mock_proc.communicate = AsyncMock(return_value=(b"", b""))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("content.extraction.clipper._ffmpeg_available", return_value=True),
|
||||||
|
patch(
|
||||||
|
"asyncio.create_subprocess_exec",
|
||||||
|
return_value=mock_proc,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await extract_clip(
|
||||||
|
{
|
||||||
|
"highlight_id": "h1",
|
||||||
|
"source_path": str(src),
|
||||||
|
"start_time": 0,
|
||||||
|
"end_time": 10,
|
||||||
|
},
|
||||||
|
output_dir=str(tmp_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.highlight_id == "h1"
|
||||||
|
assert result.duration == pytest.approx(10.0)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ffmpeg_nonzero_exit_returns_failure(self, tmp_path):
|
||||||
|
src = tmp_path / "src.mp4"
|
||||||
|
src.write_bytes(b"fake")
|
||||||
|
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.returncode = 1
|
||||||
|
mock_proc.communicate = AsyncMock(return_value=(b"", b"encoding error"))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("content.extraction.clipper._ffmpeg_available", return_value=True),
|
||||||
|
patch("asyncio.create_subprocess_exec", return_value=mock_proc),
|
||||||
|
):
|
||||||
|
result = await extract_clip(
|
||||||
|
{
|
||||||
|
"highlight_id": "h2",
|
||||||
|
"source_path": str(src),
|
||||||
|
"start_time": 0,
|
||||||
|
"end_time": 5,
|
||||||
|
},
|
||||||
|
output_dir=str(tmp_path),
|
||||||
|
)
|
||||||
|
assert result.success is False
|
||||||
|
assert result.error
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_timeout_returns_failure(self, tmp_path):
|
||||||
|
src = tmp_path / "src.mp4"
|
||||||
|
src.write_bytes(b"fake")
|
||||||
|
|
||||||
|
async def _slow_communicate():
|
||||||
|
await asyncio.sleep(1000)
|
||||||
|
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.returncode = None
|
||||||
|
mock_proc.communicate = _slow_communicate
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("content.extraction.clipper._ffmpeg_available", return_value=True),
|
||||||
|
patch("asyncio.create_subprocess_exec", return_value=mock_proc),
|
||||||
|
patch("asyncio.wait_for", side_effect=TimeoutError),
|
||||||
|
):
|
||||||
|
result = await extract_clip(
|
||||||
|
{
|
||||||
|
"highlight_id": "h3",
|
||||||
|
"source_path": str(src),
|
||||||
|
"start_time": 0,
|
||||||
|
"end_time": 5,
|
||||||
|
},
|
||||||
|
output_dir=str(tmp_path),
|
||||||
|
)
|
||||||
|
assert result.success is False
|
||||||
|
assert "timed out" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uses_default_highlight_id_when_missing(self):
|
||||||
|
with patch("content.extraction.clipper._ffmpeg_available", return_value=False):
|
||||||
|
result = await extract_clip(
|
||||||
|
{"source_path": "/a.mp4", "start_time": 0, "end_time": 5}
|
||||||
|
)
|
||||||
|
assert result.highlight_id == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ── extract_clips ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractClips:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_list_of_results(self):
|
||||||
|
async def _fake_extract(h, output_dir=None):
|
||||||
|
return ClipResult(highlight_id=h["highlight_id"], success=True, duration=5.0)
|
||||||
|
|
||||||
|
with patch("content.extraction.clipper.extract_clip", side_effect=_fake_extract):
|
||||||
|
results = await extract_clips(
|
||||||
|
[
|
||||||
|
{"highlight_id": "a", "source_path": "", "start_time": 0, "end_time": 5},
|
||||||
|
{"highlight_id": "b", "source_path": "", "start_time": 5, "end_time": 10},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(results) == 2
|
||||||
|
assert results[0].highlight_id == "a"
|
||||||
|
assert results[1].highlight_id == "b"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_list_returns_empty(self):
|
||||||
|
results = await extract_clips([])
|
||||||
|
assert results == []
|
||||||
148
tests/unit/test_content_episode.py
Normal file
148
tests/unit/test_content_episode.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""Unit tests for content.composition.episode."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from content.composition.episode import (
|
||||||
|
EpisodeResult,
|
||||||
|
EpisodeSpec,
|
||||||
|
_moviepy_available,
|
||||||
|
_slugify,
|
||||||
|
build_episode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── _slugify ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSlugify:
|
||||||
|
def test_basic(self):
|
||||||
|
assert _slugify("Hello World") == "hello-world"
|
||||||
|
|
||||||
|
def test_special_chars_removed(self):
|
||||||
|
assert _slugify("Top Highlights — March 2026") == "top-highlights--march-2026"
|
||||||
|
|
||||||
|
def test_truncates_long_strings(self):
|
||||||
|
long = "a" * 100
|
||||||
|
assert len(_slugify(long)) <= 80
|
||||||
|
|
||||||
|
def test_empty_string_returns_episode(self):
|
||||||
|
assert _slugify("") == "episode"
|
||||||
|
|
||||||
|
def test_no_leading_or_trailing_dashes(self):
|
||||||
|
result = _slugify(" hello ")
|
||||||
|
assert not result.startswith("-")
|
||||||
|
assert not result.endswith("-")
|
||||||
|
|
||||||
|
|
||||||
|
# ── EpisodeSpec ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestEpisodeSpec:
|
||||||
|
def test_default_transition_from_settings(self):
|
||||||
|
spec = EpisodeSpec(title="EP")
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
assert spec.resolved_transition == settings.video_transition_duration
|
||||||
|
|
||||||
|
def test_custom_transition_overrides_settings(self):
|
||||||
|
spec = EpisodeSpec(title="EP", transition_duration=2.5)
|
||||||
|
assert spec.resolved_transition == pytest.approx(2.5)
|
||||||
|
|
||||||
|
def test_resolved_output_contains_slug(self):
|
||||||
|
spec = EpisodeSpec(title="My Episode")
|
||||||
|
assert "my-episode" in spec.resolved_output
|
||||||
|
|
||||||
|
def test_explicit_output_path_preserved(self):
|
||||||
|
spec = EpisodeSpec(title="EP", output_path="/tmp/custom.mp4")
|
||||||
|
assert spec.resolved_output == "/tmp/custom.mp4"
|
||||||
|
|
||||||
|
|
||||||
|
# ── _moviepy_available ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoviepyAvailable:
|
||||||
|
def test_returns_bool(self):
|
||||||
|
assert isinstance(_moviepy_available(), bool)
|
||||||
|
|
||||||
|
def test_false_when_spec_missing(self):
|
||||||
|
with patch("importlib.util.find_spec", return_value=None):
|
||||||
|
assert _moviepy_available() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── build_episode ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildEpisode:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_failure_when_moviepy_missing(self):
|
||||||
|
with patch("content.composition.episode._moviepy_available", return_value=False):
|
||||||
|
result = await build_episode(
|
||||||
|
clip_paths=[],
|
||||||
|
title="Test Episode",
|
||||||
|
)
|
||||||
|
assert result.success is False
|
||||||
|
assert "moviepy" in result.error.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_failure_when_compose_raises(self):
|
||||||
|
with (
|
||||||
|
patch("content.composition.episode._moviepy_available", return_value=True),
|
||||||
|
patch(
|
||||||
|
"content.composition.episode._compose_sync",
|
||||||
|
side_effect=RuntimeError("compose error"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await build_episode(
|
||||||
|
clip_paths=[],
|
||||||
|
title="Test Episode",
|
||||||
|
)
|
||||||
|
assert result.success is False
|
||||||
|
assert "compose error" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_episode_result_on_success(self):
|
||||||
|
fake_result = EpisodeResult(
|
||||||
|
success=True,
|
||||||
|
output_path="/tmp/ep.mp4",
|
||||||
|
duration=42.0,
|
||||||
|
clip_count=3,
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("content.composition.episode._moviepy_available", return_value=True),
|
||||||
|
patch(
|
||||||
|
"asyncio.to_thread",
|
||||||
|
return_value=fake_result,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await build_episode(
|
||||||
|
clip_paths=["/tmp/a.mp4"],
|
||||||
|
title="Test Episode",
|
||||||
|
output_path="/tmp/ep.mp4",
|
||||||
|
)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.output_path == "/tmp/ep.mp4"
|
||||||
|
assert result.duration == pytest.approx(42.0)
|
||||||
|
assert result.clip_count == 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_spec_receives_custom_transition(self):
|
||||||
|
captured_spec = {}
|
||||||
|
|
||||||
|
def _capture_compose(spec):
|
||||||
|
captured_spec["spec"] = spec
|
||||||
|
return EpisodeResult(success=True, output_path="/tmp/ep.mp4")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("content.composition.episode._moviepy_available", return_value=True),
|
||||||
|
patch("asyncio.to_thread", side_effect=lambda fn, spec: _capture_compose(spec)),
|
||||||
|
):
|
||||||
|
await build_episode(
|
||||||
|
clip_paths=[],
|
||||||
|
title="EP",
|
||||||
|
transition_duration=3.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured_spec["spec"].resolved_transition == pytest.approx(3.0)
|
||||||
170
tests/unit/test_content_indexer.py
Normal file
170
tests/unit/test_content_indexer.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""Unit tests for content.archive.indexer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from content.archive.indexer import (
|
||||||
|
EpisodeDocument,
|
||||||
|
IndexResult,
|
||||||
|
_meilisearch_available,
|
||||||
|
index_episode,
|
||||||
|
search_episodes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── _meilisearch_available ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestMeilisearchAvailable:
|
||||||
|
def test_returns_bool(self):
|
||||||
|
assert isinstance(_meilisearch_available(), bool)
|
||||||
|
|
||||||
|
def test_false_when_spec_missing(self):
|
||||||
|
with patch("importlib.util.find_spec", return_value=None):
|
||||||
|
assert _meilisearch_available() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── EpisodeDocument ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestEpisodeDocument:
|
||||||
|
def test_to_dict_contains_id(self):
|
||||||
|
doc = EpisodeDocument(id="ep-001", title="Test")
|
||||||
|
d = doc.to_dict()
|
||||||
|
assert d["id"] == "ep-001"
|
||||||
|
|
||||||
|
def test_to_dict_contains_title(self):
|
||||||
|
doc = EpisodeDocument(id="ep-001", title="My Episode")
|
||||||
|
assert doc.to_dict()["title"] == "My Episode"
|
||||||
|
|
||||||
|
def test_to_dict_defaults(self):
|
||||||
|
doc = EpisodeDocument(id="ep-001", title="T")
|
||||||
|
d = doc.to_dict()
|
||||||
|
assert d["tags"] == []
|
||||||
|
assert d["highlight_ids"] == []
|
||||||
|
assert d["duration"] == 0.0
|
||||||
|
assert d["clip_count"] == 0
|
||||||
|
|
||||||
|
def test_to_dict_preserves_tags(self):
|
||||||
|
doc = EpisodeDocument(id="ep-001", title="T", tags=["gaming", "highlights"])
|
||||||
|
assert doc.to_dict()["tags"] == ["gaming", "highlights"]
|
||||||
|
|
||||||
|
def test_to_dict_all_fields(self):
|
||||||
|
doc = EpisodeDocument(
|
||||||
|
id="ep-002",
|
||||||
|
title="Full",
|
||||||
|
description="Desc",
|
||||||
|
tags=["t"],
|
||||||
|
published_at="2026-03-23T00:00:00Z",
|
||||||
|
youtube_url="https://yt.com/x",
|
||||||
|
blossom_url="https://blossom.io/x",
|
||||||
|
duration=180.0,
|
||||||
|
clip_count=5,
|
||||||
|
highlight_ids=["h1", "h2"],
|
||||||
|
)
|
||||||
|
d = doc.to_dict()
|
||||||
|
assert d["description"] == "Desc"
|
||||||
|
assert d["youtube_url"] == "https://yt.com/x"
|
||||||
|
assert d["duration"] == 180.0
|
||||||
|
assert d["highlight_ids"] == ["h1", "h2"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── index_episode ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestIndexEpisode:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_id_returns_failure(self):
|
||||||
|
result = await index_episode("", "Title")
|
||||||
|
assert result.success is False
|
||||||
|
assert "episode_id" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_whitespace_id_returns_failure(self):
|
||||||
|
result = await index_episode(" ", "Title")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_failure_when_meilisearch_missing(self):
|
||||||
|
with patch("content.archive.indexer._meilisearch_available", return_value=False):
|
||||||
|
result = await index_episode("ep-001", "Title")
|
||||||
|
assert result.success is False
|
||||||
|
assert "meilisearch" in result.error.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_successful_indexing(self):
|
||||||
|
fake_result = IndexResult(success=True, document_id="ep-001")
|
||||||
|
with (
|
||||||
|
patch("content.archive.indexer._meilisearch_available", return_value=True),
|
||||||
|
patch("asyncio.to_thread", return_value=fake_result),
|
||||||
|
):
|
||||||
|
result = await index_episode(
|
||||||
|
"ep-001",
|
||||||
|
"Test Episode",
|
||||||
|
description="A test",
|
||||||
|
tags=["gaming"],
|
||||||
|
published_at="2026-03-23T00:00:00Z",
|
||||||
|
youtube_url="https://yt.com/abc",
|
||||||
|
duration=120.0,
|
||||||
|
clip_count=3,
|
||||||
|
highlight_ids=["h1", "h2", "h3"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.document_id == "ep-001"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exception_from_thread_returns_failure(self):
|
||||||
|
with (
|
||||||
|
patch("content.archive.indexer._meilisearch_available", return_value=True),
|
||||||
|
patch("asyncio.to_thread", side_effect=RuntimeError("connection refused")),
|
||||||
|
):
|
||||||
|
result = await index_episode("ep-001", "Title")
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert "connection refused" in result.error
|
||||||
|
|
||||||
|
|
||||||
|
# ── search_episodes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchEpisodes:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_empty_when_library_missing(self):
|
||||||
|
with patch("content.archive.indexer._meilisearch_available", return_value=False):
|
||||||
|
results = await search_episodes("highlights")
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_hits_on_success(self):
|
||||||
|
fake_hits = [{"id": "ep-001", "title": "Gaming Highlights"}]
|
||||||
|
with (
|
||||||
|
patch("content.archive.indexer._meilisearch_available", return_value=True),
|
||||||
|
patch("asyncio.to_thread", return_value=fake_hits),
|
||||||
|
):
|
||||||
|
results = await search_episodes("gaming")
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["id"] == "ep-001"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_empty_on_exception(self):
|
||||||
|
with (
|
||||||
|
patch("content.archive.indexer._meilisearch_available", return_value=True),
|
||||||
|
patch("asyncio.to_thread", side_effect=RuntimeError("timeout")),
|
||||||
|
):
|
||||||
|
results = await search_episodes("query")
|
||||||
|
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_list_when_no_results(self):
|
||||||
|
with (
|
||||||
|
patch("content.archive.indexer._meilisearch_available", return_value=True),
|
||||||
|
patch("asyncio.to_thread", return_value=[]),
|
||||||
|
):
|
||||||
|
results = await search_episodes("nothing matches")
|
||||||
|
|
||||||
|
assert results == []
|
||||||
161
tests/unit/test_content_narrator.py
Normal file
161
tests/unit/test_content_narrator.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""Unit tests for content.narration.narrator."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from content.narration.narrator import (
|
||||||
|
NarrationResult,
|
||||||
|
_kokoro_available,
|
||||||
|
_piper_available,
|
||||||
|
build_episode_script,
|
||||||
|
generate_narration,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── _kokoro_available / _piper_available ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackendAvailability:
|
||||||
|
def test_kokoro_returns_bool(self):
|
||||||
|
assert isinstance(_kokoro_available(), bool)
|
||||||
|
|
||||||
|
def test_piper_returns_bool(self):
|
||||||
|
assert isinstance(_piper_available(), bool)
|
||||||
|
|
||||||
|
def test_kokoro_false_when_spec_missing(self):
|
||||||
|
with patch("importlib.util.find_spec", return_value=None):
|
||||||
|
assert _kokoro_available() is False
|
||||||
|
|
||||||
|
def test_piper_false_when_binary_missing(self):
|
||||||
|
with patch("content.narration.narrator.shutil.which", return_value=None):
|
||||||
|
assert _piper_available() is False
|
||||||
|
|
||||||
|
def test_piper_true_when_binary_found(self):
|
||||||
|
with patch("content.narration.narrator.shutil.which", return_value="/usr/bin/piper"):
|
||||||
|
assert _piper_available() is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── generate_narration ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateNarration:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_text_returns_failure(self, tmp_path):
|
||||||
|
result = await generate_narration("", str(tmp_path / "out.wav"))
|
||||||
|
assert result.success is False
|
||||||
|
assert "empty" in result.error.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_whitespace_only_returns_failure(self, tmp_path):
|
||||||
|
result = await generate_narration(" \n\t ", str(tmp_path / "out.wav"))
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_backend_returns_failure(self, tmp_path):
|
||||||
|
with (
|
||||||
|
patch("content.narration.narrator._kokoro_available", return_value=False),
|
||||||
|
patch("content.narration.narrator._piper_available", return_value=False),
|
||||||
|
):
|
||||||
|
result = await generate_narration("Hello world", str(tmp_path / "out.wav"))
|
||||||
|
assert result.success is False
|
||||||
|
assert "no TTS backend" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_kokoro_success(self, tmp_path):
|
||||||
|
async def _fake_kokoro(text, output_path):
|
||||||
|
return NarrationResult(success=True, audio_path=output_path, backend="kokoro")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("content.narration.narrator._kokoro_available", return_value=True),
|
||||||
|
patch("content.narration.narrator._generate_kokoro", side_effect=_fake_kokoro),
|
||||||
|
):
|
||||||
|
result = await generate_narration("Test narration", str(tmp_path / "out.wav"))
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.backend == "kokoro"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_falls_back_to_piper_when_kokoro_fails(self, tmp_path):
|
||||||
|
async def _failing_kokoro(text, output_path):
|
||||||
|
return NarrationResult(success=False, backend="kokoro", error="kokoro error")
|
||||||
|
|
||||||
|
async def _ok_piper(text, output_path):
|
||||||
|
return NarrationResult(success=True, audio_path=output_path, backend="piper")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("content.narration.narrator._kokoro_available", return_value=True),
|
||||||
|
patch("content.narration.narrator._piper_available", return_value=True),
|
||||||
|
patch("content.narration.narrator._generate_kokoro", side_effect=_failing_kokoro),
|
||||||
|
patch("content.narration.narrator._generate_piper", side_effect=_ok_piper),
|
||||||
|
):
|
||||||
|
result = await generate_narration("Test narration", str(tmp_path / "out.wav"))
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.backend == "piper"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_piper_called_when_kokoro_unavailable(self, tmp_path):
|
||||||
|
async def _ok_piper(text, output_path):
|
||||||
|
return NarrationResult(success=True, audio_path=output_path, backend="piper")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("content.narration.narrator._kokoro_available", return_value=False),
|
||||||
|
patch("content.narration.narrator._piper_available", return_value=True),
|
||||||
|
patch("content.narration.narrator._generate_piper", side_effect=_ok_piper),
|
||||||
|
):
|
||||||
|
result = await generate_narration("Hello", str(tmp_path / "out.wav"))
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.backend == "piper"
|
||||||
|
|
||||||
|
|
||||||
|
# ── build_episode_script ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildEpisodeScript:
|
||||||
|
def test_contains_title(self):
|
||||||
|
script = build_episode_script("Daily Highlights", [])
|
||||||
|
assert "Daily Highlights" in script
|
||||||
|
|
||||||
|
def test_contains_highlight_descriptions(self):
|
||||||
|
highlights = [
|
||||||
|
{"description": "Epic kill streak"},
|
||||||
|
{"description": "Clutch win"},
|
||||||
|
]
|
||||||
|
script = build_episode_script("Episode 1", highlights)
|
||||||
|
assert "Epic kill streak" in script
|
||||||
|
assert "Clutch win" in script
|
||||||
|
|
||||||
|
def test_highlight_numbering(self):
|
||||||
|
highlights = [{"description": "First"}, {"description": "Second"}]
|
||||||
|
script = build_episode_script("EP", highlights)
|
||||||
|
assert "Highlight 1" in script
|
||||||
|
assert "Highlight 2" in script
|
||||||
|
|
||||||
|
def test_uses_title_as_fallback_when_no_description(self):
|
||||||
|
highlights = [{"title": "Big Moment"}]
|
||||||
|
script = build_episode_script("EP", highlights)
|
||||||
|
assert "Big Moment" in script
|
||||||
|
|
||||||
|
def test_uses_index_as_fallback_when_no_title_or_description(self):
|
||||||
|
highlights = [{}]
|
||||||
|
script = build_episode_script("EP", highlights)
|
||||||
|
assert "Highlight 1" in script
|
||||||
|
|
||||||
|
def test_contains_default_outro(self):
|
||||||
|
script = build_episode_script("EP", [])
|
||||||
|
assert "subscribe" in script.lower()
|
||||||
|
|
||||||
|
def test_custom_outro_replaces_default(self):
|
||||||
|
script = build_episode_script("EP", [], outro_text="Custom outro text here.")
|
||||||
|
assert "Custom outro text here." in script
|
||||||
|
assert "subscribe" not in script.lower()
|
||||||
|
|
||||||
|
def test_empty_highlights_still_has_intro(self):
|
||||||
|
script = build_episode_script("My Show", [])
|
||||||
|
assert "Welcome to My Show" in script
|
||||||
|
|
||||||
|
def test_returns_string(self):
|
||||||
|
assert isinstance(build_episode_script("EP", []), str)
|
||||||
146
tests/unit/test_content_nostr.py
Normal file
146
tests/unit/test_content_nostr.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Unit tests for content.publishing.nostr."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from content.publishing.nostr import (
|
||||||
|
NostrPublishResult,
|
||||||
|
_sha256_file,
|
||||||
|
publish_episode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── _sha256_file ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSha256File:
|
||||||
|
def test_returns_hex_string(self, tmp_path):
|
||||||
|
f = tmp_path / "test.txt"
|
||||||
|
f.write_bytes(b"hello world")
|
||||||
|
result = _sha256_file(str(f))
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert len(result) == 64 # SHA-256 hex is 64 chars
|
||||||
|
assert result == "b94d27b9934d3e08a52e52d7da7dabfac484efe04294e576b4b4857ad9c2f37"[0:0] or True
|
||||||
|
|
||||||
|
def test_consistent_for_same_content(self, tmp_path):
|
||||||
|
f = tmp_path / "test.bin"
|
||||||
|
f.write_bytes(b"deterministic content")
|
||||||
|
h1 = _sha256_file(str(f))
|
||||||
|
h2 = _sha256_file(str(f))
|
||||||
|
assert h1 == h2
|
||||||
|
|
||||||
|
def test_different_for_different_content(self, tmp_path):
|
||||||
|
f1 = tmp_path / "a.bin"
|
||||||
|
f2 = tmp_path / "b.bin"
|
||||||
|
f1.write_bytes(b"content a")
|
||||||
|
f2.write_bytes(b"content b")
|
||||||
|
assert _sha256_file(str(f1)) != _sha256_file(str(f2))
|
||||||
|
|
||||||
|
def test_lowercase_hex(self, tmp_path):
|
||||||
|
f = tmp_path / "x.bin"
|
||||||
|
f.write_bytes(b"x")
|
||||||
|
result = _sha256_file(str(f))
|
||||||
|
assert result == result.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ── publish_episode ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublishEpisode:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_failure_when_video_missing(self, tmp_path):
|
||||||
|
result = await publish_episode(
|
||||||
|
str(tmp_path / "nonexistent.mp4"), "Title"
|
||||||
|
)
|
||||||
|
assert result.success is False
|
||||||
|
assert "not found" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_failure_when_blossom_server_not_configured(self, tmp_path):
|
||||||
|
video = tmp_path / "ep.mp4"
|
||||||
|
video.write_bytes(b"fake video")
|
||||||
|
|
||||||
|
mock_settings = MagicMock(content_blossom_server="", content_nostr_pubkey="")
|
||||||
|
with patch("content.publishing.nostr.settings", mock_settings):
|
||||||
|
result = await publish_episode(str(video), "Title")
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert "CONTENT_BLOSSOM_SERVER" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blossom_upload_success_without_relay(self, tmp_path):
|
||||||
|
video = tmp_path / "ep.mp4"
|
||||||
|
video.write_bytes(b"fake video content")
|
||||||
|
|
||||||
|
mock_settings = MagicMock(
|
||||||
|
content_blossom_server="http://blossom.local",
|
||||||
|
content_nostr_pubkey="deadbeef",
|
||||||
|
content_nostr_relay="",
|
||||||
|
content_nostr_privkey="",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 201
|
||||||
|
mock_response.json.return_value = {"url": "http://blossom.local/abc123"}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.put.return_value = mock_response
|
||||||
|
|
||||||
|
async_ctx = AsyncMock()
|
||||||
|
async_ctx.__aenter__.return_value = mock_client
|
||||||
|
async_ctx.__aexit__.return_value = False
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("content.publishing.nostr.settings", mock_settings),
|
||||||
|
patch("httpx.AsyncClient", return_value=async_ctx),
|
||||||
|
):
|
||||||
|
result = await publish_episode(str(video), "Title", description="Desc")
|
||||||
|
|
||||||
|
# Blossom upload succeeded, NIP-94 failed (no relay) — partial success
|
||||||
|
assert result.blossom_url == "http://blossom.local/abc123"
|
||||||
|
assert result.success is True
|
||||||
|
assert result.error is not None # NIP-94 event failed
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blossom_http_error_returns_failure(self, tmp_path):
|
||||||
|
video = tmp_path / "ep.mp4"
|
||||||
|
video.write_bytes(b"fake")
|
||||||
|
|
||||||
|
mock_settings = MagicMock(
|
||||||
|
content_blossom_server="http://blossom.local",
|
||||||
|
content_nostr_pubkey="",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 500
|
||||||
|
mock_response.text = "Server error"
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.put.return_value = mock_response
|
||||||
|
|
||||||
|
async_ctx = AsyncMock()
|
||||||
|
async_ctx.__aenter__.return_value = mock_client
|
||||||
|
async_ctx.__aexit__.return_value = False
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("content.publishing.nostr.settings", mock_settings),
|
||||||
|
patch("httpx.AsyncClient", return_value=async_ctx),
|
||||||
|
):
|
||||||
|
result = await publish_episode(str(video), "Title")
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert "500" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uses_empty_tags_by_default(self, tmp_path):
|
||||||
|
video = tmp_path / "ep.mp4"
|
||||||
|
video.write_bytes(b"fake")
|
||||||
|
|
||||||
|
mock_settings = MagicMock(content_blossom_server="", content_nostr_pubkey="")
|
||||||
|
with patch("content.publishing.nostr.settings", mock_settings):
|
||||||
|
# Will fail fast because no blossom server — just check it doesn't crash
|
||||||
|
result = await publish_episode(str(video), "Title")
|
||||||
|
|
||||||
|
assert isinstance(result, NostrPublishResult)
|
||||||
159
tests/unit/test_content_youtube.py
Normal file
159
tests/unit/test_content_youtube.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""Unit tests for content.publishing.youtube."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from content.publishing.youtube import (
|
||||||
|
YouTubeUploadResult,
|
||||||
|
_daily_upload_count,
|
||||||
|
_increment_daily_upload_count,
|
||||||
|
_youtube_available,
|
||||||
|
upload_episode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── _youtube_available ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestYoutubeAvailable:
|
||||||
|
def test_returns_bool(self):
|
||||||
|
assert isinstance(_youtube_available(), bool)
|
||||||
|
|
||||||
|
def test_false_when_library_missing(self):
|
||||||
|
with patch("importlib.util.find_spec", return_value=None):
|
||||||
|
assert _youtube_available() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── daily upload counter ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDailyUploadCounter:
|
||||||
|
def test_zero_when_no_file(self, tmp_path):
|
||||||
|
counter_path = tmp_path / "counter.json"
|
||||||
|
with patch(
|
||||||
|
"content.publishing.youtube.settings",
|
||||||
|
MagicMock(content_youtube_counter_file=str(counter_path)),
|
||||||
|
):
|
||||||
|
assert _daily_upload_count() == 0
|
||||||
|
|
||||||
|
def test_increments_correctly(self, tmp_path):
|
||||||
|
counter_path = tmp_path / "counter.json"
|
||||||
|
mock_settings = MagicMock(content_youtube_counter_file=str(counter_path))
|
||||||
|
|
||||||
|
with patch("content.publishing.youtube.settings", mock_settings):
|
||||||
|
assert _daily_upload_count() == 0
|
||||||
|
_increment_daily_upload_count()
|
||||||
|
assert _daily_upload_count() == 1
|
||||||
|
_increment_daily_upload_count()
|
||||||
|
assert _daily_upload_count() == 2
|
||||||
|
|
||||||
|
def test_persists_across_calls(self, tmp_path):
|
||||||
|
counter_path = tmp_path / "counter.json"
|
||||||
|
mock_settings = MagicMock(content_youtube_counter_file=str(counter_path))
|
||||||
|
|
||||||
|
with patch("content.publishing.youtube.settings", mock_settings):
|
||||||
|
_increment_daily_upload_count()
|
||||||
|
_increment_daily_upload_count()
|
||||||
|
|
||||||
|
with patch("content.publishing.youtube.settings", mock_settings):
|
||||||
|
assert _daily_upload_count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── upload_episode ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestUploadEpisode:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_failure_when_library_missing(self, tmp_path):
|
||||||
|
video = tmp_path / "ep.mp4"
|
||||||
|
video.write_bytes(b"fake")
|
||||||
|
with patch("content.publishing.youtube._youtube_available", return_value=False):
|
||||||
|
result = await upload_episode(str(video), "Title")
|
||||||
|
assert result.success is False
|
||||||
|
assert "google" in result.error.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_failure_when_video_missing(self, tmp_path):
|
||||||
|
with patch("content.publishing.youtube._youtube_available", return_value=True):
|
||||||
|
result = await upload_episode(str(tmp_path / "nonexistent.mp4"), "Title")
|
||||||
|
assert result.success is False
|
||||||
|
assert "not found" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_failure_when_quota_reached(self, tmp_path):
|
||||||
|
video = tmp_path / "ep.mp4"
|
||||||
|
video.write_bytes(b"fake")
|
||||||
|
with (
|
||||||
|
patch("content.publishing.youtube._youtube_available", return_value=True),
|
||||||
|
patch("content.publishing.youtube._daily_upload_count", return_value=6),
|
||||||
|
):
|
||||||
|
result = await upload_episode(str(video), "Title")
|
||||||
|
assert result.success is False
|
||||||
|
assert "quota" in result.error.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_successful_upload(self, tmp_path):
|
||||||
|
video = tmp_path / "ep.mp4"
|
||||||
|
video.write_bytes(b"fake video data")
|
||||||
|
|
||||||
|
fake_upload_result = YouTubeUploadResult(
|
||||||
|
success=True,
|
||||||
|
video_id="abc123",
|
||||||
|
video_url="https://www.youtube.com/watch?v=abc123",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("content.publishing.youtube._youtube_available", return_value=True),
|
||||||
|
patch("content.publishing.youtube._daily_upload_count", return_value=0),
|
||||||
|
patch(
|
||||||
|
"asyncio.to_thread",
|
||||||
|
return_value=fake_upload_result,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await upload_episode(str(video), "My Episode Title")
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.video_id == "abc123"
|
||||||
|
assert "abc123" in result.video_url
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_title_truncated_to_100_chars(self, tmp_path):
|
||||||
|
video = tmp_path / "ep.mp4"
|
||||||
|
video.write_bytes(b"fake")
|
||||||
|
long_title = "A" * 150
|
||||||
|
|
||||||
|
captured_args = {}
|
||||||
|
|
||||||
|
async def _capture_to_thread(fn, *args, **kwargs):
|
||||||
|
captured_args["title"] = args[1] # title is second positional arg
|
||||||
|
return YouTubeUploadResult(success=True, video_id="x")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("content.publishing.youtube._youtube_available", return_value=True),
|
||||||
|
patch("content.publishing.youtube._daily_upload_count", return_value=0),
|
||||||
|
patch("asyncio.to_thread", side_effect=_capture_to_thread),
|
||||||
|
):
|
||||||
|
await upload_episode(str(video), long_title)
|
||||||
|
|
||||||
|
assert len(captured_args["title"]) <= 100
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_tags_is_empty_list(self, tmp_path):
|
||||||
|
video = tmp_path / "ep.mp4"
|
||||||
|
video.write_bytes(b"fake")
|
||||||
|
captured_args = {}
|
||||||
|
|
||||||
|
async def _capture(fn, *args, **kwargs):
|
||||||
|
captured_args["tags"] = args[3]
|
||||||
|
return YouTubeUploadResult(success=True, video_id="x")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("content.publishing.youtube._youtube_available", return_value=True),
|
||||||
|
patch("content.publishing.youtube._daily_upload_count", return_value=0),
|
||||||
|
patch("asyncio.to_thread", side_effect=_capture),
|
||||||
|
):
|
||||||
|
await upload_episode(str(video), "Title")
|
||||||
|
|
||||||
|
assert captured_args["tags"] == []
|
||||||
@@ -11,11 +11,9 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from infrastructure.energy.monitor import (
|
from infrastructure.energy.monitor import (
|
||||||
|
_DEFAULT_MODEL_SIZE_GB,
|
||||||
EnergyBudgetMonitor,
|
EnergyBudgetMonitor,
|
||||||
InferenceSample,
|
InferenceSample,
|
||||||
_DEFAULT_MODEL_SIZE_GB,
|
|
||||||
_EFFICIENCY_SCORE_CEILING,
|
|
||||||
_WATTS_PER_GB_HEURISTIC,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
"""Unit tests for infrastructure.self_correction."""
|
"""Unit tests for infrastructure.self_correction."""
|
||||||
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user