fix: Rezeptverlauf-Bilder lokal speichern und Windows-Subprocess-Fix

- Bilder beim Extrahieren lokal cachen (data/images/{id}.ext) und über GET /api/history/{id}/image ausliefern; Löschen bei Verlaufseintrag-Entfernung
- history.db in ./data/ Volume verschoben, damit sie Container-Rebuilds überlebt
- yt-dlp Subprocess-Aufrufe auf Windows gefixt: run_in_executor statt asyncio.create_subprocess_exec (SelectorEventLoop-Kompatibilität)
- ./data:/app/data Docker-Volume für persistente Datenhaltung hinzugefügt
This commit is contained in:
Andre
2026-04-14 17:17:03 +02:00
parent 4de67f0192
commit c7582dafbb
13 changed files with 125 additions and 33 deletions
+14 -1
View File
@@ -1,5 +1,6 @@
"""GET /api/extract/stream – SSE endpoint."""
from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncGenerator
@@ -13,12 +14,22 @@ from app.extractors.video import extract_video
from app.models import Recipe, StatusEvent, ErrorEvent, ExtractResponse
from app.services.nutrition import enrich_nutrition
from app.services.tandoor_service import recipe_to_tandoor
from app.services.history_service import save_recipe as save_to_history
from app.services.history_service import save_recipe as save_to_history, update_image_url
from app.services import image_service
log = logging.getLogger(__name__)
router = APIRouter()
async def _cache_image_background(history_id: int, remote_url: str) -> None:
local_path = await image_service.download_and_cache(history_id, remote_url)
if local_path:
try:
update_image_url(history_id, local_path)
except Exception as e:
log.warning("Failed to update image_url in DB: %s", e)
def _sse(event: str, data: str) -> str:
return f"event: {event}\ndata: {data}\n\n"
@@ -51,6 +62,8 @@ async def _stream(url: str) -> AsyncGenerator[str, None]:
history_id = save_to_history(base.model_dump())
except Exception as db_err:
log.warning("History save failed: %s", db_err)
if history_id and recipe.image_url:
asyncio.create_task(_cache_image_background(history_id, recipe.image_url))
response = ExtractResponse(recipe=recipe, tandoor_json=tandoor_json, history_id=history_id)
yield _sse("result", response.model_dump_json())
+16 -1
View File
@@ -1,6 +1,8 @@
"""GET /api/history, DELETE /api/history/{id}"""
"""GET /api/history, DELETE /api/history/{id}, GET /api/history/{id}/image"""
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from app.services.history_service import get_all, delete_recipe
from app.services import image_service
router = APIRouter()
@@ -13,8 +15,21 @@ async def get_history():
raise HTTPException(status_code=500, detail=str(e))
@router.get("/history/{history_id}/image")
async def get_history_image(history_id: int):
path = image_service.find_cached_image(history_id)
if path is None:
raise HTTPException(status_code=404, detail="No cached image")
ext_to_mime = {v: k for k, v in image_service.MIME_TO_EXT.items()}
media_type = ext_to_mime.get(path.suffix.lstrip("."), "image/jpeg")
return FileResponse(path, media_type=media_type)
@router.delete("/history/{history_id}")
async def delete_history_entry(history_id: int):
cached = image_service.find_cached_image(history_id)
if cached:
cached.unlink(missing_ok=True)
try:
delete_recipe(history_id)
return {"status": "ok"}
+1
View File
@@ -12,6 +12,7 @@ class Settings(BaseSettings):
app_host: str = "0.0.0.0"
app_port: int = 8000
tmp_dir: str = "/app/tmp"
images_dir: str = "/app/data/images"
ffmpeg_location: str = "" # Optional: full path to ffmpeg binary dir (for Windows local dev)
+27 -27
View File
@@ -4,7 +4,9 @@ import asyncio
import json
import logging
import os
import subprocess
import uuid
from functools import partial
from pathlib import Path
from typing import AsyncGenerator
@@ -122,19 +124,20 @@ def _ffmpeg_args() -> list[str]:
return ["--ffmpeg-location", loc] if loc else []
async def _run_yt_dlp(*args: str, timeout: int) -> tuple[bytes, bytes, int]:
"""Run yt-dlp in a thread executor — works on any event loop (Windows + Linux)."""
loop = asyncio.get_event_loop()
func = partial(subprocess.run, args, capture_output=True, timeout=timeout)
result = await loop.run_in_executor(None, func)
return result.stdout, result.stderr, result.returncode
async def _get_metadata(url: str) -> dict:
"""Run yt-dlp --dump-json to get metadata without downloading."""
proc = await asyncio.create_subprocess_exec(
"yt-dlp",
"--dump-json",
"--no-playlist",
*_ffmpeg_args(),
url,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdout, stderr, returncode = await _run_yt_dlp(
"yt-dlp", "--dump-json", "--no-playlist", *_ffmpeg_args(), url, timeout=60
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60)
if proc.returncode != 0:
if returncode != 0:
err = stderr.decode(errors="replace")
_check_tiktok_error(err, url)
raise RuntimeError(f"yt-dlp Metadaten-Fehler: {err[:500]}")
@@ -148,26 +151,23 @@ async def _download_audio(url: str, output_path: Path) -> None:
"""Download audio only via yt-dlp subprocess (non-blocking)."""
output_path.parent.mkdir(parents=True, exist_ok=True)
proc = await asyncio.create_subprocess_exec(
"yt-dlp",
"--no-playlist",
"--extract-audio",
"--audio-format", "mp3",
"--audio-quality", "5",
"--max-filesize", MAX_FILESIZE,
*_ffmpeg_args(),
"--output", str(output_path).replace(".mp3", ".%(ext)s"),
url,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
except asyncio.TimeoutError:
proc.kill()
stdout, stderr, returncode = await _run_yt_dlp(
"yt-dlp",
"--no-playlist",
"--extract-audio",
"--audio-format", "mp3",
"--audio-quality", "5",
"--max-filesize", MAX_FILESIZE,
*_ffmpeg_args(),
"--output", str(output_path).replace(".mp3", ".%(ext)s"),
url,
timeout=300,
)
except subprocess.TimeoutExpired:
raise RuntimeError("Audio-Download Timeout (5 Min.) überschritten.")
if proc.returncode != 0:
if returncode != 0:
err = stderr.decode(errors="replace")
_check_tiktok_error(err, url)
raise RuntimeError(f"Audio-Download fehlgeschlagen: {err[:500]}")
+10 -1
View File
@@ -11,7 +11,7 @@ from app.config import get_settings
def _db_path() -> Path:
settings = get_settings()
return Path(settings.tmp_dir).parent / "history.db"
return Path(settings.images_dir).parent / "history.db"
def init_db() -> None:
@@ -73,6 +73,15 @@ def delete_recipe(history_id: int) -> None:
conn.commit()
def update_image_url(history_id: int, image_url: str) -> None:
with sqlite3.connect(_db_path()) as conn:
conn.execute(
"UPDATE recipe_history SET image_url = ? WHERE id = ?",
(image_url, history_id),
)
conn.commit()
def mark_imported(history_id: int, tandoor_recipe_id: Optional[int]) -> None:
now = datetime.now(timezone.utc).isoformat()
with sqlite3.connect(_db_path()) as conn:
+50
View File
@@ -0,0 +1,50 @@
"""Local image caching for recipe history."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional
import httpx
from app.config import get_settings
log = logging.getLogger(__name__)
MIME_TO_EXT = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
}
def images_dir() -> Path:
p = Path(get_settings().images_dir)
p.mkdir(parents=True, exist_ok=True)
return p
def find_cached_image(history_id: int) -> Optional[Path]:
"""Return the cached image Path for a history entry, or None."""
for ext in MIME_TO_EXT.values():
p = images_dir() / f"{history_id}.{ext}"
if p.exists():
return p
return None
async def download_and_cache(history_id: int, remote_url: str) -> Optional[str]:
"""Download image from remote_url, save locally, return local API path or None on failure."""
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(remote_url, follow_redirects=True)
resp.raise_for_status()
ct = resp.headers.get("content-type", "image/jpeg").split(";")[0].strip()
ext = MIME_TO_EXT.get(ct, "jpg")
dest = images_dir() / f"{history_id}.{ext}"
dest.write_bytes(resp.content)
log.info("Cached image for history %d → %s", history_id, dest)
return f"/api/history/{history_id}/image"
except Exception as exc:
log.warning("Image cache failed for history %d: %s", history_id, exc)
return None
+3 -2
View File
@@ -280,10 +280,11 @@ function buildHistoryCard(entry) {
const header = document.createElement('div');
header.className = 'history-card-header';
if (recipe.image_url) {
const imageUrl = entry.image_url || recipe.image_url;
if (imageUrl) {
const img = document.createElement('img');
img.className = 'history-thumb';
img.src = recipe.image_url;
img.src = imageUrl;
img.alt = '';
img.onerror = () => img.remove();
header.appendChild(img);