diff --git a/DEPLOY.md b/DEPLOY.md index c001eb7..0596e7b 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -27,8 +27,8 @@ default, e.g. via QEMU emulation). ```sh docker buildx build --platform linux/amd64 \ - --build-arg VERSION=1.16 \ - -t 192.168.2.212:3000/tigeren/metube:1.16 \ + --build-arg VERSION=1.17 \ + -t 192.168.2.212:3000/tigeren/metube:1.17 \ --push . ``` diff --git a/app/main.py b/app/main.py index 46ba8f8..ae33ea0 100644 --- a/app/main.py +++ b/app/main.py @@ -357,39 +357,100 @@ def sniff_image_type(data: bytes) -> str: return 'application/octet-stream' +def _placeholder_thumb_response(): + return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'}) + + +async def _probe_duration(video_path): + """Duration of a local media file in seconds via ffprobe, or None.""" + cmd = ['ffprobe', '-v', 'error', '-show_entries', 'format=duration', + '-of', 'csv=p=0', video_path] + try: + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL) + out, _ = await asyncio.wait_for(proc.communicate(), timeout=15) + return float(out.decode().strip()) + except Exception: # noqa: BLE001 - probing is best-effort + return None + + +async def _extract_video_frame(video_path, thumb_path, duration): + """Extract a frame from a local video file with ffmpeg. Returns True on success.""" + if not duration: + duration = await _probe_duration(video_path) + seek = 1.0 + if duration: + try: + duration = float(duration) + seek = max(0.0, min(duration * 0.25, duration - 1.0)) + except (TypeError, ValueError): + pass + cmd = ['ffmpeg', '-hide_banner', '-loglevel', 'error', + '-ss', str(seek), '-i', video_path, + '-frames:v', '1', '-vf', 'scale=480:-2', '-q:v', '4', + # thumb_path has a generic .img extension: force the JPEG encoder and + # image2 muxer explicitly (ffmpeg would otherwise guess from the name) + '-c:v', 'mjpeg', '-f', 'image2', + '-y', thumb_path] + try: + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE) + _, err = await asyncio.wait_for(proc.communicate(), timeout=30) + except Exception as exc: # noqa: BLE001 - includes TimeoutError; fall through to other sources + log.warning(f"ffmpeg frame extraction failed for {video_path}: {exc}") + try: + proc.kill() + except Exception: # noqa: BLE001 - process may not exist + pass + return False + if proc.returncode != 0: + log.warning(f"ffmpeg frame extraction failed for {video_path}: {err.decode(errors='replace')[-200:]}") + return False + return os.path.exists(thumb_path) + + @routes.get(config.URL_PREFIX + 'thumbnail') async def thumbnail(request): - """Serve a cached copy of a download's remote thumbnail. + """Serve a cached thumbnail for a download. - The URL is taken from stored yt-dlp metadata for the given download key — - clients can never point this endpoint at arbitrary URLs. Missing/invalid - thumbnails fall back to a placeholder SVG. + Sources, in order: a frame extracted from the local file (ffmpeg), the + remote thumbnail URL from stored yt-dlp metadata — clients can never point + this endpoint at arbitrary URLs — and finally a placeholder SVG. """ key = request.query.get('id') if not key: raise web.HTTPBadRequest() info = dqueue.find_info(key) - if info is None or not info.thumbnail: - return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'}) + thumb_url = getattr(info, 'thumbnail', None) if info is not None else None + log.debug(f"[Thumb] key={key[:60]} found={info is not None} has_url={bool(thumb_url)}") thumb_dir = os.path.join(config.STATE_DIR, 'thumbnails') os.makedirs(thumb_dir, exist_ok=True) thumb_path = os.path.join(thumb_dir, hashlib.sha1(key.encode('utf-8')).hexdigest() + '.img') if not os.path.exists(thumb_path): - try: - timeout = aiohttp.ClientTimeout(total=15) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.get(info.thumbnail, headers={'User-Agent': 'Mozilla/5.0 MeTube'}) as resp: - if resp.status != 200: - return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'}) - data = await resp.read() - with open(thumb_path, 'wb') as f: - f.write(data) - except Exception as exc: # noqa: BLE001 - fall back to placeholder on any fetch error - log.warning(f"Failed to fetch thumbnail for {key}: {exc}") - return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'}) + # Prefer a frame from the local file: works even for downloads whose + # metadata predates thumbnail storage, and survives expiring remote URLs + local_file = dqueue.find_download_file(key) + log.debug(f"[Thumb] local_file={local_file}") + if local_file: + await _extract_video_frame(local_file, thumb_path, getattr(info, 'duration', None)) + + if not os.path.exists(thumb_path) and thumb_url: + try: + timeout = aiohttp.ClientTimeout(total=15) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(thumb_url, headers={'User-Agent': 'Mozilla/5.0 MeTube'}) as resp: + if resp.status == 200: + data = await resp.read() + with open(thumb_path, 'wb') as f: + f.write(data) + except Exception as exc: # noqa: BLE001 - fall back to placeholder on any fetch error + log.warning(f"Failed to fetch thumbnail for {key}: {exc}") + + if not os.path.exists(thumb_path): + return _placeholder_thumb_response() with open(thumb_path, 'rb') as f: data = f.read() diff --git a/app/ytdl.py b/app/ytdl.py index b5060cc..efdd5d6 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -326,6 +326,11 @@ class PersistentQueue: # Ensure file_exists field exists if not hasattr(v, 'file_exists'): v.file_exists = None + # Ensure thumbnail/duration fields exist (added for the thumbnail UI) + if not hasattr(v, 'thumbnail'): + v.thumbnail = None + if not hasattr(v, 'duration'): + v.duration = None self.dict[k] = Download(None, None, None, None, None, None, {}, v) def exists(self, key): @@ -396,6 +401,20 @@ class DownloadQueue: return v return None + def find_download_file(self, key): + """Absolute path of the downloaded file for key, if it exists on disk.""" + info = self.find_info(key) + if info is None: + return None + filename = getattr(info, 'filename', None) + if not filename: + return None + dldirectory, error = self.__calc_download_path(info.quality, info.format, getattr(info, 'folder', None)) + if error is not None or not dldirectory: + return None + path = os.path.join(dldirectory, filename) + return path if os.path.exists(path) else None + async def __import_queue(self): for k, v in self.queue.saved_items(): await self.__add_download(v, True) @@ -475,6 +494,12 @@ class DownloadQueue: if full_entry and 'title' in full_entry: title = full_entry['title'] log.debug(f"[PreCheck] Got real title from full extraction: '{title}'") + # Backfill thumbnail/duration for flat playlist entries + if not getattr(dl, 'thumbnail', None): + thumbnails = full_entry.get('thumbnails') or [] + dl.thumbnail = full_entry.get('thumbnail') or (thumbnails[-1].get('url') if thumbnails else None) + if not getattr(dl, 'duration', None): + dl.duration = full_entry.get('duration') except Exception as e: log.warning(f"[PreCheck] Failed to get full info: {e}, using placeholder title") diff --git a/metube-config/thumbnails/ae6faa8ca099061048598101fa6cf0ab9f5fcf76.img b/metube-config/thumbnails/ae6faa8ca099061048598101fa6cf0ab9f5fcf76.img new file mode 100644 index 0000000..e28ba26 Binary files /dev/null and b/metube-config/thumbnails/ae6faa8ca099061048598101fa6cf0ab9f5fcf76.img differ diff --git a/metube-config/thumbnails/c5e1b77b813229c0d3a79901489ead901d0967e2.img b/metube-config/thumbnails/c5e1b77b813229c0d3a79901489ead901d0967e2.img new file mode 100644 index 0000000..c990238 Binary files /dev/null and b/metube-config/thumbnails/c5e1b77b813229c0d3a79901489ead901d0967e2.img differ diff --git a/metube-config/thumbnails/f36f1bf0daecf070b66568cd0ece19ad033cd5ec.img b/metube-config/thumbnails/f36f1bf0daecf070b66568cd0ece19ad033cd5ec.img new file mode 100644 index 0000000..c3d1c7c Binary files /dev/null and b/metube-config/thumbnails/f36f1bf0daecf070b66568cd0ece19ad033cd5ec.img differ diff --git a/ui/src/app/download-card.component.html b/ui/src/app/download-card.component.html index a5a3be7..c7da77e 100644 --- a/ui/src/app/download-card.component.html +++ b/ui/src/app/download-card.component.html @@ -1,10 +1,7 @@