diff --git a/.env.example b/.env.example index 93ef6c8..d5d0104 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,4 @@ TANDOOR_API_TOKEN= APP_HOST=0.0.0.0 APP_PORT=8000 TMP_DIR=/app/tmp +IMAGES_DIR=./data/images diff --git a/.gitignore b/.gitignore index 966c24c..767bd3d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ tmp/ *.log CLAUDE.md .vscode/ -.claude/ \ No newline at end of file +.claude/ +data/ \ No newline at end of file diff --git a/app/api/extract.py b/app/api/extract.py index 7878e3c..fbb0e5b 100644 --- a/app/api/extract.py +++ b/app/api/extract.py @@ -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()) diff --git a/app/api/history.py b/app/api/history.py index bfeb4d0..ce18df5 100644 --- a/app/api/history.py +++ b/app/api/history.py @@ -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"} diff --git a/app/config.py b/app/config.py index 64b2069..9f70ca2 100644 --- a/app/config.py +++ b/app/config.py @@ -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) diff --git a/app/extractors/video.py b/app/extractors/video.py index 3f05f57..2fffab7 100644 --- a/app/extractors/video.py +++ b/app/extractors/video.py @@ -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]}") diff --git a/app/services/history_service.py b/app/services/history_service.py index 386cf54..696f87c 100644 --- a/app/services/history_service.py +++ b/app/services/history_service.py @@ -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: diff --git a/app/services/image_service.py b/app/services/image_service.py new file mode 100644 index 0000000..6e797b3 --- /dev/null +++ b/app/services/image_service.py @@ -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 diff --git a/app/static/app.js b/app/static/app.js index df2c018..23ef3cf 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -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); diff --git a/history.db b/data/history.db similarity index 50% rename from history.db rename to data/history.db index a200dd3..04f4141 100644 Binary files a/history.db and b/data/history.db differ diff --git a/data/images/17.webp b/data/images/17.webp new file mode 100644 index 0000000..bd8ae36 Binary files /dev/null and b/data/images/17.webp differ diff --git a/data/images/18.jpg b/data/images/18.jpg new file mode 100644 index 0000000..64eec6e Binary files /dev/null and b/data/images/18.jpg differ diff --git a/docker-compose.yml b/docker-compose.yml index 9278481..45c97ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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