Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions PROJECT_STRUCTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Project Structure

All source files are in the workspace root. Layering is preserved by filename prefix.
The app now uses a packaged runtime architecture in `video_rss_aggregator/`, with a small set of root-level adapters and CLI entrypoints kept for compatibility.

/.gitignore
/.data/
Expand All @@ -14,8 +14,59 @@ All source files are in the workspace root. Layering is preserved by filename pr
/service_ollama.py
/service_transcribe.py
/service_summarize.py
/service_pipeline.py
/adapter_gui.py
/adapter_api.py
/adapter_rss.py
/adapter_storage.py
/video_rss_aggregator/
/__init__.py
/api.py
/bootstrap.py
/rss.py
/storage.py
/application/
/__init__.py
/ports.py
/use_cases/
/__init__.py
/ingest_feed.py
/process_source.py
/render_rss_feed.py
/runtime.py
/domain/
/__init__.py
/models.py
/outcomes.py
/publication.py
/infrastructure/
/__init__.py
/feed_source.py
/media_service.py
/publication_renderer.py
/runtime_adapters.py
/sqlite_repositories.py
/summarizer.py
/tests/
/adapters/
/test_api_app.py
/test_cli_commands.py
/application/
/test_ingest_feed.py
/test_process_source.py
/test_render_rss_feed.py
/test_runtime_use_cases.py
/domain/
/test_outcomes.py
/infrastructure/
/test_feed_source.py
/test_legacy_adapter_shims.py
/test_media_service.py
/test_publication_renderer.py
/test_runtime_adapters.py
/test_sqlite_repositories.py
/test_summarizer.py
/test_api_setup.py
/test_config.py
/test_ollama.py
/test_project_layout.py
/test_summarize_helpers.py
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ This project has been rebuilt around Qwen 3.5 multimodal models and a strict loc
- Storage: SQLite (`.data/vra.db`)
- API: FastAPI

## Architecture

- `video_rss_aggregator/` contains the current runtime architecture.
- `video_rss_aggregator/bootstrap.py` composes the application runtime and use cases.
- `video_rss_aggregator/application/` holds use-case orchestration and ports.
- `video_rss_aggregator/domain/` defines the core models and outcome types.
- `video_rss_aggregator/infrastructure/` contains SQLite, RSS, media, summarization, and runtime adapters.
- Root modules such as `adapter_api.py`, `adapter_rss.py`, `adapter_storage.py`, and `cli.py` remain as compatibility and entry-point surfaces around the packaged runtime.

## Design Goals

- Use Qwen 3.5 vision-capable small models for summarization quality.
Expand Down
172 changes: 15 additions & 157 deletions adapter_api.py
Original file line number Diff line number Diff line change
@@ -1,165 +1,23 @@
from __future__ import annotations

import platform
import shutil
import sys
from datetime import datetime, timezone
from importlib.util import find_spec

from fastapi import Depends, FastAPI, Header, HTTPException, Query
from fastapi.responses import HTMLResponse, Response
from pydantic import BaseModel

from adapter_gui import render_setup_page
from core_config import Config
from service_media import runtime_dependency_report
from service_pipeline import Pipeline


class IngestRequest(BaseModel):
feed_url: str
process: bool = False
max_items: int | None = None


class ProcessRequest(BaseModel):
source_url: str
title: str | None = None


def create_app(pipeline: Pipeline, config: Config) -> FastAPI:
app = FastAPI(title="Video RSS Aggregator", version="0.1.0")

def _check_auth(
authorization: str | None = Header(None), x_api_key: str | None = Header(None)
):
if config.api_key is None:
return
token = None
if authorization:
parts = authorization.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
token = parts[1]
if token is None:
token = x_api_key
if token != config.api_key:
raise HTTPException(status_code=401, detail="unauthorized")

@app.get("/health")
async def health():
return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}

@app.get("/", response_class=HTMLResponse)
async def setup_home():
return render_setup_page(config)

@app.get("/setup/config")
async def setup_config():
return {
"bind_address": f"{config.bind_host}:{config.bind_port}",
"storage_dir": config.storage_dir,
"database_path": config.database_path,
"ollama_base_url": config.ollama_base_url,
"model_priority": list(config.model_priority),
"vram_budget_mb": config.vram_budget_mb,
"model_selection_reserve_mb": config.model_selection_reserve_mb,
"max_frames": config.max_frames,
"frame_scene_detection": config.frame_scene_detection,
"frame_scene_threshold": config.frame_scene_threshold,
"frame_scene_min_frames": config.frame_scene_min_frames,
"api_key_required": config.api_key is not None,
"quick_commands": {
"bootstrap": "python -m vra bootstrap",
"status": "python -m vra status",
"serve": "python -m vra serve --bind 127.0.0.1:8080",
},
}

@app.get("/setup/diagnostics")
async def setup_diagnostics():
media_tools = runtime_dependency_report()
yt_dlp_cmd = shutil.which("yt-dlp")
ytdlp = {
"command": yt_dlp_cmd,
"module_available": find_spec("yt_dlp") is not None,
}
ytdlp["available"] = bool(ytdlp["command"] or ytdlp["module_available"])

