From 01680cab34df7ce9732feb396d2e64f3f1e2250f Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 4 Sep 2025 13:30:45 -0700 Subject: [PATCH 1/6] fix: update SQL_FILENAME to import from new constant (#1094) --- src/tagstudio/qt/widgets/migration_modal.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tagstudio/qt/widgets/migration_modal.py b/src/tagstudio/qt/widgets/migration_modal.py index 3702fbb4..3de94ae1 100644 --- a/src/tagstudio/qt/widgets/migration_modal.py +++ b/src/tagstudio/qt/widgets/migration_modal.py @@ -31,6 +31,7 @@ from tagstudio.core.constants import ( ) from tagstudio.core.enums import LibraryPrefs from tagstudio.core.library.alchemy import default_color_groups +from tagstudio.core.library.alchemy.constants import SQL_FILENAME from tagstudio.core.library.alchemy.joins import TagParent from tagstudio.core.library.alchemy.library import Library as SqliteLibrary from tagstudio.core.library.alchemy.models import Entry, TagAlias @@ -492,7 +493,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) From 2db8bed30410d7e18f841a004c243154c97a307e Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 4 Sep 2025 15:18:07 -0700 Subject: [PATCH 2/6] translations: add Czech, Portuguese (Portugal), and Romanian in UI --- src/tagstudio/qt/translations.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tagstudio/qt/translations.py b/src/tagstudio/qt/translations.py index 6c54d85f..1cc7dfbf 100644 --- a/src/tagstudio/qt/translations.py +++ b/src/tagstudio/qt/translations.py @@ -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", From 583d107cb8ba7964d5aba1eb67fa84a6f7fe24bb Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 5 Sep 2025 12:44:51 -0700 Subject: [PATCH 3/6] fix: reorder renderer types to fix early false positives (#1093) --- src/tagstudio/qt/widgets/thumb_renderer.py | 32 +++++++++++----------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/tagstudio/qt/widgets/thumb_renderer.py b/src/tagstudio/qt/widgets/thumb_renderer.py index 1c5f73d3..cd0db2fb 100644 --- a/src/tagstudio/qt/widgets/thumb_renderer.py +++ b/src/tagstudio/qt/widgets/thumb_renderer.py @@ -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 From eecb4d3e380dae987b6a0f56b352683c973ab912 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 5 Sep 2025 13:38:02 -0700 Subject: [PATCH 4/6] fix: account for leading slash pattern in wcmatch (#1092) --- src/tagstudio/core/library/ignore.py | 13 +++++++ src/tagstudio/core/utils/refresh_dir.py | 45 +++++++++++++------------ 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/src/tagstudio/core/library/ignore.py b/src/tagstudio/core/library/ignore.py index 66237cb2..af850b4e 100644 --- a/src/tagstudio/core/library/ignore.py +++ b/src/tagstudio/core/library/ignore.py @@ -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) diff --git a/src/tagstudio/core/utils/refresh_dir.py b/src/tagstudio/core/utils/refresh_dir.py index beec03b2..f32e822d 100644 --- a/src/tagstudio/core/utils/refresh_dir.py +++ b/src/tagstudio/core/utils/refresh_dir.py @@ -162,31 +162,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 From 3374f6b07f30f51b4006c5e9ef210cc2926d220d Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 5 Sep 2025 13:44:52 -0700 Subject: [PATCH 5/6] fix: add option to use old Windows 'start' command (#1084) --- src/tagstudio/core/global_settings.py | 1 + .../preview/preview_thumb_controller.py | 8 +++-- src/tagstudio/qt/helpers/file_opener.py | 32 +++++++++++++------ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/tagstudio/core/global_settings.py b/src/tagstudio/core/global_settings.py index 7f3add39..ec9812bf 100644 --- a/src/tagstudio/core/global_settings.py +++ b/src/tagstudio/core/global_settings.py @@ -60,6 +60,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) diff --git a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py index 017d8762..65223e54 100644 --- a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py @@ -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 + ) diff --git a/src/tagstudio/qt/helpers/file_opener.py b/src/tagstudio/qt/helpers/file_opener.py index 583830c8..0227a0d0 100644 --- a/src/tagstudio/qt/helpers/file_opener.py +++ b/src/tagstudio/qt/helpers/file_opener.py @@ -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" 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 6/6] 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",