chore: merge main into pyright-alchemy

This commit is contained in:
Travis Abendshien
2025-09-06 03:25:23 -07:00
14 changed files with 211 additions and 126 deletions

View File

@@ -12,7 +12,6 @@ class SettingItems(str, enum.Enum):
LAST_LIBRARY = "last_library"
LIBS_LIST = "libs_list"
THUMB_CACHE_SIZE_LIMIT = "thumb_cache_size_limit"
class ShowFilepathOption(int, enum.Enum):

View File

@@ -13,21 +13,16 @@ from pydantic import BaseModel, Field
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption
logger = structlog.get_logger(__name__)
DEFAULT_GLOBAL_SETTINGS_PATH = (
Path.home() / "Appdata" / "Roaming" / "TagStudio" / "settings.toml"
if platform.system() == "Windows"
else Path.home() / ".config" / "TagStudio" / "settings.toml"
)
logger = structlog.get_logger(__name__)
class TomlEnumEncoder(toml.TomlEncoder):
@override
def dump_value(self, v): # pyright: ignore[reportMissingParameterType]
if isinstance(v, Enum):
return super().dump_value(v.value)
return super().dump_value(v)
DEFAULT_THUMB_CACHE_SIZE = 500 # Number in MiB
MIN_THUMB_CACHE_SIZE = 10 # Number in MiB
class Theme(IntEnum):
@@ -45,6 +40,14 @@ class Splash(StrEnum):
NINETY_FIVE = "95"
class TomlEnumEncoder(toml.TomlEncoder):
@override
def dump_value(self, v): # pyright: ignore[reportMissingParameterType]
if isinstance(v, Enum):
return super().dump_value(v.value)
return super().dump_value(v)
# NOTE: pydantic also has a BaseSettings class (from pydantic-settings) that allows any settings
# properties to be overwritten with environment variables. As TagStudio is not currently using
# environment variables, this was not based on that, but that may be useful in the future.
@@ -52,6 +55,7 @@ class GlobalSettings(BaseModel):
language: str = Field(default="en")
open_last_loaded_on_startup: bool = Field(default=True)
generate_thumbs: bool = Field(default=True)
thumb_cache_size: float = Field(default=DEFAULT_THUMB_CACHE_SIZE)
autoplay: bool = Field(default=True)
loop: bool = Field(default=True)
show_filenames_in_grid: bool = Field(default=True)
@@ -60,6 +64,7 @@ class GlobalSettings(BaseModel):
tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT)
theme: Theme = Field(default=Theme.SYSTEM)
splash: Splash = Field(default=Splash.DEFAULT)
windows_start_command: bool = Field(default=False)
date_format: str = Field(default="%x")
hour_format: bool = Field(default=True)

View File

