diff --git a/libervia/backend/plugins/plugin_comp_file_sharing.py b/libervia/backend/plugins/plugin_comp_file_sharing.py index 7f9aa353..51379f47 100644 --- a/libervia/backend/plugins/plugin_comp_file_sharing.py +++ b/libervia/backend/plugins/plugin_comp_file_sharing.py @@ -512,8 +512,7 @@ class FileSharing: thumb_size, thumb_id = await self._t.generate_thumbnail( image_path, max_thumb_size, - #  we keep thumbnails for 6 months - 60 * 60 * 24 * 31 * 6, + C.MAX_AGE_PERSISTENT, ) except Exception as e: log.warning(_("Can't create thumbnail: {reason}").format(reason=e)) diff --git a/libervia/backend/plugins/plugin_comp_file_sharing_management.py b/libervia/backend/plugins/plugin_comp_file_sharing_management.py index e6d79779..6ec2f3b5 100644 --- a/libervia/backend/plugins/plugin_comp_file_sharing_management.py +++ b/libervia/backend/plugins/plugin_comp_file_sharing_management.py @@ -17,7 +17,9 @@ # along with this program. If not, see . import os.path +import tempfile from functools import partial +from pathlib import Path from typing import cast from twisted.internet import defer from twisted.words.protocols.jabber import jid @@ -38,6 +40,7 @@ from libervia.backend.plugins.plugin_xep_0050 import ( Error, ) from libervia.backend.plugins.plugin_xep_0264 import XEP_0264 +from libervia.backend.tools import video from libervia.backend.tools.common import utils log = getLogger(__name__) @@ -414,6 +417,26 @@ class FileSharingManagement: def _update_thumbs(self, extra, 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): """Recursively generate thumbnails @@ -430,34 +453,32 @@ class FileSharingManagement: media_type = file_data["media_type"] file_path = os.path.join(self.files_path, file_data["file_hash"]) if media_type == "image": - thumbnails = [] - - for max_thumb_size in self._t.SIZES: + thumbnails = await self._gen_thumbs_list(file_path) + elif media_type == "video": + with tempfile.TemporaryDirectory() as tmp_dir: + poster_path = Path(tmp_dir) / "poster.jpg" try: - thumb_size, thumb_id = await self._t.generate_thumbnail( - file_path, - max_thumb_size, - #  we keep thumbnails for 6 months - 60 * 60 * 24 * 31 * 6, - ) + await video.get_thumbnail(file_path, poster_path) except Exception as e: 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 - thumbnails.append({"id": thumb_id, "size": thumb_size}) - - await self.host.memory.file_update( - file_data["id"], - "extra", - partial(self._update_thumbs, thumbnails=thumbnails), - ) - - log.info( - "thumbnails for [{file_name}] generated".format( - file_name=file_data["name"] - ) + return + thumbnails = await self._gen_thumbs_list(poster_path) + else: + return + await self.host.memory.file_update( + file_data["id"], + "extra", + partial(self._update_thumbs, thumbnails=thumbnails), + ) + log.info( + "thumbnails for [{file_name}] generated".format( + file_name=file_data["name"] ) + ) else: log.warning("unmanaged file type: {type_}".format(type_=file_data["type"])) diff --git a/libervia/backend/tools/video.py b/libervia/backend/tools/video.py index ee585bbe..42d0f43b 100644 --- a/libervia/backend/tools/video.py +++ b/libervia/backend/tools/video.py @@ -28,17 +28,54 @@ from .common import async_process log = getLogger(__name__) - try: ffmpeg_path = which("ffmpeg")[0] except IndexError: log.warning(_("ffmpeg executable not found, video thumbnails won't be available")) 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: """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 dest_path: path where the file must be saved @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. Image will be in JPEG format. @raise exceptions.NotFound: ffmpeg is missing + @raise exceptions.DataError: can't extract a frame from the video """ if ffmpeg_path is None: raise exceptions.NotFound( _("ffmpeg executable is not available, can't generate video thumbnail") ) + ffmpeg = ffmpeg_path - await async_process.run( - ffmpeg_path, "-i", str(video_path), "-ss", "10", "-frames:v", "1", str(dest_path) + async def extract(seek_time: str) -> None: + 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