From 7a8d34e190e09284e6ea06215849c216a3bcc3a7 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 5 Sep 2025 16:04:06 -0700 Subject: [PATCH] feat(ui): add thumb cache size setting to settings panel (#1088) * feat: add thumb cache size setting to settings panel * refactor: change names in cache_manager.py to be less ambiguous, more descriptive * refactor: store cache size in MiB instead of bytes --- src/tagstudio/core/enums.py | 1 - src/tagstudio/core/global_settings.py | 22 ++-- src/tagstudio/qt/cache_manager.py | 114 ++++++++++--------- src/tagstudio/qt/modals/settings_panel.py | 36 +++++- src/tagstudio/qt/pagination.py | 7 +- src/tagstudio/qt/ts_qt.py | 16 ++- src/tagstudio/resources/translations/en.json | 1 + 7 files changed, 123 insertions(+), 74 deletions(-) diff --git a/src/tagstudio/core/enums.py b/src/tagstudio/core/enums.py index 8432bbb8..4284fe40 100644 --- a/src/tagstudio/core/enums.py +++ b/src/tagstudio/core/enums.py @@ -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): diff --git a/src/tagstudio/core/global_settings.py b/src/tagstudio/core/global_settings.py index ec9812bf..5dc25fd2 100644 --- a/src/tagstudio/core/global_settings.py +++ b/src/tagstudio/core/global_settings.py @@ -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) diff --git a/src/tagstudio/qt/cache_manager.py b/src/tagstudio/qt/cache_manager.py index bb3f60f4..2c7c5f18 100644 --- a/src/tagstudio/qt/cache_manager.py +++ b/src/tagstudio/qt/cache_manager.py @@ -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 diff --git a/src/tagstudio/qt/modals/settings_panel.py b/src/tagstudio/qt/modals/settings_panel.py index 25f1b6d4..344f143e 100644 --- a/src/tagstudio/qt/modals/settings_panel.py +++ b/src/tagstudio/qt/modals/settings_panel.py @@ -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"] diff --git a/src/tagstudio/qt/pagination.py b/src/tagstudio/qt/pagination.py index 6cf11be9..51cb3604 100644 --- a/src/tagstudio/qt/pagination.py +++ b/src/tagstudio/qt/pagination.py @@ -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") diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 1f63d536..a6062b8b 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -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, @@ -1693,15 +1697,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 diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index b73512da..d7c649ac 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -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",