@@ -44,7 +44,9 @@ def ignore_to_glob(ignore_patterns: list[str]) -> list[str]:
ignore_patterns (list[str]): The .gitignore-like patterns to convert.
"""
glob_patterns: list[str] = deepcopy(ignore_patterns)
glob_patterns_remove: list[str] = []
additional_patterns: list[str] = []
root_patterns: list[str] = []
# Mimic implicit .gitignore syntax behavior for the SQLite GLOB function.
for pattern in glob_patterns:
@@ -66,6 +68,16 @@ def ignore_to_glob(ignore_patterns: list[str]) -> list[str]:
gp = gp.removeprefix("**/").removeprefix("*/")
additional_patterns.append(exclusion_char + gp)
elif gp.startswith("/"):
# Matches "/file" case for .gitignore behavior where it should only match
# a file or folder int the root directory, and nowhere else.
glob_patterns_remove.append(gp)
gp = gp.lstrip("/")
root_patterns.append(exclusion_char + gp)
for gp in glob_patterns_remove:
glob_patterns.remove(gp)
glob_patterns = glob_patterns + additional_patterns
# Add "/**" suffix to suffix-less patterns to match implicit .gitignore behavior.
@@ -75,6 +87,7 @@ def ignore_to_glob(ignore_patterns: list[str]) -> list[str]:
glob_patterns.append(pattern.removesuffix("/*").removesuffix("/") + "/**")
glob_patterns = glob_patterns + root_patterns
glob_patterns = list(set(glob_patterns))
logger.info("[Ignore]", glob_patterns=glob_patterns)

View File

@@ -161,31 +161,34 @@ class RefreshDirTracker:
logger.info("[Refresh]: Falling back to wcmatch for scanning")
for f in pathlib.Path(str(library_dir)).glob(
"***/*", flags=PATH_GLOB_FLAGS, exclude=ignore_patterns
):
end_time_loop = time()
# Yield output every 1/30 of a second
if (end_time_loop - start_time_loop) > 0.034:
yield dir_file_count
start_time_loop = time()
try:
for f in pathlib.Path(str(library_dir)).glob(
"***/*", flags=PATH_GLOB_FLAGS, exclude=ignore_patterns
):
end_time_loop = time()
# Yield output every 1/30 of a second
if (end_time_loop - start_time_loop) > 0.034:
yield dir_file_count
start_time_loop = time()
# Skip if the file/path is already mapped in the Library
if f in self.library.included_files:
dir_file_count += 1
continue
# Ignore if the file is a directory
if f.is_dir():
continue
# Skip if the file/path is already mapped in the Library
if f in self.library.included_files:
dir_file_count += 1
continue
self.library.included_files.add(f)
# Ignore if the file is a directory
if f.is_dir():
continue
relative_path = f.relative_to(library_dir)
dir_file_count += 1
self.library.included_files.add(f)
relative_path = f.relative_to(library_dir)
if not self.library.has_path_entry(relative_path):
self.files_not_in_library.append(relative_path)
if not self.library.has_path_entry(relative_path):
self.files_not_in_library.append(relative_path)
except ValueError:
logger.info("[Refresh]: ValueError when refreshing directory with wcmatch!")
end_time_total = time()
yield dir_file_count

View File

@@ -12,58 +12,66 @@ import structlog
from PIL import Image
from tagstudio.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME
from tagstudio.core.global_settings import DEFAULT_THUMB_CACHE_SIZE
logger = structlog.get_logger(__name__)
class CacheEntry:
class CacheFolder:
def __init__(self, path: Path, size: int):
self.path: Path = path
self.size: int = size
class CacheManager:
DEFAULT_MAX_SIZE = 500_000_000
DEFAULT_MAX_FOLDER_SIZE = 10_000_000
MAX_FOLDER_SIZE = 10 # Number in MiB
STAT_MULTIPLIER = 1_000_000 # Multiplier to apply to file stats (bytes) to get user units (MiB)
def __init__(
self,
library_dir: Path,
max_size: int = DEFAULT_MAX_SIZE,
max_folder_size: int = DEFAULT_MAX_FOLDER_SIZE,
max_size: int | float = DEFAULT_THUMB_CACHE_SIZE,
):
self._lock = RLock()
self.cache_folder = library_dir / TS_FOLDER_NAME / THUMB_CACHE_NAME
self.max_folder_size = max_folder_size
self.max_size = max(max_size, max_folder_size)
"""A class for managing frontend caches, such as for file thumbnails.
self.folders: list[CacheEntry] = []
Args:
library_dir(Path): The path of the folder containing the .TagStudio library folder.
max_size: (int | float) The maximum size of the cache, in MiB.
"""
self._lock = RLock()
self.cache_path = library_dir / TS_FOLDER_NAME / THUMB_CACHE_NAME
self.max_size: int = max(
math.floor(max_size * CacheManager.STAT_MULTIPLIER),
math.floor(CacheManager.MAX_FOLDER_SIZE * CacheManager.STAT_MULTIPLIER),
)
self.folders: list[CacheFolder] = []
self.current_size = 0
if self.cache_folder.exists():
for folder in self.cache_folder.iterdir():
if self.cache_path.exists():
for folder in self.cache_path.iterdir():
if not folder.is_dir():
continue
folder_size = 0
for file in folder.iterdir():
folder_size += file.stat().st_size
self.folders.append(CacheEntry(folder, folder_size))
self.folders.append(CacheFolder(folder, folder_size))
self.current_size += folder_size
def _set_mru(self, index: int):
"""Move entry at index so it's considered the most recently used."""
def _set_most_recent_folder(self, index: int):
"""Move CacheFolder at index so it's considered the most recently used folder."""
with self._lock as _lock:
if index == (len(self.folders) - 1):
return
entry = self.folders.pop(index)
self.folders.append(entry)
cache_folder = self.folders.pop(index)
self.folders.append(cache_folder)
def _mru(self) -> Iterable[int]:
def _get_most_recent_folder(self) -> Iterable[int]:
"""Get each folders index sorted most recently used first."""
with self._lock as _lock:
return reversed(range(len(self.folders)))
def _lru(self) -> Iterable[int]:
"""Get each folders index sorted least recently used first."""
def _least_recent_folder(self) -> Iterable[int]:
"""Get each folder's index sorted least recently used first."""
with self._lock as _lock:
return range(len(self.folders))
@@ -78,14 +86,14 @@ class CacheManager:
self.folders = folders
logger.info("[CacheManager] Cleared cache!")
def _remove_folder(self, entry: CacheEntry) -> bool:
def _remove_folder(self, cache_folder: CacheFolder) -> bool:
with self._lock as _lock:
self.current_size -= entry.size
if not entry.path.is_dir():
self.current_size -= cache_folder.size
if not cache_folder.path.is_dir():
return True
is_empty = True
for file in entry.path.iterdir():
for file in cache_folder.path.iterdir():
assert file.is_file() and file.suffix == ".webp"
try:
file.unlink(missing_ok=True)
@@ -94,61 +102,61 @@ class CacheManager:
logger.warn("[CacheManager] Failed to remove file", file=file, error=e)
if is_empty:
entry.path.rmdir()
cache_folder.path.rmdir()
return True
else:
size = 0
for file in entry.path.iterdir():
for file in cache_folder.path.iterdir():
size += file.stat().st_size
entry.size = size
cache_folder.size = size
self.current_size += size
return False
def get_file_path(self, file_name: Path) -> Path | None:
with self._lock as _lock:
for i in self._mru():
entry = self.folders[i]
file_path = entry.path / file_name
for i in self._get_most_recent_folder():
cache_folder = self.folders[i]
file_path = cache_folder.path / file_name
if file_path.exists():
self._set_mru(i)
self._set_most_recent_folder(i)
return file_path
return None
def save_image(self, image: Image.Image, file_name: Path, mode: str = "RGBA"):
"""Save an image to the cache."""
with self._lock as _lock:
entry = self._get_current_folder()
file_path = entry.path / file_name
cache_folder: CacheFolder = self._get_current_folder()
file_path = cache_folder.path / file_name
image.save(file_path, mode=mode)
size = file_path.stat().st_size
entry.size += size
cache_folder.size += size
self.current_size += size
self._cull_folders()
def _create_folder(self) -> CacheEntry:
def _create_folder(self) -> CacheFolder:
with self._lock as _lock:
folder = self.cache_folder / Path(str(math.floor(dt.timestamp(dt.now()))))
folder = self.cache_path / Path(str(math.floor(dt.timestamp(dt.now()))))
try:
folder.mkdir(parents=True)
except FileExistsError:
for entry in self.folders:
if entry.path == folder:
return entry
entry = CacheEntry(folder, 0)
self.folders.append(entry)
return entry
for cache_folder in self.folders:
if cache_folder.path == folder:
return cache_folder
cache_folder = CacheFolder(folder, 0)
self.folders.append(cache_folder)
return cache_folder
def _get_current_folder(self) -> CacheEntry:
def _get_current_folder(self) -> CacheFolder:
with self._lock as _lock:
if len(self.folders) == 0:
return self._create_folder()
for i in self._mru():
entry = self.folders[i]
if entry.size < self.max_folder_size:
self._set_mru(i)
return entry
for i in self._get_most_recent_folder():
cache_folder: CacheFolder = self.folders[i]
if cache_folder.size < CacheManager.MAX_FOLDER_SIZE * CacheManager.STAT_MULTIPLIER:
self._set_most_recent_folder(i)
return cache_folder
return self._create_folder()
@@ -159,10 +167,12 @@ class CacheManager:
return
removed: list[int] = []
for i in self._lru():
entry = self.folders[i]
logger.info("[CacheManager] Removing folder due to size limit", folder=entry.path)
if self._remove_folder(entry):
for i in self._least_recent_folder():
cache_folder: CacheFolder = self.folders[i]
logger.info(
"[CacheManager] Removing folder due to size limit", folder=cache_folder.path
)
if self._remove_folder(cache_folder):
removed.append(i)
if self.current_size < self.max_size:
break

