Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): add thumbnail caching #694

Merged
merged 11 commits into from
Jan 27, 2025
4 changes: 2 additions & 2 deletions docs/updates/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ Features are broken up into the following priority levels, with nested prioritie
- [x] Timeline scrubber [HIGH]
- [ ] Fullscreen [MEDIUM]
- [ ] Optimizations [HIGH]
- [ ] Thumbnail caching [HIGH] [#104](https://github.com/TagStudioDev/TagStudio/issues/104)
- [x] Thumbnail caching [HIGH]
- [ ] File property indexes [HIGH]

## Version Milestones
Expand Down Expand Up @@ -195,7 +195,7 @@ These version milestones are rough estimations for when the previous core featur
- [ ] Library Settings [HIGH]
- [ ] Stored in `.TagStudio` folder [HIGH]
- [ ] Optimizations [HIGH]
- [ ] Thumbnail caching [HIGH]
- [x] Thumbnail caching [HIGH]

### 9.6 (Alpha)

Expand Down
11 changes: 6 additions & 5 deletions tagstudio/resources/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,12 @@
"menu.edit.manage_tags": "Manage Tags",
"menu.edit.new_tag": "New &Tag",
"menu.edit": "Edit",
"menu.file.clear_recent_libraries": "Clear Recent",
"menu.file.close_library": "&Close Library",
"menu.file.new_library": "New Library",
"menu.file.open_create_library": "&Open/Create Library",
"menu.file.open_recent_library": "Open Recent",
"menu.file.clear_recent_libraries": "Clear Recent",
"menu.file.open_library": "Open Library",
"menu.file.open_recent_library": "Open Recent",
"menu.file.refresh_directories": "&Refresh Directories",
"menu.file.save_backup": "&Save Library Backup",
"menu.file.save_library": "Save Library",
Expand All @@ -177,9 +177,12 @@
"preview.no_selection": "No Items Selected",
"select.all": "Select All",
"select.clear": "Clear Selection",
"settings.clear_thumb_cache.title": "Clear Thumbnail Cache",
"settings.open_library_on_start": "Open Library on Start",
"settings.show_filenames_in_grid": "Show Filenames in Grid",
"settings.show_recent_libraries": "Show Recent Libraries",
"sorting.direction.ascending": "Ascending",
"sorting.direction.descending": "Descending",
"splash.opening_library": "Opening Library \"{library_path}\"...",
"status.library_backup_in_progress": "Saving Library Backup...",
"status.library_backup_success": "Library Backup Saved at: \"{path}\" ({time_span})",
Expand Down Expand Up @@ -214,7 +217,5 @@
"view.size.4": "Extra Large",
"window.message.error_opening_library": "Error opening library.",
"window.title.error": "Error",
"window.title.open_create_library": "Open/Create Library",
"sorting.direction.ascending": "Ascending",
"sorting.direction.descending": "Descending"
"window.title.open_create_library": "Open/Create Library"
}
1 change: 1 addition & 0 deletions tagstudio/src/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
TS_FOLDER_NAME: str = ".TagStudio"
BACKUP_FOLDER_NAME: str = "backups"
COLLAGE_FOLDER_NAME: str = "collages"
THUMB_CACHE_NAME: str = "thumbs"

