plugin File Sharing Management: generate video thumbnails:

Video thumbnails are now generated too (recursively, along with images) on the `thumb`
command. The poster frame is extracted at 25% of the duration (instead of a fixed 10s
seek).

Thumbnails are now kept in cache indefinitely.
This commit is contained in:
Goffi 2026-10-20 13:15:26 +02:00
commit 8e4e96dcbb
3 changed files with 114 additions and 28 deletions

View file

@ -512,8 +512,7 @@ class FileSharing:
thumb_size, thumb_id = await self._t.generate_thumbnail( thumb_size, thumb_id = await self._t.generate_thumbnail(
image_path, image_path,
max_thumb_size, max_thumb_size,
#  we keep thumbnails for 6 months C.MAX_AGE_PERSISTENT,
60 * 60 * 24 * 31 * 6,
) )
except Exception as e: except Exception as e:
log.warning(_("Can't create thumbnail: {reason}").format(reason=e)) log.warning(_("Can't create thumbnail: {reason}").format(reason=e))

View file

@ -17,7 +17,9 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import os.path import os.path
import tempfile
from functools import partial from functools import partial
from pathlib import Path
from typing import cast from typing import cast
from twisted.internet import defer from twisted.internet import defer
from twisted.words.protocols.jabber import jid from twisted.words.protocols.jabber import jid
@ -38,6 +40,7 @@ from libervia.backend.plugins.plugin_xep_0050 import (
Error, Error,
) )
from libervia.backend.plugins.plugin_xep_0264 import XEP_0264 from libervia.backend.plugins.plugin_xep_0264 import XEP_0264
from libervia.backend.tools import video
from libervia.backend.tools.common import utils from libervia.backend.tools.common import utils
log = getLogger(__name__) log = getLogger(__name__)
@ -414,6 +417,26 @@ class FileSharingManagement:
def _update_thumbs(self, extra, thumbnails): def _update_thumbs(self, extra, thumbnails):
extra[C.KEY_THUMBNAILS] = thumbnails extra[C.KEY_THUMBNAILS] = thumbnails
async def _gen_thumbs_list(self, source_path: str | Path) -> list[dict]:
"""Generate the list of thumbnails from an image path
@param source_path: path of the image to generate thumbnails from
@return: list of thumbnail data
"""
thumbnails = []
for max_thumb_size in self._t.SIZES:
try:
thumb_size, thumb_id = await self._t.generate_thumbnail(
source_path,
max_thumb_size,
C.MAX_AGE_PERSISTENT,
)
except Exception as e:
log.warning(_("Can't create thumbnail: {reason}").format(reason=e))
break
thumbnails.append({"id": thumb_id, "size": thumb_size})
return thumbnails
async def _gen_thumbs(self, client, requestor, namespace, file_data): async def _gen_thumbs(self, client, requestor, namespace, file_data):
"""Recursively generate thumbnails """Recursively generate thumbnails
@ -430,34 +453,32 @@ class FileSharingManagement:
media_type = file_data["media_type"] media_type = file_data["media_type"]
file_path = os.path.join(self.files_path, file_data["file_hash"]) file_path = os.path.join(self.files_path, file_data["file_hash"])
if media_type == "image": if media_type == "image":
thumbnails = [] thumbnails = await self._gen_thumbs_list(file_path)
elif media_type == "video":
for max_thumb_size in self._t.SIZES: with tempfile.TemporaryDirectory() as tmp_dir:
poster_path = Path(tmp_dir) / "poster.jpg"
try: try:
thumb_size, thumb_id = await self._t.generate_thumbnail( await video.get_thumbnail(file_path, poster_path)
file_path,
max_thumb_size,
#  we keep thumbnails for 6 months
60 * 60 * 24 * 31 * 6,
)
except Exception as e: except Exception as e:
log.warning( log.warning(
_("Can't create thumbnail: {reason}").format(reason=e) _("Can't get video thumbnail for {name}: {e}").format(
name=file_data["name"], e=e
)
) )
break return
thumbnails.append({"id": thumb_id, "size": thumb_size}) thumbnails = await self._gen_thumbs_list(poster_path)
else:
await self.host.memory.file_update( return
file_data["id"], await self.host.memory.file_update(
"extra", file_data["id"],
partial(self._update_thumbs, thumbnails=thumbnails), "extra",
) partial(self._update_thumbs, thumbnails=thumbnails),
)
log.info( log.info(
"thumbnails for [{file_name}] generated".format( "thumbnails for [{file_name}] generated".format(
file_name=file_data["name"] file_name=file_data["name"]
)
) )
)
else: else:
log.warning("unmanaged file type: {type_}".format(type_=file_data["type"])) log.warning("unmanaged file type: {type_}".format(type_=file_data["type"]))

