from __future__ import annotations import asyncio import hashlib import logging import os import subprocess from pathlib import Path from app.features.streaming.library.ffprobe import ffprobe from app.library.cache import Cache from app.library.config import Config from app.library.Utils import FILES_TYPE, get_file_sidecar LOG: logging.Logger = logging.getLogger("streaming.thumbnail") IMAGE_TYPES: tuple[str, ...] = (".jpg", ".jpeg", ".png", ".webp") FOLDER_IMAGE_ORDER: tuple[str, ...] = ("thumbnail", "poster", "artwork", "cover", "fanart") THUMBNAIL_SAMPLE_FRAMES = 200 THUMBNAIL_DEFAULT_SEEK_SECONDS = 3.0 THUMBNAIL_MIN_SEEK_SECONDS = 1.0 THUMBNAIL_MAX_SEEK_SECONDS = 15.0 THUMBNAIL_END_MARGIN_SECONDS = 0.1 THUMBNAIL_MISS_TTL = 3600.0 _LOCK = asyncio.Lock() _IN_PROCESS: dict[str, asyncio.Task[Path | None]] = {} _SEM: asyncio.Semaphore | None = None _SEM_LIMIT: int | None = None def _get_semaphore() -> asyncio.Semaphore: global _SEM, _SEM_LIMIT # noqa: PLW0603 limit: int = max(1, int(Config.get_instance().thumb_concurrency)) if _SEM is None or _SEM_LIMIT != limit: _SEM = asyncio.Semaphore(limit) _SEM_LIMIT = limit LOG.info(f"Configured thumbnail generation concurrency limit: {limit}") return _SEM def _is_same_stem_image(media_file: Path, image_file: Path) -> bool: if image_file.parent != media_file.parent: return False for image_type in FILES_TYPE: if "image" != image_type.get("type"): continue rx = image_type.get("rx") if rx and rx.search(image_file.name): break else: return False return image_file.stem == media_file.stem def pick_local_thumb(media_file: Path) -> Path | None: sidecar: dict[str, list[dict[str, str]]] = get_file_sidecar(media_file) images: list[dict[str, str]] = sidecar.get("image", []) local_images: list[Path] = [Path(item["file"]) for item in images if isinstance(item.get("file"), Path)] for image_file in local_images: if image_file.suffix.lower() in IMAGE_TYPES and _is_same_stem_image(media_file, image_file): return image_file by_name: dict[str, Path] = { image_file.stem.lower(): image_file for image_file in local_images if image_file.suffix.lower() in IMAGE_TYPES } for name in FOLDER_IMAGE_ORDER: if image_file := by_name.get(name): return image_file return None def _cache_key(media_file: Path) -> str: stat = media_file.stat() payload: str = f"{media_file.resolve()}:{stat.st_size}:{stat.st_mtime_ns}" return hashlib.sha256(payload.encode("utf-8")).hexdigest() def _seek_seconds(ff_info) -> float | None: duration: float = 0.0 try: duration = float(ff_info.metadata.get("duration") or 0.0) except (TypeError, ValueError): duration = 0.0 if duration <= 0: return THUMBNAIL_DEFAULT_SEEK_SECONDS if duration <= THUMBNAIL_END_MARGIN_SECONDS: return None return min( max(duration * 0.10, THUMBNAIL_MIN_SEEK_SECONDS), THUMBNAIL_MAX_SEEK_SECONDS, duration - THUMBNAIL_END_MARGIN_SECONDS, ) def _build_ffmpeg_args(media_file: Path, output_file: Path, *, seek_seconds: float | None) -> list[str]: args: list[str] = [ "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y", ] if seek_seconds is not None and seek_seconds > 0: args.extend(["-ss", f"{seek_seconds:.3f}"]) args.extend( [ "-i", str(media_file), "-vf", f"thumbnail={THUMBNAIL_SAMPLE_FRAMES},scale=1280:-1:force_original_aspect_ratio=decrease", "-frames:v", "1", "-q:v", "3", "-an", str(output_file), ] ) return args async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None: ff_info = await ffprobe(media_file) if not ff_info.has_video(): LOG.debug(f"Skipping thumbnail generation for '{media_file}' because no video stream exists.") return None output_file.parent.mkdir(parents=True, exist_ok=True) temp_file = output_file.with_suffix(".tmp.jpg") if temp_file.exists(): temp_file.unlink(missing_ok=True) seek_seconds: float | None = _seek_seconds(ff_info) attempts: list[float | None] = [seek_seconds] if seek_seconds is not None and seek_seconds > 0: attempts.append(None) sem = _get_semaphore() if sem.locked(): limit = _SEM_LIMIT or 1 LOG.debug(f"Waiting for thumbnail generation slot for '{media_file}'. limit={limit}") async with sem: last_error: str = "ffmpeg produced an empty thumbnail file" for idx, attempt_seek in enumerate(attempts, start=1): temp_file.unlink(missing_ok=True) args: list[str] = _build_ffmpeg_args(media_file, temp_file, seek_seconds=attempt_seek) LOG.debug( f"Generating thumbnail for '{media_file}'. attempt={idx}/{len(attempts)} seek={'none' if attempt_seek is None else f'{attempt_seek:.3f}s'}" ) try: proc = await asyncio.create_subprocess_exec( *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, ) except FileNotFoundError as exc: msg = "ffmpeg not found." raise OSError(msg) from exc stdout, stderr = await proc.communicate() if 0 == proc.returncode and temp_file.exists() and temp_file.stat().st_size > 0: temp_file.replace(output_file) LOG.info(f"Generated thumbnail '{output_file}' for '{media_file}'.") return output_file if 0 != proc.returncode: last_error: str = ( f"ffmpeg thumbnail generation failed (rc={proc.returncode}).\n" f"stdout:\n{stdout.decode('utf-8', errors='replace')}\n" f"stderr:\n{stderr.decode('utf-8', errors='replace')}" ) else: last_error: str = "ffmpeg produced an empty thumbnail file" LOG.debug( f"Thumbnail generation attempt failed for '{media_file}'. seek={'none' if attempt_seek is None else f'{attempt_seek:.3f}s'}" ) temp_file.unlink(missing_ok=True) raise OSError(last_error) async def ensure_thumb(media_file: Path, cache_root: Path) -> Path | None: config: Config = Config.get_instance() cache: Cache = Cache.get_instance() if bool(config.thumb_generate) is not True: return None key: str = _cache_key(media_file) cache_file: Path = ( media_file.with_name(f"{media_file.name}.thumb.jpg") if bool(config.thumb_sidecar) else cache_root / f"{key}.jpg" ) miss_key: str = f"thumbnail-miss:{key}" if cache_file.exists() and cache_file.stat().st_size > 0: cache.delete(miss_key) return cache_file if cache.has(miss_key): return None async with _LOCK: if cache_file.exists() and cache_file.stat().st_size > 0: cache.delete(miss_key) return cache_file if cache.has(miss_key): return None task = _IN_PROCESS.get(key) if task is not None and not task.done(): LOG.debug(f"Waiting for thumbnail generation for '{media_file}'.") else: task = asyncio.create_task(_run_ffmpeg(media_file, cache_file), name=f"thumb-{key[:8]}") _IN_PROCESS[key] = task LOG.debug(f"Starting thumbnail generation task for '{media_file}' -> '{cache_file}'.") try: result: Path | None = await task if result is None: cache.set(miss_key, value=True, ttl=THUMBNAIL_MISS_TTL) else: cache.delete(miss_key) return result except OSError: cache.set(miss_key, value=True, ttl=THUMBNAIL_MISS_TTL) raise finally: async with _LOCK: current = _IN_PROCESS.get(key) if current is task and task.done(): _IN_PROCESS.pop(key, None)