View File

@@ -144,7 +144,9 @@ class PreviewThumb(PreviewThumbView):
return self.__get_image_stats(filepath)
def _open_file_action_callback(self):
open_file(self.__current_file)
open_file(
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
)
def _open_explorer_action_callback(self):
open_file(self.__current_file, file_manager=True)
@@ -154,4 +156,6 @@ class PreviewThumb(PreviewThumbView):
self.__driver.delete_files_callback(self.__current_file)
def _button_wrapper_callback(self):
open_file(self.__current_file)
open_file(
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
)

View File

@@ -20,13 +20,15 @@ from tagstudio.qt.helpers.silent_popen import silent_Popen
logger = structlog.get_logger(__name__)
def open_file(path: str | Path, file_manager: bool = False):
def open_file(path: str | Path, file_manager: bool = False, windows_start_command: bool = False):
"""Open a file in the default application or file explorer.
Args:
path (str): The path to the file to open.
file_manager (bool, optional): Whether to open the file in the file manager
(e.g. Finder on macOS). Defaults to False.
windows_start_command (bool): Flag to determine if the older 'start' command should be used
on Windows for opening files. This fixes issues on some systems in niche cases.
"""
path = Path(path)
logger.info("Opening file", path=path)
@@ -51,14 +53,26 @@ def open_file(path: str | Path, file_manager: bool = False):
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
)
else:
command = f'"{normpath}"'
silent_Popen(
command,
shell=True,
close_fds=True,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
)
if windows_start_command:
command_name = "start"
# First parameter is for title, NOT filepath
command_args = ["", normpath]
subprocess.Popen(
[command_name] + command_args,
shell=True,
close_fds=True,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
)
else:
command = f'"{normpath}"'
silent_Popen(
command,
shell=True,
close_fds=True,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
)
else:
if sys.platform == "darwin":
command_name = "open"

