feat: 缩略图优先从本地视频抽帧生成,远程图为回退

This commit is contained in:
tigerenwork 2026-08-12 00:45:45 +08:00
parent 451cc3e0c3
commit 4a85bdfaf6
8 changed files with 109 additions and 26 deletions

View File

@ -27,8 +27,8 @@ default, e.g. via QEMU emulation).
```sh ```sh
docker buildx build --platform linux/amd64 \ docker buildx build --platform linux/amd64 \
--build-arg VERSION=1.16 \ --build-arg VERSION=1.17 \
-t 192.168.2.212:3000/tigeren/metube:1.16 \ -t 192.168.2.212:3000/tigeren/metube:1.17 \
--push . --push .
``` ```

View File

@ -357,39 +357,100 @@ def sniff_image_type(data: bytes) -> str:
return 'application/octet-stream' 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') @routes.get(config.URL_PREFIX + 'thumbnail')
async def thumbnail(request): 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 Sources, in order: a frame extracted from the local file (ffmpeg), the
clients can never point this endpoint at arbitrary URLs. Missing/invalid remote thumbnail URL from stored yt-dlp metadata clients can never point
thumbnails fall back to a placeholder SVG. this endpoint at arbitrary URLs and finally a placeholder SVG.
""" """
key = request.query.get('id') key = request.query.get('id')
if not key: if not key:
raise web.HTTPBadRequest() raise web.HTTPBadRequest()
info = dqueue.find_info(key) info = dqueue.find_info(key)
if info is None or not info.thumbnail: thumb_url = getattr(info, 'thumbnail', None) if info is not None else None
return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'}) 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') thumb_dir = os.path.join(config.STATE_DIR, 'thumbnails')
os.makedirs(thumb_dir, exist_ok=True) os.makedirs(thumb_dir, exist_ok=True)
thumb_path = os.path.join(thumb_dir, hashlib.sha1(key.encode('utf-8')).hexdigest() + '.img') thumb_path = os.path.join(thumb_dir, hashlib.sha1(key.encode('utf-8')).hexdigest() + '.img')
if not os.path.exists(thumb_path): if not os.path.exists(thumb_path):
try: # Prefer a frame from the local file: works even for downloads whose
timeout = aiohttp.ClientTimeout(total=15) # metadata predates thumbnail storage, and survives expiring remote URLs
async with aiohttp.ClientSession(timeout=timeout) as session: local_file = dqueue.find_download_file(key)
async with session.get(info.thumbnail, headers={'User-Agent': 'Mozilla/5.0 MeTube'}) as resp: log.debug(f"[Thumb] local_file={local_file}")
if resp.status != 200: if local_file:
return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'}) await _extract_video_frame(local_file, thumb_path, getattr(info, 'duration', None))
data = await resp.read()
with open(thumb_path, 'wb') as f: if not os.path.exists(thumb_path) and thumb_url:
f.write(data) try:
except Exception as exc: # noqa: BLE001 - fall back to placeholder on any fetch error timeout = aiohttp.ClientTimeout(total=15)
log.warning(f"Failed to fetch thumbnail for {key}: {exc}") async with aiohttp.ClientSession(timeout=timeout) as session:
return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'}) 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: with open(thumb_path, 'rb') as f:
data = f.read() data = f.read()

View File

@ -326,6 +326,11 @@ class PersistentQueue:
# Ensure file_exists field exists # Ensure file_exists field exists
if not hasattr(v, 'file_exists'): if not hasattr(v, 'file_exists'):
v.file_exists = None 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) self.dict[k] = Download(None, None, None, None, None, None, {}, v)
def exists(self, key): def exists(self, key):
@ -396,6 +401,20 @@ class DownloadQueue:
return v return v
return None 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): async def __import_queue(self):
for k, v in self.queue.saved_items(): for k, v in self.queue.saved_items():
await self.__add_download(v, True) await self.__add_download(v, True)
@ -475,6 +494,12 @@ class DownloadQueue:
if full_entry and 'title' in full_entry: if full_entry and 'title' in full_entry:
title = full_entry['title'] title = full_entry['title']
log.debug(f"[PreCheck] Got real title from full extraction: '{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: except Exception as e:
log.warning(f"[PreCheck] Failed to get full info: {e}, using placeholder title") log.warning(f"[PreCheck] Failed to get full info: {e}, using placeholder title")

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -1,10 +1,7 @@
<div class="card" [class.selected]="selected"> <div class="card" [class.selected]="selected">
<input type="checkbox" class="card-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title"> <input type="checkbox" class="card-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title">
<div class="thumb"> <div class="thumb">
<img *ngIf="row.value.thumbnail" [src]="thumbUrl()" [alt]="row.value.title" loading="lazy"> <img [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
<span class="thumb-ph" *ngIf="!row.value.thumbnail">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
</span>
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span> <span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
<span *ngIf="row.value.file_exists === false" class="file-warn" title="文件不存在"> <span *ngIf="row.value.file_exists === false" class="file-warn" title="文件不存在">
<fa-icon [icon]="faTimesCircle"></fa-icon> <fa-icon [icon]="faTimesCircle"></fa-icon>

View File

@ -1,8 +1,8 @@
<div class="q-row" [class.selected]="selected"> <div class="q-row" [class.selected]="selected">
<input type="checkbox" class="q-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title"> <input type="checkbox" class="q-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title">
<div class="thumb"> <div class="thumb">
<img *ngIf="row.value.thumbnail" [src]="thumbUrl()" [alt]="row.value.title" loading="lazy"> <img *ngIf="status === 'completed' || row.value.thumbnail" [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
<span class="thumb-ph" *ngIf="!row.value.thumbnail"> <span class="thumb-ph" *ngIf="status !== 'completed' && !row.value.thumbnail">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
</span> </span>
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span> <span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>