mirror of
https://github.com/Andre-Bzk/Rezept0r.git
synced 2026-11-15 15:43:04 +01:00
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:
@@ -5,3 +5,4 @@ TANDOOR_API_TOKEN=
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
TMP_DIR=/app/tmp
|
||||
IMAGES_DIR=./data/images
|
||||
|
||||
@@ -18,3 +18,4 @@ tmp/
|
||||
CLAUDE.md
|
||||
.vscode/
|
||||
.claude/
|
||||
data/
|
||||
+14
-1
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+18
-18
@@ -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,7 +151,8 @@ 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(
|
||||
try:
|
||||
stdout, stderr, returncode = await _run_yt_dlp(
|
||||
"yt-dlp",
|
||||
"--no-playlist",
|
||||
"--extract-audio",
|
||||
@@ -158,16 +162,12 @@ async def _download_audio(url: str, output_path: Path) -> None:
|
||||
*_ffmpeg_args(),
|
||||
"--output", str(output_path).replace(".mp3", ".%(ext)s"),
|
||||
url,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
timeout=300,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
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]}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
@@ -10,5 +10,6 @@ services:
|
||||
- "88:8000"
|
||||
volumes:
|
||||
- ./tmp:/app/tmp
|
||||
- ./data:/app/data
|
||||
mem_limit: 512m
|
||||
memswap_limit: 512m # No swap – protect SD card
|
||||
|
||||
Reference in New Issue
Block a user