View File

@@ -5,11 +5,14 @@
from typing import TYPE_CHECKING, Any
import structlog
from PySide6.QtCore import Qt
from PySide6.QtGui import QDoubleValidator
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QTabWidget,
@@ -18,13 +21,20 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption
from tagstudio.core.global_settings import Splash, Theme
from tagstudio.core.global_settings import (
DEFAULT_THUMB_CACHE_SIZE,
MIN_THUMB_CACHE_SIZE,
Splash,
Theme,
)
from tagstudio.qt.translations import DEFAULT_TRANSLATION, LANGUAGES, Translations
from tagstudio.qt.widgets.panel import PanelModal, PanelWidget
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
class SettingsPanel(PanelWidget):
driver: "QtDriver"
@@ -142,6 +152,25 @@ class SettingsPanel(PanelWidget):
self.generate_thumbs.setChecked(self.driver.settings.generate_thumbs)
form_layout.addRow(Translations["settings.generate_thumbs"], self.generate_thumbs)
# Thumbnail Cache Size
self.thumb_cache_size_container = QWidget()
self.thumb_cache_size_layout = QHBoxLayout(self.thumb_cache_size_container)
self.thumb_cache_size_layout.setContentsMargins(0, 0, 0, 0)
self.thumb_cache_size_layout.setSpacing(6)
self.thumb_cache_size = QLineEdit()
self.thumb_cache_size.setAlignment(Qt.AlignmentFlag.AlignRight)
self.validator = QDoubleValidator(MIN_THUMB_CACHE_SIZE, 1_000_000_000, 2) # High limit
self.thumb_cache_size.setValidator(self.validator)
self.thumb_cache_size.setText(
str(max(self.driver.settings.thumb_cache_size, MIN_THUMB_CACHE_SIZE)).removesuffix(".0")
)
self.thumb_cache_size_layout.addWidget(self.thumb_cache_size)
self.thumb_cache_size_layout.setStretch(1, 2)
self.thumb_cache_size_layout.addWidget(QLabel("MiB"))
form_layout.addRow(
Translations["settings.thumb_cache_size.label"], self.thumb_cache_size_container
)
# Autoplay
self.autoplay_checkbox = QCheckBox()
self.autoplay_checkbox.setChecked(self.driver.settings.autoplay)
@@ -252,6 +281,10 @@ class SettingsPanel(PanelWidget):
"language": self.__get_language(),
"open_last_loaded_on_startup": self.open_last_lib_checkbox.isChecked(),
"generate_thumbs": self.generate_thumbs.isChecked(),
"thumb_cache_size": max(
float(self.thumb_cache_size.text()) or DEFAULT_THUMB_CACHE_SIZE,
MIN_THUMB_CACHE_SIZE,
),
"autoplay": self.autoplay_checkbox.isChecked(),
"show_filenames_in_grid": self.show_filenames_checkbox.isChecked(),
"page_size": int(self.page_size_line_edit.text()),
@@ -271,6 +304,7 @@ class SettingsPanel(PanelWidget):
driver.settings.open_last_loaded_on_startup = settings["open_last_loaded_on_startup"]
driver.settings.autoplay = settings["autoplay"]
driver.settings.generate_thumbs = settings["generate_thumbs"]
driver.settings.thumb_cache_size = settings["thumb_cache_size"]
driver.settings.show_filenames_in_grid = settings["show_filenames_in_grid"]
driver.settings.page_size = settings["page_size"]
driver.settings.show_filepath = settings["show_filepath"]