ollama: dict[str, object] = {
"base_url": config.ollama_base_url,
"reachable": False,
"version": None,
"models_found": 0,
"error": None,
}
try:
runtime = await pipeline.runtime_status()
ollama["reachable"] = True
ollama["version"] = runtime.get("ollama_version")
local_models = runtime.get("local_models", {})
ollama["models_found"] = len(local_models)
except Exception as exc:
ollama["error"] = str(exc)

ffmpeg_ok = bool(media_tools["ffmpeg"].get("available"))
ffprobe_ok = bool(media_tools["ffprobe"].get("available"))
ytdlp_ok = bool(ytdlp["available"])
ollama_ok = bool(ollama["reachable"])

return {
"platform": {
"system": platform.system(),
"release": platform.release(),
"python_version": sys.version.split()[0],
"python_executable": sys.executable,
},
"dependencies": {
"ffmpeg": media_tools["ffmpeg"],
"ffprobe": media_tools["ffprobe"],
"yt_dlp": ytdlp,
"ollama": ollama,
},
"ready": ffmpeg_ok and ffprobe_ok and ytdlp_ok and ollama_ok,
}

@app.post("/setup/bootstrap")
async def setup_bootstrap(_=Depends(_check_auth)):
return await pipeline.bootstrap_models()
from video_rss_aggregator.api import IngestRequest, ProcessRequest
from video_rss_aggregator.api import create_app as create_runtime_app
from video_rss_aggregator.bootstrap import AppRuntime, AppUseCases, build_runtime

@app.post("/ingest")
async def ingest(req: IngestRequest, _=Depends(_check_auth)):
report = await pipeline.ingest_feed(req.feed_url, req.process, req.max_items)
return {
"feed_title": report.feed_title,
"item_count": report.item_count,
"processed_count": report.processed_count,
}

@app.post("/process")
async def process(req: ProcessRequest, _=Depends(_check_auth)):
report = await pipeline.process_source(req.source_url, req.title)
return {
"source_url": report.source_url,
"title": report.title,
"transcript_chars": report.transcript_chars,
"frame_count": report.frame_count,
"summary": {
"summary": report.summary.summary,
"key_points": report.summary.key_points,
"visual_highlights": report.summary.visual_highlights,
"model_used": report.summary.model_used,
"vram_mb": report.summary.vram_mb,
"error": report.summary.error,
},
}
def create_app(runtime: AppRuntime | None = None, config: Config | None = None):
if runtime is not None and not isinstance(runtime, AppRuntime):
raise TypeError("create_app expects an AppRuntime or None")

@app.get("/rss")
async def rss_feed(limit: int = Query(20, ge=1, le=200)):
xml = await pipeline.rss_feed(limit)
return Response(content=xml, media_type="application/rss+xml")
return create_runtime_app(runtime=runtime, config=config)

@app.get("/runtime")
async def runtime(_=Depends(_check_auth)):
return await pipeline.runtime_status()

return app
__all__ = [
"AppRuntime",
"AppUseCases",
"IngestRequest",
"ProcessRequest",
"build_runtime",
"create_app",
]
50 changes: 2 additions & 48 deletions adapter_rss.py
Original file line number Diff line number Diff line change
@@ -1,49 +1,3 @@
from __future__ import annotations
from video_rss_aggregator.rss import render_feed

from datetime import datetime, timezone
from xml.etree.ElementTree import Element, SubElement, tostring

from adapter_storage import SummaryRecord


def render_feed(
title: str,
link: str,
description: str,
records: list[SummaryRecord],
) -> str:
rss = Element("rss", version="2.0")
channel = SubElement(rss, "channel")
SubElement(channel, "title").text = title
SubElement(channel, "link").text = link
SubElement(channel, "description").text = description

for rec in records:
item = SubElement(channel, "item")
SubElement(item, "title").text = rec.title or "Untitled video"
SubElement(item, "link").text = rec.source_url

desc_parts = [rec.summary]
if rec.key_points:
bullets = "\n".join(f"- {p}" for p in rec.key_points)
desc_parts.append(bullets)
if rec.visual_highlights:
visuals = "\n".join(f"- {p}" for p in rec.visual_highlights)
desc_parts.append(f"Visual Highlights:\n{visuals}")
if rec.model_used:
desc_parts.append(f"Model: {rec.model_used} (VRAM {rec.vram_mb:.2f} MB)")

SubElement(item, "description").text = "\n\n".join(desc_parts)

if rec.published_at:
SubElement(item, "pubDate").text = _rfc2822(rec.published_at)

return '<?xml version="1.0" encoding="UTF-8"?>\n' + tostring(
rss, encoding="unicode"
)


def _rfc2822(dt: datetime) -> str:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.strftime("%a, %d %b %Y %H:%M:%S %z")
__all__ = ["render_feed"]
Loading