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_HOST=0.0.0.0
|
||||||
APP_PORT=8000
|
APP_PORT=8000
|
||||||
TMP_DIR=/app/tmp
|
TMP_DIR=/app/tmp
|
||||||
|
IMAGES_DIR=./data/images
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ tmp/
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.vscode/
|
.vscode/
|
||||||
.claude/
|
.claude/
|
||||||
|
data/
|
||||||
+14
-1
@@ -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
@@ -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"}
|
||||||
|
|||||||
@@ -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
@@ -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]}")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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');
|
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 |
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user