diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index 64ff6214..98e59e43 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -7,7 +7,6 @@ TS_FOLDER_NAME: str = ".TagStudio" BACKUP_FOLDER_NAME: str = "backups" COLLAGE_FOLDER_NAME: str = "collages" -LIBRARY_FILENAME: str = "ts_library.json" # TODO: Turn this whitelist into a user-configurable blacklist. IMAGE_TYPES: list[str] = [ @@ -122,13 +121,13 @@ + SHORTCUT_TYPES ) - TAG_FAVORITE = 1 TAG_ARCHIVED = 0 class LibraryPrefs(Enum): - IS_EXCLUDE_LIST = True - EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"] + IS_EXCLUDE_LIST: bool = True + EXTENSION_LIST: list[str] = ["json", "xmp", "aae"] PAGE_SIZE: int = 500 - DB_VERSION: int = 1 + # increase in case of db breaking change (for now) + DB_VERSION: int = 2 diff --git a/tagstudio/src/core/driver.py b/tagstudio/src/core/driver.py new file mode 100644 index 00000000..5d52c5e0 --- /dev/null +++ b/tagstudio/src/core/driver.py @@ -0,0 +1,40 @@ +from pathlib import Path + +import structlog +from PySide6.QtCore import QSettings +from src.core.constants import TS_FOLDER_NAME +from src.core.enums import SettingItems +from src.core.library.alchemy.library import LibraryStatus + +logger = structlog.get_logger(__name__) + + +class DriverMixin: + settings: QSettings + + def evaluate_path(self, open_path: str | None) -> LibraryStatus: + """Check if the path of library is valid.""" + library_path: Path | None = None + if open_path: + library_path = Path(open_path) + if not library_path.exists(): + logger.error("Path does not exist.", open_path=open_path) + return LibraryStatus(success=False, message="Path does not exist.") + elif self.settings.value( + SettingItems.START_LOAD_LAST, defaultValue=True, type=bool + ) and self.settings.value(SettingItems.LAST_LIBRARY): + library_path = Path(str(self.settings.value(SettingItems.LAST_LIBRARY))) + if library_path and not (library_path / TS_FOLDER_NAME).exists(): + logger.error( + "TagStudio folder does not exist.", + library_path=library_path, + ts_folder=TS_FOLDER_NAME, + ) + self.settings.setValue(SettingItems.LAST_LIBRARY, "") + # dont consider this a fatal error, just skip opening the library + library_path = None + + return LibraryStatus( + success=True, + library_path=library_path, + ) diff --git a/tagstudio/src/core/library/alchemy/__init__.py b/tagstudio/src/core/library/alchemy/__init__.py index 2cfed3b1..ab3994c9 100644 --- a/tagstudio/src/core/library/alchemy/__init__.py +++ b/tagstudio/src/core/library/alchemy/__init__.py @@ -1,5 +1,6 @@ +from . import constants from .enums import ItemType from .library import Library from .models import Entry, Tag -__all__ = ["Entry", "Library", "Tag", "ItemType"] +__all__ = ["Entry", "Library", "Tag", "ItemType", "constants"] diff --git a/tagstudio/src/core/library/alchemy/constants.py b/tagstudio/src/core/library/alchemy/constants.py new file mode 100644 index 00000000..f5be0fe6 --- /dev/null +++ b/tagstudio/src/core/library/alchemy/constants.py @@ -0,0 +1 @@ +LIBRARY_FILENAME: str = "ts_library.sqlite" diff --git a/tagstudio/src/core/library/alchemy/fields.py b/tagstudio/src/core/library/alchemy/fields.py index 1b77c1e6..d0252ad9 100644 --- a/tagstudio/src/core/library/alchemy/fields.py +++ b/tagstudio/src/core/library/alchemy/fields.py @@ -18,27 +18,27 @@ class BaseField(Base): __abstract__ = True @declared_attr - def id(cls) -> Mapped[int]: # noqa: N805 + def id(self) -> Mapped[int]: return mapped_column(primary_key=True, autoincrement=True) @declared_attr - def type_key(cls) -> Mapped[str]: # noqa: N805 + def type_key(self) -> Mapped[str]: return mapped_column(ForeignKey("value_type.key")) @declared_attr - def type(cls) -> Mapped[ValueType]: # noqa: N805 - return relationship(foreign_keys=[cls.type_key], lazy=False) # type: ignore + def type(self) -> Mapped[ValueType]: + return relationship(foreign_keys=[self.type_key], lazy=False) # type: ignore @declared_attr - def entry_id(cls) -> Mapped[int]: # noqa: N805 + def entry_id(self) -> Mapped[int]: return mapped_column(ForeignKey("entries.id")) @declared_attr - def entry(cls) -> Mapped[Entry]: # noqa: N805 - return relationship(foreign_keys=[cls.entry_id]) # type: ignore + def entry(self) -> Mapped[Entry]: + return relationship(foreign_keys=[self.entry_id]) # type: ignore @declared_attr - def position(cls) -> Mapped[int]: # noqa: N805 + def position(self) -> Mapped[int]: return mapped_column(default=0) def __hash__(self): diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 125c88c8..4e523ced 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -36,6 +36,7 @@ TS_FOLDER_NAME, LibraryPrefs, ) +from . import constants from .db import make_tables from .enums import FieldTypeEnum, FilterState, TagColor from .fields import ( @@ -48,8 +49,6 @@ from .joins import TagField, TagSubtag from .models import Entry, Folder, Preferences, Tag, TagAlias, ValueType -LIBRARY_FILENAME: str = "ts_library.sqlite" - logger = structlog.get_logger(__name__) @@ -115,6 +114,13 @@ def __getitem__(self, index: int) -> Entry: return self.items[index] +@dataclass +class LibraryStatus: + success: bool + library_path: Path | None = None + message: str | None = None + + class Library: """Class for the Library object, and all CRUD operations made upon it.""" @@ -130,7 +136,9 @@ def close(self): self.storage_path = None self.folder = None - def open_library(self, library_dir: Path | str, storage_path: str | None = None) -> None: + def open_library( + self, library_dir: Path | str, storage_path: str | None = None + ) -> LibraryStatus: if isinstance(library_dir, str): library_dir = Path(library_dir) @@ -139,7 +147,7 @@ def open_library(self, library_dir: Path | str, storage_path: str | None = None) self.storage_path = storage_path else: self.verify_ts_folders(self.library_dir) - self.storage_path = self.library_dir / TS_FOLDER_NAME / LIBRARY_FILENAME + self.storage_path = self.library_dir / TS_FOLDER_NAME / constants.LIBRARY_FILENAME connection_string = URL.create( drivername="sqlite", @@ -183,6 +191,22 @@ def open_library(self, library_dir: Path | str, storage_path: str | None = None) logger.debug("ValueType already exists", field=field) session.rollback() + db_version = session.scalar( + select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name) + ) + # if the db version is different, we cant proceed + if db_version.value != LibraryPrefs.DB_VERSION.value: + logger.error( + "DB version mismatch", + db_version=db_version.value, + expected=LibraryPrefs.DB_VERSION.value, + ) + # TODO - handle migration + return LibraryStatus( + success=False, + message="Database version mismatch; delete db file to recreate", + ) + # check if folder matching current path exists already self.folder = session.scalar(select(Folder).where(Folder.path == self.library_dir)) if not self.folder: @@ -196,6 +220,8 @@ def open_library(self, library_dir: Path | str, storage_path: str | None = None) session.commit() self.folder = folder + return LibraryStatus(success=True) + @property def default_fields(self) -> list[BaseField]: with Session(self.engine) as session: @@ -324,13 +350,17 @@ def add_entries(self, items: list[Entry]) -> list[int]: with Session(self.engine) as session: # add all items - session.add_all(items) - session.flush() - new_ids = [item.id for item in items] + try: + session.add_all(items) + session.flush() + except IntegrityError: + session.rollback() + logger.exception("IntegrityError") + return [] + new_ids = [item.id for item in items] session.expunge_all() - session.commit() return new_ids @@ -396,9 +426,9 @@ def search_library( if not search.id: # if `id` is set, we don't need to filter by extensions if extensions and is_exclude_list: - statement = statement.where(Entry.path.notilike(f"%.{','.join(extensions)}")) + statement = statement.where(Entry.suffix.notin_(extensions)) elif extensions: - statement = statement.where(Entry.path.ilike(f"%.{','.join(extensions)}")) + statement = statement.where(Entry.suffix.in_(extensions)) statement = statement.options( selectinload(Entry.text_fields), @@ -770,7 +800,7 @@ def save_library_backup_to_disk(self) -> Path: target_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename shutil.copy2( - self.library_dir / TS_FOLDER_NAME / LIBRARY_FILENAME, + self.library_dir / TS_FOLDER_NAME / constants.LIBRARY_FILENAME, target_path, ) diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py index 303e9049..47a181dc 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -120,6 +120,7 @@ class Entry(Base): folder: Mapped[Folder] = relationship("Folder") path: Mapped[Path] = mapped_column(PathType, unique=True) + suffix: Mapped[str] = mapped_column() text_fields: Mapped[list[TextField]] = relationship( back_populates="entry", @@ -177,6 +178,8 @@ def __init__( self.path = path self.folder = folder + self.suffix = path.suffix and path.suffix.lstrip(".").lower() + for field in fields: if isinstance(field, TextField): self.text_fields.append(field) diff --git a/tagstudio/src/core/library/json/constants.py b/tagstudio/src/core/library/json/constants.py new file mode 100644 index 00000000..edf6dfe4 --- /dev/null +++ b/tagstudio/src/core/library/json/constants.py @@ -0,0 +1 @@ +LIBRARY_FILENAME: str = "ts_library.json" diff --git a/tagstudio/src/qt/modals/file_extension.py b/tagstudio/src/qt/modals/file_extension.py index 15403a88..e3c48634 100644 --- a/tagstudio/src/qt/modals/file_extension.py +++ b/tagstudio/src/qt/modals/file_extension.py @@ -104,7 +104,7 @@ def save(self): for i in range(self.table.rowCount()): ext = self.table.item(i, 0) if ext and ext.text().strip(): - extensions.append(ext.text().strip().lower()) + extensions.append(ext.text().strip().lstrip(".").lower()) # save preference self.lib.set_prefs(LibraryPrefs.EXTENSION_LIST, extensions) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 8ecd4e7a..50a9f9f5 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -50,6 +50,7 @@ QLineEdit, QMenu, QMenuBar, + QMessageBox, QPushButton, QScrollArea, QSplashScreen, @@ -58,11 +59,11 @@ from src.core.constants import ( TAG_ARCHIVED, TAG_FAVORITE, - TS_FOLDER_NAME, VERSION, VERSION_BRANCH, LibraryPrefs, ) +from src.core.driver import DriverMixin from src.core.enums import MacroID, SettingItems from src.core.library.alchemy.enums import ( FieldTypeEnum, @@ -71,6 +72,7 @@ SearchMode, ) from src.core.library.alchemy.fields import _FieldID +from src.core.library.alchemy.library import LibraryStatus from src.core.ts_core import TagStudioCore from src.core.utils.refresh_dir import RefreshDirTracker from src.core.utils.web import strip_web_protocol @@ -120,7 +122,7 @@ def run(self): pass -class QtDriver(QObject): +class QtDriver(DriverMixin, QObject): """A Qt GUI frontend driver for TagStudio.""" SIGTERM = Signal() @@ -173,16 +175,15 @@ def __init__(self, backend, args): filename=self.settings.fileName(), ) - max_threads = os.cpu_count() - for i in range(max_threads): - # thread = threading.Thread( - # target=self.consumer, name=f"ThumbRenderer_{i}", args=(), daemon=True - # ) - # thread.start() - thread = Consumer(self.thumb_job_queue) - thread.setObjectName(f"ThumbRenderer_{i}") - self.thumb_threads.append(thread) - thread.start() + def init_workers(self): + """Init workers for rendering thumbnails.""" + if not self.thumb_threads: + max_threads = os.cpu_count() + for i in range(max_threads): + thread = Consumer(self.thumb_job_queue) + thread.setObjectName(f"ThumbRenderer_{i}") + self.thumb_threads.append(thread) + thread.start() def open_library_from_dialog(self): dir = QFileDialog.getExistingDirectory( @@ -457,33 +458,42 @@ def create_folders_tags_modal(): self.item_thumbs: list[ItemThumb] = [] self.thumb_renderers: list[ThumbRenderer] = [] self.filter = FilterState() - self.init_library_window() - lib: str | None = None - if self.args.open: - lib = self.args.open - elif self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool): - lib = str(self.settings.value(SettingItems.LAST_LIBRARY)) - - # TODO: Remove this check if the library is no longer saved with files - if lib and not (Path(lib) / TS_FOLDER_NAME).exists(): - logger.error(f"[QT DRIVER] {TS_FOLDER_NAME} folder in {lib} does not exist.") - self.settings.setValue(SettingItems.LAST_LIBRARY, "") - lib = None - - if lib: + # check status of library path evaluating + path_result = self.evaluate_path(self.args.open) + if path_result.success and path_result.library_path: + # check the status of library opening self.splash.showMessage( - f'Opening Library "{lib}"...', + f'Opening Library "{path_result.library_path}"...', int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter), QColor("#9782ff"), ) - self.open_library(lib) + path_result = self.open_library(path_result.library_path) - app.exec() + if path_result.success: + app.exec() + else: + self.show_error_message(path_result.message or "Error opening library.") self.shutdown() + def show_error_message(self, message: str): + self.main_window.statusbar.showMessage(message, 3) + self.main_window.setWindowTitle(message) + + msg_box = QMessageBox() + msg_box.setIcon(QMessageBox.Icon.Critical) + msg_box.setText(message) + msg_box.setWindowTitle("Error") + + # Add a button that closes the application + quit_button = msg_box.addButton("Close", QMessageBox.ButtonRole.AcceptRole) + quit_button.clicked.connect(lambda: (self.shutdown())) + + # Show the message box + msg_box.exec() + def init_library_window(self): # self._init_landing_page() # Taken care of inside the widget now self._init_thumb_grid() @@ -562,7 +572,7 @@ def close_library(self, is_shutdown: bool = False): self.main_window.statusbar.showMessage("Closing Library...") start_time = time.time() - self.settings.setValue(SettingItems.LAST_LIBRARY, self.lib.library_dir) + self.settings.setValue(SettingItems.LAST_LIBRARY, str(self.lib.library_dir)) self.settings.sync() self.lib.close() @@ -1000,12 +1010,10 @@ def filter_items(self, filter: FilterState | None = None) -> None: end_time = time.time() if self.filter.summary: - # fmt: off self.main_window.statusbar.showMessage( - f"{results.total_count} Results Found for \"{self.filter.summary}\"" + f'{results.total_count} Results Found for "{self.filter.summary}"' f" ({format_timespan(end_time - start_time)})" ) - # fmt: on else: self.main_window.statusbar.showMessage( f"{results.total_count} Results ({format_timespan(end_time - start_time)})" @@ -1061,14 +1069,18 @@ def update_libs_list(self, path: Path | str): self.settings.endGroup() self.settings.sync() - def open_library(self, path: Path | str): + def open_library(self, path: Path | str) -> LibraryStatus: """Opens a TagStudio library.""" open_message: str = f'Opening Library "{str(path)}"...' self.main_window.landing_widget.set_status_label(open_message) self.main_window.statusbar.showMessage(open_message, 3) self.main_window.repaint() - self.lib.open_library(path) + open_status = self.lib.open_library(path) + if not open_status.success: + return open_status + + self.init_workers() self.filter.page_size = self.lib.prefs(LibraryPrefs.PAGE_SIZE) @@ -1086,3 +1098,4 @@ def open_library(self, path: Path | str): self.filter_items() self.main_window.toggle_landing_page(enabled=False) + return open_status diff --git a/tagstudio/src/qt/widgets/video_player.py b/tagstudio/src/qt/widgets/video_player.py index 0d5928f5..e1a4c141 100644 --- a/tagstudio/src/qt/widgets/video_player.py +++ b/tagstudio/src/qt/widgets/video_player.py @@ -41,6 +41,7 @@ class VideoPlayer(QGraphicsView): video_preview = None play_pause = None mute_button = None + filepath: str | None def __init__(self, driver: "QtDriver") -> None: super().__init__() diff --git a/tagstudio/tests/qt/test_driver.py b/tagstudio/tests/qt/test_qt_driver.py similarity index 100% rename from tagstudio/tests/qt/test_driver.py rename to tagstudio/tests/qt/test_qt_driver.py diff --git a/tagstudio/tests/test_driver.py b/tagstudio/tests/test_driver.py new file mode 100644 index 00000000..65882d68 --- /dev/null +++ b/tagstudio/tests/test_driver.py @@ -0,0 +1,66 @@ +from os import makedirs +from pathlib import Path +from tempfile import TemporaryDirectory + +from PySide6.QtCore import QSettings +from src.core.constants import TS_FOLDER_NAME +from src.core.driver import DriverMixin +from src.core.enums import SettingItems +from src.core.library.alchemy.library import LibraryStatus + + +class TestDriver(DriverMixin): + def __init__(self, settings): + self.settings = settings + + +def test_evaluate_path_empty(): + # Given + settings = QSettings() + driver = TestDriver(settings) + + # When + result = driver.evaluate_path(None) + + # Then + assert result == LibraryStatus(success=True) + + +def test_evaluate_path_missing(): + # Given + settings = QSettings() + driver = TestDriver(settings) + + # When + result = driver.evaluate_path("/0/4/5/1/") + + # Then + assert result == LibraryStatus(success=False, message="Path does not exist.") + + +def test_evaluate_path_last_lib_not_exists(): + # Given + settings = QSettings() + settings.setValue(SettingItems.LAST_LIBRARY, "/0/4/5/1/") + driver = TestDriver(settings) + + # When + result = driver.evaluate_path(None) + + # Then + assert result == LibraryStatus(success=True, library_path=None, message=None) + + +def test_evaluate_path_last_lib_present(): + # Given + settings = QSettings() + with TemporaryDirectory() as tmpdir: + settings.setValue(SettingItems.LAST_LIBRARY, tmpdir) + makedirs(Path(tmpdir) / TS_FOLDER_NAME) + driver = TestDriver(settings) + + # When + result = driver.evaluate_path(None) + + # Then + assert result == LibraryStatus(success=True, library_path=Path(tmpdir))