FONT_SAMPLE_TEXT: str = (
"""ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]"""
Expand Down
1 change: 1 addition & 0 deletions tagstudio/src/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class SettingItems(str, enum.Enum):
WINDOW_SHOW_LIBS = "window_show_libs"
SHOW_FILENAMES = "show_filenames"
AUTOPLAY = "autoplay_videos"
THUMB_CACHE_SIZE_LIMIT = "thumb_cache_size_limit"


class Theme(str, enum.Enum):
Expand Down
2 changes: 1 addition & 1 deletion tagstudio/src/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ class Library:
def close(self):
if self.engine:
self.engine.dispose()
self.library_dir = None
self.library_dir: Path | None = None
self.storage_path = None
self.folder = None
self.included_files = set()
Expand Down
20 changes: 20 additions & 0 deletions tagstudio/src/core/singleton.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Based off example from Refactoring Guru:
# https://refactoring.guru/design-patterns/singleton/python/example#example-1
# Adapted for TagStudio: https://github.com/CyanVoxel/TagStudio

from threading import Lock


class Singleton(type):
"""A thread-safe implementation of a Singleton."""

_instances: dict = {}

_lock: Lock = Lock()

def __call__(cls, *args, **kwargs):
with cls._lock:
if cls not in cls._instances:
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
192 changes: 192 additions & 0 deletions tagstudio/src/qt/cache_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio

import contextlib
import math
import typing
from datetime import datetime as dt
from pathlib import Path

import structlog
from PIL import (
Image,
)
from src.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME
from src.core.singleton import Singleton

# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.core.library import Library

logger = structlog.get_logger(__name__)


class CacheManager(metaclass=Singleton):
FOLDER_SIZE = 10000000 # Each cache folder assumed to be 10 MiB
size_limit = 500000000 # 500 MiB default

folder_dict: dict[Path, int] = {}

def __init__(self):
self.lib: Library | None = None
self.last_lib_path: Path | None = None

@staticmethod
def clear_cache(library_dir: Path) -> bool:
"""Clear all files and folders within the cached folder.

Returns:
bool: True if successfully deleted, else False.
"""
cleared = True

if library_dir:
tree: Path = library_dir / TS_FOLDER_NAME / THUMB_CACHE_NAME

for folder in tree.glob("*"):
for file in folder.glob("*"):
# NOTE: On macOS with non-native file systems, this will commonly raise
# FileNotFound errors due to trying to delete "._" files that have
# already been deleted: https://bugs.python.org/issue29699
with contextlib.suppress(FileNotFoundError):
file.unlink()
try:
folder.rmdir()
with contextlib.suppress(KeyError):
CacheManager.folder_dict.pop(folder)
except Exception as e:
logger.error(
"[CacheManager] Couldn't unlink empty cache folder!",
error=e,
folder=folder,
tree=tree,
)

for _ in tree.glob("*"):
cleared = False

if cleared:
logger.info("[CacheManager] Cleared cache!")
else:
logger.error("[CacheManager] Couldn't delete cache!", tree=tree)

return cleared

def set_library(self, library):
"""Set the TagStudio library for the cache manager."""
self.lib = library
self.last_lib_path = self.lib.library_dir
if library.library_dir:
self.check_folder_status()

def cache_dir(self) -> Path | None:
"""Return the current cache directory, not including folder slugs."""
if not self.lib.library_dir:
return None
return Path(self.lib.library_dir / TS_FOLDER_NAME / THUMB_CACHE_NAME)

def save_image(self, image: Image.Image, path: Path, mode: str = "RGBA"):
"""Save an image to the cache."""
folder = self.get_current_folder()
if folder:
image_path: Path = folder / path
image.save(image_path, mode=mode)
with contextlib.suppress(KeyError):
CacheManager.folder_dict[folder] += image_path.stat().st_size

def check_folder_status(self):
"""Check the status of the cache folders.

This includes registering existing ones and creating new ones if needed.
"""
if (
(self.last_lib_path != self.lib.library_dir)
or not self.cache_dir()
or not self.cache_dir().exists()
):
self.register_existing_folders()

def create_folder() -> Path | None:
"""Create a new cache folder."""
if not self.lib.library_dir:
return None
folder_path = Path(self.cache_dir() / str(math.floor(dt.timestamp(dt.now()))))
logger.info("[CacheManager] Creating new folder", folder=folder_path)
try:
folder_path.mkdir(exist_ok=True)
except NotADirectoryError:
logger.error("[CacheManager] Not a directory", path=folder_path)
return folder_path

# Get size of most recent folder, if any exist.
if CacheManager.folder_dict:
last_folder = sorted(CacheManager.folder_dict.keys())[-1]

if CacheManager.folder_dict[last_folder] > CacheManager.FOLDER_SIZE:
new_folder = create_folder()
CacheManager.folder_dict[new_folder] = 0
else:
new_folder = create_folder()
CacheManager.folder_dict[new_folder] = 0

def get_current_folder(self) -> Path:
"""Get the current cache folder path that should be used."""
self.check_folder_status()
self.cull_folders()

return sorted(CacheManager.folder_dict.keys())[-1]

def register_existing_folders(self):
"""Scan and register any pre-existing cache folders with the most recent size."""
self.last_lib_path = self.lib.library_dir
CacheManager.folder_dict.clear()

# NOTE: The /dev/null check is a workaround for current test assumptions.
if self.last_lib_path and self.last_lib_path != Path("/dev/null"):
# Ensure thumbnail cache path exists.
self.cache_dir().mkdir(exist_ok=True)
# Registers any existing folders and counts the capacity of the most recent one.
for f in sorted(self.cache_dir().glob("*")):
if f.is_dir():
# A folder is found. Add it to the class dict.BlockingIOError
CacheManager.folder_dict[f] = 0
CacheManager.folder_dict = dict(
sorted(CacheManager.folder_dict.items(), key=lambda kv: kv[0])
)

if CacheManager.folder_dict:
last_folder = sorted(CacheManager.folder_dict.keys())[-1]
for f in last_folder.glob("*"):
if not f.is_dir():
with contextlib.suppress(KeyError):
CacheManager.folder_dict[last_folder] += f.stat().st_size

def cull_folders(self):
"""Remove folders and their cached context based on size or age limits."""
# Ensure that the user's configured size limit isn't less than the internal folder size.
size_limit = max(CacheManager.size_limit, CacheManager.FOLDER_SIZE)

if len(CacheManager.folder_dict) > (size_limit / CacheManager.FOLDER_SIZE):
f = sorted(CacheManager.folder_dict.keys())[0]
folder = self.cache_dir() / f
logger.info("[CacheManager] Removing folder due to size limit", folder=folder)

for file in folder.glob("*"):
try:
file.unlink()
except Exception as e:
logger.error(
"[CacheManager] Couldn't cull file inside of folder!",
error=e,
file=file,
folder=folder,
)
try:
folder.rmdir()
with contextlib.suppress(KeyError):
CacheManager.folder_dict.pop(f)
self.cull_folders()
except Exception as e:
logger.error("[CacheManager] Couldn't cull folder!", error=e, folder=folder)
pass
12 changes: 9 additions & 3 deletions tagstudio/src/qt/helpers/gradient.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@


def four_corner_gradient(
image: Image.Image, size: tuple[int, int], mask: Image.Image
image: Image.Image, size: tuple[int, int], mask: Image.Image | None = None
) -> Image.Image:
if image.size != size:
# Four-Corner Gradient Background.
Expand All @@ -29,11 +29,17 @@ def four_corner_gradient(
)

final = Image.new("RGBA", bg.size, (0, 0, 0, 0))
final.paste(bg, mask=mask.getchannel(0))
if mask:
final.paste(bg, mask=mask.getchannel(0))
else:
final = bg

else:
final = Image.new("RGBA", size, (0, 0, 0, 0))
final.paste(image, mask=mask.getchannel(0))
if mask:
final.paste(image, mask=mask.getchannel(0))
else:
final = image

if final.mode != "RGBA":
final = final.convert("RGBA")
Expand Down
2 changes: 1 addition & 1 deletion tagstudio/src/qt/resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def get(self, id: str) -> Any:
pass
except FileNotFoundError:
path: Path = ResourceManager._res_folder / "resources" / res.get("path")
logger.error("[ResourceManager][ERROR]: Could not find resource: ", path)
logger.error("[ResourceManager][ERROR]: Could not find resource: ", path=path)
return None

def __getattr__(self, __name: str) -> Any:
Expand Down
Loading
Loading