View file

@ -28,17 +28,54 @@ from .common import async_process
log = getLogger(__name__) log = getLogger(__name__)
try: try:
ffmpeg_path = which("ffmpeg")[0] ffmpeg_path = which("ffmpeg")[0]
except IndexError: except IndexError:
log.warning(_("ffmpeg executable not found, video thumbnails won't be available")) log.warning(_("ffmpeg executable not found, video thumbnails won't be available"))
ffmpeg_path = None ffmpeg_path = None
try:
ffprobe_path = which("ffprobe")[0]
except IndexError:
log.warning(
_("ffprobe executable not found, video thumbnails will use the first frame")
)
ffprobe_path = None
async def _get_duration(video_path: Path | str) -> float | None:
"""Get the duration of a video in seconds
@return: duration in seconds, or None if ffprobe is unavailable or fails
"""
if ffprobe_path is None:
return None
try:
result = await async_process.run(
ffprobe_path,
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(video_path),
)
except exceptions.CommandException:
return None
try:
return float(result)
except ValueError:
log.error(f"Can't parse ffprobe duration for {video_path}: {result!r}")
return None
async def get_thumbnail(video_path: Union[Path, str], dest_path: Path) -> Path: async def get_thumbnail(video_path: Union[Path, str], dest_path: Path) -> Path:
"""Extract thumbnail from video """Extract thumbnail from video
The frame is extracted at 25% of the video duration, falling back to the first frame
if the duration is unknown or the extraction fails.
@param video_path: source of the video @param video_path: source of the video
@param dest_path: path where the file must be saved @param dest_path: path where the file must be saved
@return: path of the generated thumbnail @return: path of the generated thumbnail
@ -46,12 +83,41 @@ async def get_thumbnail(video_path: Union[Path, str], dest_path: Path) -> Path:
it should be deleted after use. it should be deleted after use.
Image will be in JPEG format. Image will be in JPEG format.
@raise exceptions.NotFound: ffmpeg is missing @raise exceptions.NotFound: ffmpeg is missing
@raise exceptions.DataError: can't extract a frame from the video
""" """
if ffmpeg_path is None: if ffmpeg_path is None:
raise exceptions.NotFound( raise exceptions.NotFound(
_("ffmpeg executable is not available, can't generate video thumbnail") _("ffmpeg executable is not available, can't generate video thumbnail")
) )
ffmpeg = ffmpeg_path
await async_process.run( async def extract(seek_time: str) -> None:
ffmpeg_path, "-i", str(video_path), "-ss", "10", "-frames:v", "1", str(dest_path) await async_process.run(
ffmpeg,
"-ss",
seek_time,
"-i",
str(video_path),
"-frames:v",
"1",
"-y",
str(dest_path),
)
duration = await _get_duration(video_path)
data_error = exceptions.DataError(
_("Can't extract a frame from the video {video_path}").format(
video_path=video_path
)
) )
seek_times = [f"{duration * 0.25:.2f}", "0"] if duration else ["0"]
last_error = None
for seek_time in seek_times:
try:
await extract(seek_time)
break
except exceptions.CommandException as e:
last_error = e
if not dest_path.exists() or dest_path.stat().st_size == 0:
raise data_error from last_error
return dest_path