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
+1
View File
@@ -5,3 +5,4 @@ TANDOOR_API_TOKEN=
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8000 APP_PORT=8000
TMP_DIR=/app/tmp TMP_DIR=/app/tmp
IMAGES_DIR=./data/images
+1
View File
@@ -18,3 +18,4 @@ tmp/
CLAUDE.md CLAUDE.md
.vscode/ .vscode/
.claude/ .claude/
data/
+14 -1
View File
@@ -1,5 +1,6 @@
"""GET /api/extract/stream – SSE endpoint.""" """GET /api/extract/stream – SSE endpoint."""
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
from typing import AsyncGenerator 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.models import Recipe, StatusEvent, ErrorEvent, ExtractResponse
from app.services.nutrition import enrich_nutrition from app.services.nutrition import enrich_nutrition
from app.services.tandoor_service import recipe_to_tandoor 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__) log = logging.getLogger(__name__)
router = APIRouter() 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: def _sse(event: str, data: str) -> str:
return f"event: {event}\ndata: {data}\n\n" 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()) history_id = save_to_history(base.model_dump())
except Exception as db_err: except Exception as db_err:
log.warning("History save failed: %s", 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) response = ExtractResponse(recipe=recipe, tandoor_json=tandoor_json, history_id=history_id)
yield _sse("result", response.model_dump_json()) 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 import APIRouter, HTTPException
from fastapi.responses import FileResponse
from app.services.history_service import get_all, delete_recipe from app.services.history_service import get_all, delete_recipe
from app.services import image_service
router = APIRouter() router = APIRouter()
@@ -13,8 +15,21 @@ async def get_history():
raise HTTPException(status_code=500, detail=str(e)) 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}") @router.delete("/history/{history_id}")
async def delete_history_entry(history_id: int): async def delete_history_entry(history_id: int):
cached = image_service.find_cached_image(history_id)
if cached:
cached.unlink(missing_ok=True)
try: try:
delete_recipe(history_id) delete_recipe(history_id)
return {"status": "ok"} return {"status": "ok"}
+1
View File
@@ -12,6 +12,7 @@ class Settings(BaseSettings):
app_host: str = "0.0.0.0" app_host: str = "0.0.0.0"
app_port: int = 8000 app_port: int = 8000
tmp_dir: str = "/app/tmp" 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) 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 json
import logging import logging
import os import os
import subprocess
import uuid import uuid
from functools import partial
from pathlib import Path from pathlib import Path
from typing import AsyncGenerator from typing import AsyncGenerator
@@ -122,19 +124,20 @@ def _ffmpeg_args() -> list[str]:
return ["--ffmpeg-location", loc] if loc else [] 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: async def _get_metadata(url: str) -> dict:
"""Run yt-dlp --dump-json to get metadata without downloading.""" """Run yt-dlp --dump-json to get metadata without downloading."""
proc = await asyncio.create_subprocess_exec( stdout, stderr, returncode = await _run_yt_dlp(
"yt-dlp", "yt-dlp", "--dump-json", "--no-playlist", *_ffmpeg_args(), url, timeout=60
"--dump-json",
"--no-playlist",
*_ffmpeg_args(),
url,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60) if returncode != 0:
if proc.returncode != 0:
err = stderr.decode(errors="replace") err = stderr.decode(errors="replace")
_check_tiktok_error(err, url) _check_tiktok_error(err, url)
raise RuntimeError(f"yt-dlp Metadaten-Fehler: {err[:500]}") 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).""" """Download audio only via yt-dlp subprocess (non-blocking)."""
output_path.parent.mkdir(parents=True, exist_ok=True) 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: try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300) stdout, stderr, returncode = await _run_yt_dlp(
except asyncio.TimeoutError: "yt-dlp",
proc.kill() "--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.") raise RuntimeError("Audio-Download Timeout (5 Min.) überschritten.")
if proc.returncode != 0: if returncode != 0:
err = stderr.decode(errors="replace") err = stderr.decode(errors="replace")
_check_tiktok_error(err, url) _check_tiktok_error(err, url)
raise RuntimeError(f"Audio-Download fehlgeschlagen: {err[:500]}") 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: def _db_path() -> Path:
settings = get_settings() settings = get_settings()
return Path(settings.tmp_dir).parent / "history.db" return Path(settings.images_dir).parent / "history.db"
def init_db() -> None: def init_db() -> None:
@@ -73,6 +73,15 @@ def delete_recipe(history_id: int) -> None:
conn.commit() 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: def mark_imported(history_id: int, tandoor_recipe_id: Optional[int]) -> None:
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
with sqlite3.connect(_db_path()) as conn: 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'); const header = document.createElement('div');
header.className = 'history-card-header'; header.className = 'history-card-header';
if (recipe.image_url) { const imageUrl = entry.image_url || recipe.image_url;
if (imageUrl) {
const img = document.createElement('img'); const img = document.createElement('img');
img.className = 'history-thumb'; img.className = 'history-thumb';
img.src = recipe.image_url; img.src = imageUrl;
img.alt = ''; img.alt = '';
img.onerror = () => img.remove(); img.onerror = () => img.remove();
header.appendChild(img); header.appendChild(img);
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

+1
View File
@@ -10,5 +10,6 @@ services:
- "88:8000" - "88:8000"
volumes: volumes:
- ./tmp:/app/tmp - ./tmp:/app/tmp
- ./data:/app/data
mem_limit: 512m mem_limit: 512m
memswap_limit: 512m # No swap – protect SD card memswap_limit: 512m # No swap – protect SD card