Skip to content
Merged
4 changes: 2 additions & 2 deletions src/tagstudio/core/library/alchemy/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def show_all(cls) -> BrowsingState:

@classmethod
def from_search_query(cls, search_query: str) -> BrowsingState:
return cls(query=search_query)
return cls(query=search_query if search_query.strip() else None)

@classmethod
def from_tag_id(cls, tag_id: int | str, state: BrowsingState | None = None) -> BrowsingState:
Expand Down Expand Up @@ -152,7 +152,7 @@ def with_sorting_direction(self, ascending: bool) -> BrowsingState:
return self._with_new_results(ascending=ascending)

def with_search_query(self, search_query: str) -> BrowsingState:
return self._with_new_results(query=search_query)
return self._with_new_results(query=search_query if search_query.strip() else None)

def with_show_hidden_entries(self, show_hidden_entries: bool) -> BrowsingState:
return self._with_new_results(show_hidden_entries=show_hidden_entries)
Expand Down
4 changes: 2 additions & 2 deletions src/tagstudio/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -993,14 +993,14 @@ def search_library(

start_time = time.time()
if page_size:
rows = session.execute(statement).fetchall()
rows = session.connection().execute(statement).fetchall()
ids = []
total_count = 0
for row in rows:
ids.append(row[0])
total_count = row[1]
else:
ids = list(session.scalars(statement))
ids = list(session.connection().execute(statement).scalars())
total_count = len(ids)
end_time = time.time()
logger.info(f"SQL Execution finished ({format_timespan(end_time - start_time)})")
Expand Down
4 changes: 3 additions & 1 deletion src/tagstudio/previews/base_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ class BasePreview:
"""A base preview renderer class.

Attributes:
has_small_variant (bool): Whether `is_small` renders a different image than the preview.
has_small_variant (bool): Whether this renderer has a small variant (for thumbnails).
has_theme_variants (bool): Whether this renderer has variants for the light/dark theme.
media_type_name (str): Used for identifying the MediaType.
priority (int): Render priority over other Preview classes.
"""

has_small_variant: bool = False
has_theme_variants: bool = False
media_type_name: str
priority: int = 50

Expand Down
34 changes: 20 additions & 14 deletions src/tagstudio/previews/file_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
# SPDX-License-Identifier: MIT


import contextlib
import hashlib
import importlib
import inspect
Expand All @@ -20,7 +19,6 @@
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.query_lang.file_groups import SEARCH
from tagstudio.core.utils.decompression import decompressed, get_inner_ext, is_compressed
from tagstudio.core.utils.types import unwrap
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.qt.app_settings import (
DEFAULT_CACHED_THUMB_RES,
Expand Down Expand Up @@ -125,19 +123,23 @@ def find_renderer(filepath: Path) -> type[BasePreview] | None:
return renderer

@staticmethod
def previously_failed(filepath: Path, date_modified: float | None = None) -> bool:
def previously_failed(filepath: Path, theme: Theme, date_modified: float | None = None) -> bool:
"""Return whether rendering a file failed before."""
file_name = FileRenderer._cached_thumb_name(filepath, date_modified)
file_name = FileRenderer._cached_thumb_name(filepath, theme, date_modified)
return file_name in FileRenderer._failed_render_filenames

@staticmethod
def cached_thumbnail(
cache: CacheManager | None, filepath: Path, date_modified: float | None = None
cache: CacheManager | None,
filepath: Path,
theme: Theme,
date_modified: float | None = None,
) -> Image.Image | None:
"""Return a file's cached thumbnail, if it has one."""
if not cache:
return None
cached_path = cache.get_file_path(FileRenderer._cached_thumb_name(filepath, date_modified))
file_name = FileRenderer._cached_thumb_name(filepath, theme, date_modified)
cached_path = cache.get_file_path(file_name)
if not (cached_path and cached_path.is_file()):
return None
try:
Expand All @@ -149,15 +151,20 @@ def cached_thumbnail(
return None

@staticmethod
def _cached_thumb_name(filepath: Path, date_modified: float | None = None) -> Path:
def _cached_thumb_name(
filepath: Path, theme: Theme, date_modified: float | None = None
) -> Path:
"""Return the cache filename for a file's thumbnail.

Args:
filepath (Path): The path of the file to check for a cached image of.
theme (Theme): The light/dark theme, used if the file's renderer has theme variants.
date_modified (float | None): The file's stored date modified timestamp, if it has one.
"""
mod_time: str = "" if date_modified is None else str(date_modified)
hashable_str: str = f"{str(filepath)}{mod_time}"
renderer = FileRenderer.find_renderer(filepath)
theme_name = theme.name if renderer is not None and renderer.has_theme_variants else ""
hashable_str: str = f"{str(filepath)}{mod_time}{theme_name}"
hash_value = hashlib.shake_128(hashable_str.encode("utf-8")).hexdigest(8)
return Path(f"{hash_value}{FileRenderer._cached_img_ext}")

Expand Down Expand Up @@ -187,10 +194,9 @@ def render(

is_unlinked = not filepath.exists()
is_ignored = False
with contextlib.suppress(TypeError):
is_ignored = Ignore.matcher.is_ignored(
filepath.relative_to(unwrap(self.lib.library_dir))
)
library_dir = self.lib.library_dir
if library_dir is not None and filepath.is_relative_to(library_dir):
is_ignored = Ignore.matcher.is_ignored(filepath.relative_to(library_dir))

if is_thumb:
image = self._get_thumbnail(cache, filepath, size, dpi_scale, theme, date_modified)
Expand Down Expand Up @@ -219,7 +225,7 @@ def _get_thumbnail(
theme (Theme): A theme enum to determine the light/dark theme.
date_modified (float | None): The date modified timestamp of the file. Used for caches.
"""
image = FileRenderer.cached_thumbnail(cache, filepath, date_modified)
image = FileRenderer.cached_thumbnail(cache, filepath, theme, date_modified)
if not image and self.settings.generate_thumbs:
res = self.settings.cached_thumb_resolution
if not MIN_CACHED_THUMB_RES <= res <= MAX_CACHED_THUMB_RES:
Expand Down Expand Up @@ -254,7 +260,7 @@ def _render(
is_small (bool): Is this specifically a thumbnail? Use for specifying small variants.
cache (CacheManager | None): A cache manager to save the rendered image to, if any.
"""
file_name = FileRenderer._cached_thumb_name(filepath, date_modified)
file_name = FileRenderer._cached_thumb_name(filepath, theme, date_modified)
scaled_size = math.ceil(max(size) * dpi_scale)
image: Image.Image | None = None

Expand Down
2 changes: 1 addition & 1 deletion src/tagstudio/previews/media_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def probe_file(filepath: Path) -> dict[str, Any] | None:
@lru_cache(maxsize=256)
def _cached_probe(filepath: Path, _mod_time: float) -> dict[str, Any] | None:
try:
return probe(filepath)
return probe(filepath, timeout=10)
except ffmpeg.Error:
return None

Expand Down
17 changes: 9 additions & 8 deletions src/tagstudio/previews/renderers/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def render(
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return archive_thumb(filepath)
return archive_thumb(filepath, size=size)


class SevenZipFile(py7zr.SevenZipFile):
Expand Down Expand Up @@ -123,11 +123,12 @@ def open_archive(filepath: Path) -> Archive:
return archiver(filepath, "r")


def first_image_in_archive(archive: Archive) -> Image | None:
def first_image_in_archive(archive: Archive, size: tuple[int, int] | None = None) -> Image | None:
"""Find and extract the first renderable image in the archive.

Args:
archive (Archive): The current archive.
size (tuple[int, int] | None): The size of the thumbnail, if known.

Returns:
Image: The first renderable image in the archive.
Expand All @@ -137,21 +138,22 @@ def first_image_in_archive(archive: Archive) -> Image | None:
ext = archive_path.suffix.lower() if archive_path.suffix else archive_path.stem
if MediaTypes.image_raster.contains(ext, RENDER):
image_data = archive.read(file_name) # pyright: ignore[reportUnknownVariableType]
return image_from_bytes(BytesIO(image_data))
return image_from_bytes(BytesIO(image_data), min_size=size)

return None


def archive_thumb(
filepath: Path,
image_names: list[Path] | list[str] | None = None,
size: tuple[int, int] | None = None,
) -> Image | None:
"""Extract an embedded preview image from an archive.

Args:
filepath (Path): The path to the archive.
image_names: (list[Path] | list[str] | None): List of embedded image names to search for.
ext (str): The file extension. Used to help determine more specific archive type.
size (tuple[int, int] | None): The size of the thumbnail, if known.

Returns:
Image: The first image found in the archive.
Expand All @@ -160,16 +162,15 @@ def archive_thumb(
with open_archive(filepath) as archive:
# If no list of image names to search for was provided, default to the first image.
if not image_names:
return first_image_in_archive(archive)
return first_image_in_archive(archive, size)

for image_name in image_names:
if image_name in archive.namelist():
file_data = archive.read(str(image_name)) # pyright: ignore[reportUnknownVariableType]
return image_from_bytes(BytesIO(file_data))
return image_from_bytes(BytesIO(file_data), min_size=size)

# If no images were found with the given names, fallback to the first image found.
if not image_names:
return first_image_in_archive(archive)
return first_image_in_archive(archive, size)

except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/previews/renderers/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

class AudioPreview(BasePreview):
media_type_name = "audio"
has_theme_variants = True
priority = 70

@override
Expand Down
19 changes: 11 additions & 8 deletions src/tagstudio/previews/renderers/ebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,15 @@ def render(
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return epub_thumb(filepath)
return epub_thumb(filepath, size)


def epub_thumb(filepath: Path) -> Image | None:
def epub_thumb(filepath: Path, size: tuple[int, int] | None = None) -> Image | None:
"""Extracts the cover specified by ComicInfo.xml or first image found in the ePub file.

Args:
filepath (Path): The path to the ePub file.
ext (str): The file extension.
size (tuple[int, int] | None): The size of the thumbnail, if known.

Returns:
Image: The cover specified in ComicInfo.xml,
Expand All @@ -73,25 +73,28 @@ def epub_thumb(filepath: Path) -> Image | None:
with open_archive(filepath) as archive:
if "ComicInfo.xml" in archive.namelist():
comic_info = ET.fromstring(archive.read("ComicInfo.xml"))
im = cover_from_comic_info(archive, comic_info, "FrontCover")
im = cover_from_comic_info(archive, comic_info, "FrontCover", size)
if not im:
im = cover_from_comic_info(archive, comic_info, "InnerCover")
im = cover_from_comic_info(archive, comic_info, "InnerCover", size)

if not im:
im = first_image_in_archive(archive)
im = first_image_in_archive(archive, size)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)

return im


def cover_from_comic_info(archive: Archive, comic_info: Element, cover_type: str) -> Image | None:
def cover_from_comic_info(
archive: Archive, comic_info: Element, cover_type: str, size: tuple[int, int] | None = None
) -> Image | None:
"""Extract the cover specified in ComicInfo.xml.

Args:
archive (Archive): The current ePub file.
comic_info (Element): The parsed ComicInfo.xml.
cover_type (str): The type of cover to load.
size (tuple[int, int] | None): The size of the thumbnail, if known.

Returns:
Image: The cover specified in ComicInfo.xml.
Expand All @@ -105,6 +108,6 @@ def cover_from_comic_info(archive: Archive, comic_info: Element, cover_type: str
ext = Path(page_name).suffix.lower()
if MediaTypes.image_raster.contains(ext, RENDER):
image_data = archive.read(page_name) # pyright: ignore[reportUnknownVariableType]
im = image_from_bytes(BytesIO(image_data))
im = image_from_bytes(BytesIO(image_data), min_size=size)

return im
1 change: 1 addition & 0 deletions src/tagstudio/previews/renderers/font.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
class FontPreview(BasePreview):
media_type_name = "font"
has_small_variant = True
has_theme_variants = True

@override
@classmethod
Expand Down
13 changes: 9 additions & 4 deletions src/tagstudio/previews/renderers/raster_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,22 +65,23 @@ def render(
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return raster_image_thumb(filepath)
return raster_image_thumb(filepath, size)


def raster_image_thumb(filepath: Path) -> Image | None:
def raster_image_thumb(filepath: Path, size: tuple[int, int] | None = None) -> Image | None:
"""Render a thumbnail for a standard image type.

Args:
filepath (Path): The path of the file.
size (tuple[int, int] | None): The size of the thumbnail, if known.
"""
im: Image | None = None
try:
if filepath.suffix.lower() == ".exr":
return exr_image_thumb(filepath)

with filepath.open("rb") as file:
im = image_from_bytes(BytesIO(file.read()))
im = image_from_bytes(BytesIO(file.read()), min_size=size)
except (
DecompressionBombError,
FileNotFoundError,
Expand Down Expand Up @@ -123,16 +124,20 @@ def exr_image_thumb(filepath: Path) -> Image | None:
return im


def image_from_bytes(image_data: BytesIO) -> Image:
def image_from_bytes(image_data: BytesIO, min_size: tuple[int, int] | None = None) -> Image:
"""Load a raster image and add a background if it's transparent.

Args:
image_data (BytesIO): The binary image data.
min_size (tuple[int, int] | None): The smallest size to decode the image at, if known.
Only JPEGs can be decoded at a reduced size.

Returns:
Image.Image: The loaded raster image, with a background if needed.
"""
im: Image = open_image(image_data)
if min_size is not None:
im.draft(None, min_size)
if im.mode != "RGB" and im.mode != "RGBA":
im = im.convert(mode="RGBA")
if im.mode == "RGBA":
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/previews/renderers/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@

class TextPreview(BasePreview):
media_type_name = "plaintext"
has_theme_variants = True
font = ImageFont.load_default(20)

@override
Expand Down
14 changes: 8 additions & 6 deletions src/tagstudio/previews/vendored/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ def probe(filename: Path | str, timeout: int | None = None, **kwargs: ...):
"""Run ffprobe on the specified file and return a JSON representation of the output.

Raises:
Error: If ffprobe returns a non-zero exit code, an Error is raised
with a generic error message. The stderr output can be retrieved
Error: If ffprobe returns a non-zero exit code or runs longer than `timeout` seconds,
an Error is raised with a generic error message. The stderr output can be retrieved
by accessing the `stderr` property of the exception.
"""
ffprobe_cmd: str | None = FfprobeStatus.which()
Expand All @@ -36,10 +36,12 @@ def probe(filename: Path | str, timeout: int | None = None, **kwargs: ...):

# PATCHED
p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=False)
communicate_kwargs = {}
if timeout is not None:
communicate_kwargs["timeout"] = timeout
out, err = p.communicate(**communicate_kwargs)
try:
out, err = p.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
logger.warning("[Probe] ffprobe timed out", filename=filename, timeout=timeout)
p.kill()
out, err = p.communicate()
if p.returncode != 0:
raise ffmpeg.Error("ffprobe", out, err)
return json.loads(out.decode("utf-8"))
Loading
Loading