From 97af0de6c24ba4bfcf4678adf2b41230169ff3e1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Hamal=20Dvo=C5=99=C3=A1k?= <mordae@anilinux.org>
Date: Tue, 11 Mar 2025 11:16:36 +0100
Subject: [PATCH] Use deeper queue for thumbnails

---
 lazy_player/file_model.py  |   9 ---
 lazy_player/main_window.py |   2 +-
 lazy_player/thumbnailer.py | 146 +++++++++++++++++++------------------
 3 files changed, 77 insertions(+), 80 deletions(-)

diff --git a/lazy_player/file_model.py b/lazy_player/file_model.py
index 7daeb16..3054a2c 100644
--- a/lazy_player/file_model.py
+++ b/lazy_player/file_model.py
@@ -8,8 +8,6 @@ from typing import Optional, overload
 
 from gi.repository import Gio, GObject
 
-from .thumbnailer import Thumbnailer
-
 
 class FileType(Enum):
     DIRECTORY = auto()
@@ -80,13 +78,6 @@ class FileItem(GObject.Object):
         self._has_thumbnail = value
         self.notify("has-thumbnail")
 
-    def ensure_thumbnail(self, thumbnailer: Thumbnailer):
-        if self.thumbnail or self.attempted_thumbnail:
-            return
-
-        if not self.attempted_thumbnail:
-            thumbnailer.generate_thumbnail(self)
-
     @overload
     def _load_attribute(self, name: str, dfl: str) -> str: ...
 
diff --git a/lazy_player/main_window.py b/lazy_player/main_window.py
index c00161f..fb5ecf7 100644
--- a/lazy_player/main_window.py
+++ b/lazy_player/main_window.py
@@ -471,7 +471,7 @@ class MainWindow(Gtk.ApplicationWindow):
 
         # Update thumbnail if available
         if file_item := self.selection:
-            file_item.ensure_thumbnail(self.thumbnailer)
+            self.thumbnailer.generate_thumbnail(file_item)
 
             if file_item.thumbnail and not self.thumbnail_image.get_paintable():
                 gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail))
diff --git a/lazy_player/thumbnailer.py b/lazy_player/thumbnailer.py
index fc222dc..866a549 100644
--- a/lazy_player/thumbnailer.py
+++ b/lazy_player/thumbnailer.py
@@ -1,7 +1,8 @@
 from __future__ import annotations
 
+import sys
 import threading
-from queue import Empty, Queue
+from queue import Empty, Full, LifoQueue
 from typing import TYPE_CHECKING
 
 from gi.repository import GLib, Gst
@@ -18,13 +19,15 @@ DEFAULT_SEEK_FLAGS = (
     | Gst.SeekFlags.TRICKMODE_NO_AUDIO
 )
 
+__all__ = ["Thumbnailer", "generate_thumbnail_sync"]
+
 
 class Thumbnailer(threading.Thread):
-    queue: Queue[FileItem | None]
+    queue: LifoQueue[FileItem | None]
 
     def __init__(self):
         super().__init__(daemon=True)
-        self.queue = Queue(maxsize=1)
+        self.queue = LifoQueue(maxsize=20)
 
     def generate_thumbnail(self, file_item: FileItem):
         """Add a file item to the thumbnail queue"""
@@ -32,20 +35,23 @@ class Thumbnailer(threading.Thread):
         if not file_item.full_path.is_file():
             return
 
-        # Replace any pending item in the queue
+        if file_item.attempted_thumbnail:
+            return
+
         try:
-            self.queue.get_nowait()
-        except Empty:
+            self.queue.put_nowait(file_item)
+        except Full:
             pass
 
-        self.queue.put_nowait(file_item)
+        file_item.attempted_thumbnail = True
 
     def stop(self):
         """Stop the thumbnailer thread"""
 
-        # Replace any pending items in the queue
         try:
-            self.queue.get_nowait()
+            # Drop all pending items
+            while True:
+                self.queue.get_nowait()
         except Empty:
             pass
 
@@ -61,73 +67,73 @@ class Thumbnailer(threading.Thread):
             if file_item is None:
                 break
 
-            file_item.attempted_thumbnail = True
-            self._generate_thumbnail(file_item)
+            generate_thumbnail_sync(file_item)
 
