Skip to content

Commit b73fede

Browse files
CyanVoxelSola-ris
andauthored
fix: proper compound ext support + compression ext handling (#1536)
* fix: properly handle compound file extentions * fix(ui): register other supported tar types to render previews Co-authored-by: Sola-ris <190788035+Sola-ris@users.noreply.github.com> * chore: correct stale docstring --------- Co-authored-by: Sola-ris <190788035+Sola-ris@users.noreply.github.com>
1 parent 10e0b20 commit b73fede

14 files changed

Lines changed: 559 additions & 78 deletions

File tree

‎docs/preview-support.md‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,17 @@ Preview support for office documents or well-known project file formats varies b
105105

106106
Archive thumbnails will display the first image from the archive within the Preview Panel.
107107

108-
| Filetype | Extensions |
109-
| -------- | -------------- |
110-
| 7-Zip | `.7z`, `.s7z` |
111-
| RAR | `.rar` |
112-
| Tar | `.tar`, `.tgz` |
113-
| Zip | `.zip` |
108+
| Filetype | Extensions |
109+
| --------------- | ------------------------------------------------------ |
110+
| 7-Zip | `.7z`, `.s7z` |
111+
| RAR | `.rar` |
112+
| Tar | `.tar` |
113+
| Tar (bzip2) | `.tar.bz2`, `.tar.bz`, `.tbz2`, `.tbz`, `.tb2`, `.tz2` |
114+
| Tar (gzip) | `.tar.gz`, `.tgz`, `.taz` |
115+
| Tar (LZMA) | `.tar.lzma`, `.tlz` |
116+
| Tar (XZ) | `.tar.xz`, `.txz` |
117+
| Tar (Zstandard) | `.tar.zst`, `.tzst` |
118+
| Zip | `.zip` |
114119

115120
### :material-book: eBooks
116121

‎src/tagstudio/core/library/alchemy/visitors.py‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import TYPE_CHECKING, override
77

88
import structlog
9-
from sqlalchemy import ColumnElement, and_, distinct, false, func, or_, select
9+
from sqlalchemy import ColumnElement, and_, distinct, false, func, literal, or_, select
1010
from sqlalchemy.orm import Session
1111
from sqlalchemy.sql.operators import ilike_op
1212

@@ -100,17 +100,16 @@ def visit_constraint(self, node: Constraint) -> ColumnElement[bool]:
100100
extensions: set[str] = (
101101
media_type.context_sets.get(SEARCH, set()) if media_type else set()
102102
)
103-
return Entry.suffix.in_(map(lambda x: x.replace(".", ""), extensions))
103+
return self._entry_has_any_ext(extensions)
104104

105105
elif node.type == ConstraintType.FileType:
106-
# NOTE: Entries store their suffix without a leading dot, the MediaTypes system includes
107-
# the leading dot (if any), and this search system should take in either variant.
108-
return or_(
109-
*[
110-
Entry.suffix.ilike(ft.removeprefix("."))
111-
for ft in MediaTypes.get_equivalent_exts(f".{node.value.removeprefix('.')}")
112-
]
113-
)
106+
# NOTE: The MediaTypes system includes the leading dot (if any), and this search system
107+
# should take in either variant.
108+
ext = f".{node.value.removeprefix('.').lower()}"
109+
extensions = MediaTypes.get_equivalent_exts(ext).copy()
110+
for compound_ext in MediaTypes.get_compound_exts(ext):
111+
extensions |= MediaTypes.get_equivalent_exts(compound_ext)
112+
return self._entry_has_any_ext(extensions)
114113
elif node.type == ConstraintType.Special: # noqa: SIM102 unnecessary once there is a second special constraint
115114
if node.value.lower() == "untagged":
116115
return ~Entry.id.in_(select(Entry.id).join(TagEntry))
@@ -126,6 +125,30 @@ def visit_property(self, node: Property) -> ColumnElement[bool]:
126125
def visit_not(self, node: Not) -> ColumnElement[bool]:
127126
return ~self.visit(node.child)
128127

128+
def _entry_has_any_ext(self, extensions: set[str]) -> ColumnElement[bool]:
129+
"""Returns a Boolean Expression that is true if the Entry has any of the extensions."""
130+
# NOTE: Entries only store their last suffix (lowercase and without a leading dot), so
131+
# compound extensions also match the end of the path.
132+
suffixes: set[str] = set()
133+
last_suffixes_by_prefix: dict[str, set[str]] = {}
134+
for ext in extensions:
135+
prefix, _, last_suffix = ext.rpartition(".")
136+
if prefix:
137+
last_suffixes_by_prefix.setdefault(prefix, set()).add(last_suffix)
138+
else:
139+
suffixes.add(last_suffix)
140+
141+
return or_(
142+
Entry.suffix.in_(suffixes),
143+
*[
144+
and_(
145+
Entry.suffix.in_(last_suffixes),
146+
Entry.path.like(literal(f"%{prefix}.") + Entry.suffix),
147+
)
148+
for prefix, last_suffixes in last_suffixes_by_prefix.items()
149+
],
150+
)
151+
129152
def __get_tag_ids(self, tag_name: str, include_children: bool = True) -> list[int]:
130153
"""Given a tag name find the ids of all tags that this name could refer to."""
131154
with Session(self.lib.engine) as session:

‎src/tagstudio/core/media_types.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44

55
import re
6+
from pathlib import PurePath
67
from typing import Any
78

89
import structlog
@@ -105,6 +106,7 @@ class MediaTypes(metaclass=SanitizedAttr):
105106
"""A singleton class that manages registered media types and their relationships."""
106107

107108
_chained_groups: dict[str, set[str]] = {}
109+
_compound_exts_by_last_suffix: dict[str, list[str]] = {}
108110
_name_to_key_map: dict[str, str] = {}
109111
all_groups: list[MediaTypeGroup] = []
110112
equivalent_exts: dict[str, set[str]] = {}
@@ -275,6 +277,16 @@ def register(cls, group_key: str, ext: str | list[str], contexts: str | list[str
275277
for e in ext:
276278
cls.equivalent_exts.setdefault(e, set(ext))
277279

280+
# Index compound extensions (e.g. ".tar.gz") by their last suffix (.gz -> .tar.gz)
281+
for e in ext:
282+
if e.count(".") > 1:
283+
compound_exts = cls._compound_exts_by_last_suffix.setdefault(
284+
f".{e.rsplit('.', 1)[1]}", []
285+
)
286+
if e not in compound_exts:
287+
compound_exts.append(e)
288+
compound_exts.sort(key=len, reverse=True)
289+
278290
# Create any chained groups from dot notations (e.g. "adobe.photoshop")
279291
name_parts = group_key.split(".")
280292
for i in range(1, len(name_parts)):
@@ -301,5 +313,33 @@ def get_equivalent_exts(cls, ext: str) -> set[str]:
301313
"""
302314
return cls.equivalent_exts.get(ext, {ext})
303315

316+
@classmethod
317+
def get_ext(cls, path: PurePath) -> str:
318+
"""Return the longest registered extension a path ends with, or else its last suffix.
319+
320+
For example, `my.archive.tar.gz` returns `.tar.gz` and `v1.2.zip` returns `.zip`.
321+
"""
322+
last_suffix = path.suffix.lower()
323+
compound_exts = cls._compound_exts_by_last_suffix.get(last_suffix)
324+
if compound_exts:
325+
name = path.name.lower()
326+
for ext in compound_exts:
327+
if name.endswith(ext):
328+
return ext
329+
return last_suffix
330+
331+
@classmethod
332+
def get_compound_exts(cls, ext: str) -> set[str]:
333+
"""Return the registered compound extensions that start with an extension.
334+
335+
For example, `.tar` returns `.tar.gz`, `.tar.xz`, etc. (but not `.tar` itself).
336+
"""
337+
return {
338+
compound_ext
339+
for compound_exts in cls._compound_exts_by_last_suffix.values()
340+
for compound_ext in compound_exts
341+
if compound_ext.startswith(f"{ext}.")
342+
}
343+
304344

305345
_FORBIDDEN_NAMES = set(dir(MediaTypes))

‎src/tagstudio/core/query_lang/file_groups.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ def register_types() -> None:
346346
MediaTypes.register("model", ".stl", SEARCH)
347347

348348
# Archives -----------------------------------------------------------------
349-
MediaTypes.add_name_aliases("archive", ["Archive", "Compressed"])
349+
MediaTypes.add_name_aliases("archive", "Archive")
350350
MediaTypes.register("archive", ".cba", SEARCH) # Also under "ebook.comic"
351351

352352
# RAR
@@ -374,7 +374,9 @@ def register_types() -> None:
374374
],
375375
)
376376
MediaTypes.register("archive.tar", ".tar", SEARCH)
377-
MediaTypes.register("archive.tar", [".tar.bz", ".tb2", ".tbz", ".tbz2", ".tz2"], SEARCH)
377+
MediaTypes.register(
378+
"archive.tar", [".tar.bz", ".tar.bz2", ".tb2", ".tbz", ".tbz2", ".tz2"], SEARCH
379+
)
378380
MediaTypes.register("archive.tar", [".tar.gz", ".taz", ".tgz"], SEARCH)
379381
MediaTypes.register("archive.tar", [".tar.lzma", ".tlz"], SEARCH)
380382
MediaTypes.register("archive.tar", [".tar.xz", ".txz"], SEARCH)
@@ -434,6 +436,16 @@ def register_types() -> None:
434436
MediaTypes.register("binary", ".pyd", SEARCH)
435437
MediaTypes.register("binary", ".pyo", SEARCH)
436438

439+
# Compressed ---------------------------------------------------------------
440+
# NOTE: These are single-file compression formats that wrap other files (e.g. ".tar.gz").
441+
MediaTypes.add_name_aliases("compressed", "Compressed")
442+
MediaTypes.register("compressed", ".bz", SEARCH)
443+
MediaTypes.register("compressed", ".bz2", SEARCH)
444+
MediaTypes.register("compressed", ".gz", SEARCH)
445+
MediaTypes.register("compressed", ".lzma", SEARCH)
446+
MediaTypes.register("compressed", ".xz", SEARCH)
447+
MediaTypes.register("compressed", ".zst", SEARCH)
448+
437449
# Databases ----------------------------------------------------------------
438450
MediaTypes.add_name_aliases("database", ["Database", "DB"])
439451
MediaTypes.register("database", ".db", SEARCH)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# SPDX-FileCopyrightText: (c) TagStudio Contributors
2+
# SPDX-License-Identifier: MIT
3+
4+
5+
from collections.abc import Callable, Generator
6+
from compression import bz2, gzip, lzma, zstd
7+
from contextlib import contextmanager
8+
from io import BufferedIOBase
9+
from pathlib import Path
10+
from tempfile import TemporaryDirectory
11+
12+
import structlog
13+
14+
from tagstudio.core.media_types import MediaTypes
15+
from tagstudio.core.query_lang.file_groups import SEARCH
16+
17+
logger = structlog.get_logger(__name__)
18+
19+
MAX_DECOMPRESSED_SIZE: int = 128 * 1024 * 1024 # 128 MiB
20+
_CHUNK_SIZE: int = 1024 * 1024
21+
22+
_OPENERS: dict[str, Callable[[Path], BufferedIOBase]] = {
23+
".bz": lambda path: bz2.open(path, "rb"),
24+
".bz2": lambda path: bz2.open(path, "rb"),
25+
".gz": lambda path: gzip.open(path, "rb"),
26+
".lzma": lambda path: lzma.open(path, "rb"),
27+
".xz": lambda path: lzma.open(path, "rb"),
28+
".zst": lambda path: zstd.open(path, "rb"),
29+
}
30+
31+
32+
def is_compressed(path: Path) -> bool:
33+
"""Return `True` if the file is a compressed file."""
34+
return MediaTypes.contains("compressed", path.suffix.lower(), SEARCH)
35+
36+
37+
def get_inner_ext(path: Path) -> str | None:
38+
"""Return the extension of the file inside a compressed file, if it's a known file type."""
39+
if not is_compressed(path) or MediaTypes.get_ext(path) != path.suffix.lower():
40+
return None
41+
inner_ext = MediaTypes.get_ext(Path(path.stem))
42+
return inner_ext if MediaTypes.find(inner_ext, SEARCH) else None
43+
44+
45+
def get_display_ext(path: Path) -> str:
46+
"""Return a file's extension, including the inner file type if compressed (e.g. ".png.gz")."""
47+
inner_ext = get_inner_ext(path)
48+
return f"{inner_ext}{path.suffix.lower()}" if inner_ext else MediaTypes.get_ext(path)
49+
50+
51+
def decompress(path: Path, dest: Path, max_size: int = MAX_DECOMPRESSED_SIZE) -> bool:
52+
"""Decompress a file to dest, stopping and returning `False` if it exceeds max_size bytes."""
53+
size = 0
54+
with _OPENERS[path.suffix.lower()](path) as source, open(dest, "wb") as out:
55+
while chunk := source.read(_CHUNK_SIZE):
56+
size += len(chunk)
57+
if size > max_size:
58+
return False
59+
out.write(chunk)
60+
return True
61+
62+
63+
@contextmanager
64+
def decompressed(path: Path, max_size: int = MAX_DECOMPRESSED_SIZE) -> Generator[Path | None]:
65+
"""Yield a path to a file's uncompressed contents, or `None` if it can't be decompressed."""
66+
if not is_compressed(path):
67+
yield path
68+
return
69+
70+
if path.suffix.lower() not in _OPENERS:
71+
logger.info("[Decompression] Unsupported compression format", path=path)
72+
yield None
73+
return
74+
75+
# NOTE: Windows can't delete a file that a reader still has open, so cleanup can't be strict.
76+
with TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
77+
temp_path = Path(temp_dir) / path.stem
78+
try:
79+
is_decompressed = decompress(path, temp_path, max_size)
80+
except (EOFError, OSError, lzma.LZMAError, zstd.ZstdError) as e:
81+
logger.error("[Decompression] Could not decompress file", path=path, error=e)
82+
is_decompressed = False
83+
else:
84+
if not is_decompressed:
85+
logger.warning("[Decompression] File is too large to decompress", path=path)
86+
87+
yield temp_path if is_decompressed else None

‎src/tagstudio/previews/file_renderer.py‎

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from tagstudio.core.library.ignore import Ignore
2020
from tagstudio.core.media_types import MediaTypeGroup, MediaTypes, slugify
2121
from tagstudio.core.query_lang.file_groups import SEARCH
22+
from tagstudio.core.utils.decompression import decompressed, get_inner_ext, is_compressed
2223
from tagstudio.core.utils.types import unwrap
2324
from tagstudio.previews.base_preview import RENDER, BasePreview
2425
from tagstudio.previews.effects import apply_overlay_color
@@ -104,15 +105,15 @@ def __init__(self, library: Library, settings: AppSettings) -> None:
104105
# Key: ("name", UiColor, 512, 512, 1.25)
105106
self.icons: dict[tuple[str, UiColor, int, int, float], Image.Image] = {}
106107

107-
def _get_resource_id(self, url: Path) -> str:
108+
def _get_resource_id(self, path: Path) -> str:
108109
"""Return the name of the icon resource to use for a file type.
109110
110111
Special terms will return special resources.
111112
112113
Args:
113-
url (Path): The file url to assess. "$LOADING" will return the loading graphic.
114+
path (Path): The file path to assess. "$LOADING" will return the loading graphic.
114115
"""
115-
ext = url.suffix.lower()
116+
ext = get_inner_ext(path) or MediaTypes.get_ext(path)
116117
groups = MediaTypes.find(ext, SEARCH) # Fallback icons use the SEARCH context
117118
groups.sort( # Sort by priority and most specific dot-separated subgroup.
118119
key=lambda g: (
@@ -731,27 +732,19 @@ def _render(
731732

732733
if filepath and filepath.is_file():
733734
try:
734-
ext = filepath.suffix.lower() if filepath.suffix else filepath.stem.lower()
735-
for preview in FileRenderer._preview_renderers:
736-
media_type: MediaTypeGroup | None = getattr(
737-
MediaTypes, preview.media_type_name, None
735+
preview = FileRenderer._find_preview(filepath)
736+
if preview:
737+
image = preview.render(
738+
filepath=filepath,
739+
is_small=is_small,
740+
theme=theme,
741+
size=(scaled_size, scaled_size),
742+
dpi_scale=dpi_scale,
743+
)
744+
elif is_compressed(filepath):
745+
image = FileRenderer._render_decompressed(
746+
filepath, is_small, theme, (scaled_size, scaled_size), dpi_scale
738747
)
739-
if media_type is None:
740-
logger.error(
741-
f"[FileRenderer] "
742-
f"Attribute '{preview.media_type_name}' not registered with MediaTypes",
743-
)
744-
break
745-
746-
if media_type.contains(ext, RENDER):
747-
image = preview.render(
748-
filepath=filepath,
749-
is_small=is_small,
750-
theme=theme,
751-
size=(scaled_size, scaled_size),
752-
dpi_scale=dpi_scale,
753-
)
754-
break
755748

756749
if image:
757750
image = self._resize_image(image, (scaled_size, scaled_size))
@@ -763,6 +756,44 @@ def _render(
763756

764757
return image
765758

759+
@staticmethod
760+
def _find_preview(filepath: Path) -> type[BasePreview] | None:
761+
"""Return the highest priority preview renderer for a file's extension, if any."""
762+
ext = MediaTypes.get_ext(filepath) if filepath.suffix else filepath.stem.lower()
763+
for preview in FileRenderer._preview_renderers:
764+
media_type: MediaTypeGroup | None = getattr(MediaTypes, preview.media_type_name, None)
765+
if media_type is None:
766+
logger.error(
767+
f"[FileRenderer] "
768+
f"Attribute '{preview.media_type_name}' not registered with MediaTypes",
769+
)
770+
return None
771+
772+
if media_type.contains(ext, RENDER):
773+
return preview
774+
775+
return None
776+
777+
@staticmethod
778+
def _render_decompressed(
779+
filepath: Path, is_small: bool, theme: Theme, size: tuple[int, int], dpi_scale: float
780+
) -> Image.Image | None:
781+
"""Render a compressed file with the preview renderer for the uncompressed file."""
782+
preview = FileRenderer._find_preview(Path(filepath.stem))
783+
if preview is None:
784+
return None
785+
786+
with decompressed(filepath) as inner_path:
787+
if inner_path is None:
788+
return None
789+
790+
image = preview.render(
791+
filepath=inner_path, is_small=is_small, theme=theme, size=size, dpi_scale=dpi_scale
792+
)
793+
if image:
794+
image.load() # Read the image data before the temporary file is deleted
795+
return image
796+
766797
def _resize_image(self, image: Image.Image, size: tuple[int, int]) -> Image.Image:
767798
orig_x, orig_y = image.size
768799
new_x, new_y = size

0 commit comments

Comments
 (0)