|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | | -import platform |
4 | | -import shutil |
5 | | -import sys |
6 | | -from datetime import datetime, timezone |
7 | | -from importlib.util import find_spec |
8 | | - |
9 | | -from fastapi import Depends, FastAPI, Header, HTTPException, Query |
10 | | -from fastapi.responses import HTMLResponse, Response |
11 | | -from pydantic import BaseModel |
12 | | - |
13 | | -from adapter_gui import render_setup_page |
14 | 3 | from core_config import Config |
15 | | -from service_media import runtime_dependency_report |
16 | | -from service_pipeline import Pipeline |
17 | | - |
18 | | - |
19 | | -class IngestRequest(BaseModel): |
20 | | - feed_url: str |
21 | | - process: bool = False |
22 | | - max_items: int | None = None |
23 | | - |
24 | | - |
25 | | -class ProcessRequest(BaseModel): |
26 | | - source_url: str |
27 | | - title: str | None = None |
28 | | - |
29 | | - |
30 | | -def create_app(pipeline: Pipeline, config: Config) -> FastAPI: |
31 | | - app = FastAPI(title="Video RSS Aggregator", version="0.1.0") |
32 | | - |
33 | | - def _check_auth( |
34 | | - authorization: str | None = Header(None), x_api_key: str | None = Header(None) |
35 | | - ): |
36 | | - if config.api_key is None: |
37 | | - return |
38 | | - token = None |
39 | | - if authorization: |
40 | | - parts = authorization.split() |
41 | | - if len(parts) == 2 and parts[0].lower() == "bearer": |
42 | | - token = parts[1] |
43 | | - if token is None: |
44 | | - token = x_api_key |
45 | | - if token != config.api_key: |
46 | | - raise HTTPException(status_code=401, detail="unauthorized") |
47 | | - |
48 | | - @app.get("/health") |
49 | | - async def health(): |
50 | | - return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()} |
51 | | - |
52 | | - @app.get("/", response_class=HTMLResponse) |
53 | | - async def setup_home(): |
54 | | - return render_setup_page(config) |
55 | | - |
56 | | - @app.get("/setup/config") |
57 | | - async def setup_config(): |
58 | | - return { |
59 | | - "bind_address": f"{config.bind_host}:{config.bind_port}", |
60 | | - "storage_dir": config.storage_dir, |
61 | | - "database_path": config.database_path, |
62 | | - "ollama_base_url": config.ollama_base_url, |
63 | | - "model_priority": list(config.model_priority), |
64 | | - "vram_budget_mb": config.vram_budget_mb, |
65 | | - "model_selection_reserve_mb": config.model_selection_reserve_mb, |
66 | | - "max_frames": config.max_frames, |
67 | | - "frame_scene_detection": config.frame_scene_detection, |
68 | | - "frame_scene_threshold": config.frame_scene_threshold, |
69 | | - "frame_scene_min_frames": config.frame_scene_min_frames, |
70 | | - "api_key_required": config.api_key is not None, |
71 | | - "quick_commands": { |
72 | | - "bootstrap": "python -m vra bootstrap", |
73 | | - "status": "python -m vra status", |
74 | | - "serve": "python -m vra serve --bind 127.0.0.1:8080", |
75 | | - }, |
76 | | - } |
77 | | - |
78 | | - @app.get("/setup/diagnostics") |
79 | | - async def setup_diagnostics(): |
80 | | - media_tools = runtime_dependency_report() |
81 | | - yt_dlp_cmd = shutil.which("yt-dlp") |
82 | | - ytdlp = { |
83 | | - "command": yt_dlp_cmd, |
84 | | - "module_available": find_spec("yt_dlp") is not None, |
85 | | - } |
86 | | - ytdlp["available"] = bool(ytdlp["command"] or ytdlp["module_available"]) |
87 | | - |
88 | | - ollama: dict[str, object] = { |
89 | | - "base_url": config.ollama_base_url, |
90 | | - "reachable": False, |
91 | | - "version": None, |
92 | | - "models_found": 0, |
93 | | - "error": None, |
94 | | - } |
95 | | - try: |
96 | | - runtime = await pipeline.runtime_status() |
97 | | - ollama["reachable"] = True |
98 | | - ollama["version"] = runtime.get("ollama_version") |
99 | | - local_models = runtime.get("local_models", {}) |
100 | | - ollama["models_found"] = len(local_models) |
101 | | - except Exception as exc: |
102 | | - ollama["error"] = str(exc) |
103 | | - |
104 | | - ffmpeg_ok = bool(media_tools["ffmpeg"].get("available")) |
105 | | - ffprobe_ok = bool(media_tools["ffprobe"].get("available")) |
106 | | - ytdlp_ok = bool(ytdlp["available"]) |
107 | | - ollama_ok = bool(ollama["reachable"]) |
108 | | - |
109 | | - return { |
110 | | - "platform": { |
111 | | - "system": platform.system(), |
112 | | - "release": platform.release(), |
113 | | - "python_version": sys.version.split()[0], |
114 | | - "python_executable": sys.executable, |
115 | | - }, |
116 | | - "dependencies": { |
117 | | - "ffmpeg": media_tools["ffmpeg"], |
118 | | - "ffprobe": media_tools["ffprobe"], |
119 | | - "yt_dlp": ytdlp, |
120 | | - "ollama": ollama, |
121 | | - }, |
122 | | - "ready": ffmpeg_ok and ffprobe_ok and ytdlp_ok and ollama_ok, |
123 | | - } |
124 | | - |
125 | | - @app.post("/setup/bootstrap") |
126 | | - async def setup_bootstrap(_=Depends(_check_auth)): |
127 | | - return await pipeline.bootstrap_models() |
| 4 | +from video_rss_aggregator.api import IngestRequest, ProcessRequest |
| 5 | +from video_rss_aggregator.api import create_app as create_runtime_app |
| 6 | +from video_rss_aggregator.bootstrap import AppRuntime, AppUseCases, build_runtime |
128 | 7 |
|
129 | | - @app.post("/ingest") |
130 | | - async def ingest(req: IngestRequest, _=Depends(_check_auth)): |
131 | | - report = await pipeline.ingest_feed(req.feed_url, req.process, req.max_items) |
132 | | - return { |
133 | | - "feed_title": report.feed_title, |
134 | | - "item_count": report.item_count, |
135 | | - "processed_count": report.processed_count, |
136 | | - } |
137 | 8 |
|
138 | | - @app.post("/process") |
139 | | - async def process(req: ProcessRequest, _=Depends(_check_auth)): |
140 | | - report = await pipeline.process_source(req.source_url, req.title) |
141 | | - return { |
142 | | - "source_url": report.source_url, |
143 | | - "title": report.title, |
144 | | - "transcript_chars": report.transcript_chars, |
145 | | - "frame_count": report.frame_count, |
146 | | - "summary": { |
147 | | - "summary": report.summary.summary, |
148 | | - "key_points": report.summary.key_points, |
149 | | - "visual_highlights": report.summary.visual_highlights, |
150 | | - "model_used": report.summary.model_used, |
151 | | - "vram_mb": report.summary.vram_mb, |
152 | | - "error": report.summary.error, |
153 | | - }, |
154 | | - } |
| 9 | +def create_app(runtime: AppRuntime | None = None, config: Config | None = None): |
| 10 | + if runtime is not None and not isinstance(runtime, AppRuntime): |
| 11 | + raise TypeError("create_app expects an AppRuntime or None") |
155 | 12 |
|
156 | | - @app.get("/rss") |
157 | | - async def rss_feed(limit: int = Query(20, ge=1, le=200)): |
158 | | - xml = await pipeline.rss_feed(limit) |
159 | | - return Response(content=xml, media_type="application/rss+xml") |
| 13 | + return create_runtime_app(runtime=runtime, config=config) |
160 | 14 |
|
161 | | - @app.get("/runtime") |
162 | | - async def runtime(_=Depends(_check_auth)): |
163 | | - return await pipeline.runtime_status() |
164 | 15 |
|
165 | | - return app |
| 16 | +__all__ = [ |
| 17 | + "AppRuntime", |
| 18 | + "AppUseCases", |
| 19 | + "IngestRequest", |
| 20 | + "ProcessRequest", |
| 21 | + "build_runtime", |
| 22 | + "create_app", |
| 23 | +] |
0 commit comments