View File

@@ -5,6 +5,8 @@
"""A pagination widget created for TagStudio."""
from typing import override
from PIL import Image, ImageQt
from PySide6.QtCore import QObject, QSize, Signal
from PySide6.QtGui import QIntValidator, QPixmap
@@ -285,9 +287,10 @@ class Pagination(QWidget, QObject):
class Validator(QIntValidator):
def __init__(self, bottom: int, top: int, parent=None) -> None:
super().__init__(bottom, top, parent)
def __init__(self, bottom: int, top: int) -> None:
super().__init__(bottom, top)
@override
def fixup(self, input: str) -> str:
input = input.strip("0")
return super().fixup(str(self.top()) if input else "1")

View File

@@ -13,10 +13,9 @@ logger = structlog.get_logger(__name__)
DEFAULT_TRANSLATION = "en"
LANGUAGES = {
# "Cantonese (Traditional)": "yue_Hant", # Empty
"Chinese (Simplified)": "zh_Hans",
"Chinese (Traditional)": "zh_Hant",
# "Czech": "cs", # Minimal
"Czech": "cs",
# "Danish": "da", # Minimal
"Dutch": "nl",
"English": "en",
@@ -29,7 +28,8 @@ LANGUAGES = {
"Norwegian Bokmål": "nb_NO",
"Polish": "pl",
"Portuguese (Brazil)": "pt_BR",
# "Portuguese (Portugal)": "pt", # Empty
"Portuguese (Portugal)": "pt",
"Romanian": "ro",
"Russian": "ru",
"Spanish": "es",
"Swedish": "sv",

View File

@@ -49,7 +49,11 @@ import tagstudio.qt.resources_rc # noqa: F401
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
from tagstudio.core.driver import DriverMixin
from tagstudio.core.enums import MacroID, SettingItems, ShowFilepathOption
from tagstudio.core.global_settings import DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, Theme
from tagstudio.core.global_settings import (
DEFAULT_GLOBAL_SETTINGS_PATH,
GlobalSettings,
Theme,
)
from tagstudio.core.library.alchemy.enums import (
BrowsingState,
FieldTypeEnum,
@@ -1691,15 +1695,9 @@ class QtDriver(DriverMixin, QObject):
open_status = LibraryStatus(
success=False, library_path=path, message=type(e).__name__, msg_description=str(e)
)
max_size: int = self.cached_values.value(
SettingItems.THUMB_CACHE_SIZE_LIMIT,
defaultValue=CacheManager.DEFAULT_MAX_SIZE,
type=int,
) # type: ignore
self.cache_manager = CacheManager(path, max_size=max_size)
self.cache_manager = CacheManager(path, max_size=self.settings.thumb_cache_size)
logger.info(
f"[Config] Thumbnail cache size limit: {format_size(max_size)}",
f"[Config] Thumbnail Cache Size: {format_size(self.settings.thumb_cache_size)}",
)
# Migration is required

View File

@@ -30,6 +30,7 @@ from tagstudio.core.constants import (
TS_FOLDER_NAME,
)
from tagstudio.core.enums import LibraryPrefs
from tagstudio.core.library.alchemy.constants import SQL_FILENAME
from tagstudio.core.library.alchemy.library import Entry, TagAlias, TagParent
from tagstudio.core.library.alchemy.library import Library as SqliteLibrary
from tagstudio.core.library.helpers.migration import json_to_sql_color
@@ -491,7 +492,7 @@ class JsonMigrationModal(QObject):
def finish_migration(self):
"""Finish the migration upon user approval."""
final_name = self.json_lib.library_dir / TS_FOLDER_NAME / SqliteLibrary.SQL_FILENAME
final_name = self.json_lib.library_dir / TS_FOLDER_NAME / SQL_FILENAME
if self.temp_path.exists():
self.temp_path.rename(final_name)

View File

@@ -1524,8 +1524,23 @@ class ThumbRenderer(QObject):
if _filepath and _filepath.is_file():
try:
ext: str = _filepath.suffix.lower() if _filepath.suffix else _filepath.stem.lower()
# Images =======================================================
# Ebooks =======================================================
if MediaCategories.is_ext_in_category(
ext, MediaCategories.EBOOK_TYPES, mime_fallback=True
):
image = self._epub_cover(_filepath)
# Krita ========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.KRITA_TYPES, mime_fallback=True
):
image = self._krita_thumb(_filepath)
# VTF ==========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True
):
image = self._vtf_thumb(_filepath)
# Images =======================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.IMAGE_TYPES, mime_fallback=True
):
# Raw Images -----------------------------------------------
@@ -1552,11 +1567,6 @@ class ThumbRenderer(QObject):
# PowerPoint Slideshow
elif ext in {".pptx"}:
image = self._powerpoint_thumb(_filepath)
# Krita ========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.KRITA_TYPES, mime_fallback=True
):
image = self._krita_thumb(_filepath)
# OpenDocument/OpenOffice ======================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True
@@ -1590,11 +1600,6 @@ class ThumbRenderer(QObject):
savable_media_type = False
if image is not None:
image = self._apply_overlay_color(image, UiColor.GREEN)
# Ebooks =======================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.EBOOK_TYPES, mime_fallback=True
):
image = self._epub_cover(_filepath)
# Blender ======================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.BLENDER_TYPES, mime_fallback=True
@@ -1605,11 +1610,6 @@ class ThumbRenderer(QObject):
ext, MediaCategories.PDF_TYPES, mime_fallback=True
):
image = self._pdf_thumb(_filepath, adj_size)
# VTF ==========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True
):
image = self._vtf_thumb(_filepath)
# No Rendered Thumbnail ========================================
if not image:
raise NoRendererError

View File

@@ -287,6 +287,7 @@
"settings.theme.label": "Theme:",
"settings.theme.light": "Light",
"settings.theme.system": "System",
"settings.thumb_cache_size.label": "Thumbnail Cache Size",
"settings.title": "Settings",
"settings.zeropadding.label": "Date Zero-Padding",
"sorting.direction.ascending": "Ascending",