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
This commit is contained in:
Travis Abendshien
2025-09-05 16:04:06 -07:00
committed by GitHub
parent 3374f6b07f
commit 7a8d34e190
7 changed files with 123 additions and 74 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)

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

@@ -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

@@ -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

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",