-    def _generate_thumbnail(self, file_item: FileItem):
-        """Generate thumbnail for a single file"""
 
-        pipeline_str = (
-            "uridecodebin name=uridecodebin ! "
-            "videoconvert ! "
-            "jpegenc quality=85 ! "
-            "appsink sync=false name=sink"
-        )
+def generate_thumbnail_sync(file_item: FileItem):
+    """Generate thumbnail for a single file"""
 
-        pipeline = Gst.parse_launch(pipeline_str)
-        assert isinstance(pipeline, Gst.Pipeline)
+    pipeline_str = (
+        "uridecodebin name=uridecodebin ! "
+        "videoconvert ! "
+        "jpegenc quality=85 ! "
+        "appsink sync=false name=sink"
+    )
 
-        sink = pipeline.get_by_name("sink")
-        assert isinstance(sink, Gst.Element)
+    pipeline = Gst.parse_launch(pipeline_str)
+    assert isinstance(pipeline, Gst.Pipeline)
 
-        uridecodebin = pipeline.get_by_name("uridecodebin")
-        assert isinstance(uridecodebin, Gst.Element)
+    sink = pipeline.get_by_name("sink")
+    assert isinstance(sink, Gst.Element)
 
-        # Set file URI
-        uridecodebin.set_property("uri", Gst.filename_to_uri(str(file_item.full_path)))
+    uridecodebin = pipeline.get_by_name("uridecodebin")
+    assert isinstance(uridecodebin, Gst.Element)
+
+    # Set file URI
+    uridecodebin.set_property("uri", Gst.filename_to_uri(str(file_item.full_path)))
+
+    try:
+        # Set pipeline to PAUSED to get duration
+        pipeline.set_state(Gst.State.PAUSED)
+        pipeline.get_state(Gst.SECOND)
+
+        # Seek to 1/3 of duration
+        success, duration = pipeline.query_duration(Gst.Format.TIME)
+        if not success:
+            return
+
+        seek_pos = duration // 3
+        pipeline.seek_simple(Gst.Format.TIME, DEFAULT_SEEK_FLAGS, seek_pos)
+
+        # Start playing to capture frame
+        pipeline.set_state(Gst.State.PLAYING)
+
+        sample = sink.emit("pull-sample")
+        if not sample:
+            return
+
+        # Extract image data
+        buffer = sample.get_buffer()
+        if not buffer:
+            return
+
+        success, map_info = buffer.map(Gst.MapFlags.READ)
+        if not success:
+            return
 
         try:
-            # Set pipeline to PAUSED to get duration
-            pipeline.set_state(Gst.State.PAUSED)
-            pipeline.get_state(Gst.SECOND)
-
-            # Seek to 1/3 of duration
-            success, duration = pipeline.query_duration(Gst.Format.TIME)
-            if not success:
-                return
-
-            seek_pos = duration // 3
-            pipeline.seek_simple(Gst.Format.TIME, DEFAULT_SEEK_FLAGS, seek_pos)
-
-            # Start playing to capture frame
-            pipeline.set_state(Gst.State.PLAYING)
-
-            sample = sink.emit("pull-sample")
-            if not sample:
-                return
-
-            # Extract image data
-            buffer = sample.get_buffer()
-            if not buffer:
-                return
-
-            success, map_info = buffer.map(Gst.MapFlags.READ)
-            if not success:
-                return
-
-            try:
-                thumbnail = bytes(map_info.data)
-            finally:
-                buffer.unmap(map_info)
-
-            def set_thumbnail():
-                file_item.thumbnail = thumbnail
-                file_item.has_thumbnail = True
-
-            GLib.idle_add(set_thumbnail)
-
-        except Exception as err:
-            print("Failed:", file_item.full_path, err)
-
+            thumbnail = bytes(map_info.data)
         finally:
-            pipeline.set_state(Gst.State.NULL)
+            buffer.unmap(map_info)
+
+        def set_thumbnail():
+            file_item.thumbnail = thumbnail
+            file_item.has_thumbnail = True
+
+        GLib.idle_add(set_thumbnail)
+
+    except Exception as err:
+        print("[thumbnailer] Error:", file_item.full_path, err, file=sys.stderr)
+
+    finally:
+        pipeline.set_state(Gst.State.NULL)