diff --git a/requirements.txt b/requirements.txt index 5658340c..39156a43 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ pydub==0.25.1 mutagen==1.47.0 numpy==1.26.4 ffmpeg-python==0.2.0 +Send2Trash==1.8.3 \ No newline at end of file diff --git a/tagstudio/resources/qt/videos/placeholder.mp4 b/tagstudio/resources/qt/videos/placeholder.mp4 new file mode 100644 index 00000000..1e22e4c7 Binary files /dev/null and b/tagstudio/resources/qt/videos/placeholder.mp4 differ diff --git a/tagstudio/src/core/media_types.py b/tagstudio/src/core/media_types.py index 449aa8ae..c85c0abe 100644 --- a/tagstudio/src/core/media_types.py +++ b/tagstudio/src/core/media_types.py @@ -23,6 +23,7 @@ class MediaType(str, Enum): DISK_IMAGE: str = "disk_image" DOCUMENT: str = "document" FONT: str = "font" + IMAGE_ANIMATED: str = "image_animated" IMAGE_RAW: str = "image_raw" IMAGE_VECTOR: str = "image_vector" IMAGE: str = "image" @@ -168,6 +169,12 @@ class MediaCategories: ".woff", ".woff2", } + _IMAGE_ANIMATED_SET: set[str] = { + ".apng", + ".gif", + ".webp", + ".jxl", + } _IMAGE_RAW_SET: set[str] = { ".arw", ".cr2", @@ -317,6 +324,11 @@ class MediaCategories: extensions=_FONT_SET, is_iana=True, ) + IMAGE_ANIMATED_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.IMAGE_ANIMATED, + extensions=_IMAGE_ANIMATED_SET, + is_iana=False, + ) IMAGE_RAW_TYPES: MediaCategory = MediaCategory( media_type=MediaType.IMAGE_RAW, extensions=_IMAGE_RAW_SET, @@ -404,6 +416,7 @@ class MediaCategories: DISK_IMAGE_TYPES, DOCUMENT_TYPES, FONT_TYPES, + IMAGE_ANIMATED_TYPES, IMAGE_RAW_TYPES, IMAGE_TYPES, IMAGE_VECTOR_TYPES, diff --git a/tagstudio/src/qt/helpers/file_deleter.py b/tagstudio/src/qt/helpers/file_deleter.py index 3e99b696..8047c4ce 100644 --- a/tagstudio/src/qt/helpers/file_deleter.py +++ b/tagstudio/src/qt/helpers/file_deleter.py @@ -1,38 +1,30 @@ -import logging -import traceback -from pathlib import Path -from collections.abc import Callable +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio -ERROR = f"[ERROR]" -WARNING = f"[WARNING]" -INFO = f"[INFO]" +import logging +from pathlib import Path + +from send2trash import send2trash logging.basicConfig(format="%(message)s", level=logging.INFO) -def delete_file(path: str | Path, callback: Callable): - _path = str(path) - _file = Path(_path) - logging.info(f"Deleting file: {_path}") - if not _file.exists(): - logging.error(f"File not found: {_path}") - return +def delete_file(path: str | Path) -> bool: + """Sends a file to the system trash. + + Args: + path (str | Path): The path of the file to delete. + """ + _path = Path(path) try: - _file.unlink() - callback() - except Exception as exception: - logging.exception(exception) - - -class FileDeleterHelper: - def __init__(self, filepath: str | Path): - self.filepath = filepath - - def set_filepath(self, filepath: str | Path): - self.filepath = filepath - - def set_delete_callback(self, callback: Callable): - self.delete_callback = callback - - def delete_file(self): - delete_file(self.filepath, self.delete_callback) + logging.info(f"[delete_file] Sending to Trash: {_path}") + send2trash(_path) + return True + except PermissionError as e: + logging.error(f"[delete_file][ERROR] PermissionError: {e}") + except FileNotFoundError: + logging.error(f"[delete_file][ERROR] File Not Found: {_path}") + except Exception as e: + logging.error(e) + return False diff --git a/tagstudio/src/qt/resource_manager.py b/tagstudio/src/qt/resource_manager.py index 3c4e8099..c38d382a 100644 --- a/tagstudio/src/qt/resource_manager.py +++ b/tagstudio/src/qt/resource_manager.py @@ -31,6 +31,20 @@ class ResourceManager: ) ResourceManager._initialized = True + @staticmethod + def get_path(id: str) -> Path | None: + """Get a resource's path from the ResourceManager. + Args: + id (str): The name of the resource. + + Returns: + Path: The resource path if found, else None. + """ + res: dict = ResourceManager._map.get(id) + if res: + return Path(__file__).parents[2] / "resources" / res.get("path") + return None + def get(self, id: str) -> Any: """Get a resource from the ResourceManager. This can include resources inside and outside of QResources, and will return diff --git a/tagstudio/src/qt/resources.json b/tagstudio/src/qt/resources.json index ef007b6f..e5857909 100644 --- a/tagstudio/src/qt/resources.json +++ b/tagstudio/src/qt/resources.json @@ -90,5 +90,9 @@ "thumb_loading": { "path": "qt/images/thumb_loading.png", "mode": "pil" + }, + "placeholder_mp4": { + "path": "qt/videos/placeholder.mp4", + "mode": "rb" } } diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 8421cc5e..8338fa62 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -69,9 +69,11 @@ from src.core.constants import ( TAG_FAVORITE, TAG_ARCHIVED, ) +from src.core.media_types import MediaCategories, MediaType from src.core.utils.web import strip_web_protocol from src.qt.flowlayout import FlowLayout from src.qt.main_window import Ui_MainWindow +from src.qt.helpers.file_deleter import delete_file from src.qt.helpers.function_iterator import FunctionIterator from src.qt.helpers.custom_runnable import CustomRunnable from src.qt.resource_manager import ResourceManager @@ -532,7 +534,7 @@ class QtDriver(QObject): folders_to_tags_action.triggered.connect(lambda: ftt_modal.show()) macros_menu.addAction(folders_to_tags_action) - # Help Menu ========================================================== + # Help Menu ============================================================ self.repo_action = QAction("Visit GitHub Repository", menu_bar) self.repo_action.triggered.connect( lambda: webbrowser.open("https://github.com/TagStudioDev/TagStudio") @@ -549,6 +551,9 @@ class QtDriver(QObject): menu_bar.addMenu(window_menu) menu_bar.addMenu(help_menu) + # ====================================================================== + + # Preview Panel -------------------------------------------------------- self.preview_panel = PreviewPanel(self.lib, self) l: QHBoxLayout = self.main_window.splitter l.addWidget(self.preview_panel) @@ -824,6 +829,73 @@ class QtDriver(QObject): self.modal.saved.connect(lambda: (panel.save(), self.filter_items(""))) self.modal.show() + def delete_files_callback(self, origin_path: str | Path): + """Callback to send on or more files to the system trash. + + If 0-1 items are currently selected, the origin_path is used to delete the file + from the originating context menu item. + If there are currently multiple items selected, + then the selection buffer is used to determine the files to be deleted. + + Args: + origin_path(str): The file path associated with the widget making the call. + May or may not be the file targeted, depending on the selection rules. + """ + _op: Path = Path(origin_path) + item = None + deleted_count: int = 0 + filepath: Path = None # Initialize + if len(self.selected) <= 1: + if self.selected: + item = self.lib.get_entry(self.selected[0][1]) + filepath = self.lib.library_dir / item.path / item.filename + # If the file to be deleted is currently being displayed on the Preview Panel, + # tell the panel to stop any use of the file. + if origin_path == filepath: + self.preview_panel.stop_file_use() + self.main_window.statusbar.showMessage(f'Deleting file "{origin_path}"...') + self.main_window.statusbar.repaint() + if delete_file(_op): + op_item = self.lib.get_entry_id_from_filepath(_op) + self.lib.remove_entry(op_item) + self.purge_item_from_navigation(ItemType.ENTRY, op_item) + deleted_count += 1 + elif len(self.selected) > 1: + for i, item_pair in enumerate(self.selected): + if item_pair[0] == ItemType.ENTRY: + item = self.lib.get_entry(item_pair[1]) + filepath = self.lib.library_dir / item.path / item.filename + self.main_window.statusbar.showMessage( + f'Deleting file "{filepath}"...' + ) + self.main_window.statusbar.repaint() + if delete_file(filepath): + self.purge_item_from_navigation(item.type, item.id) + self.lib.remove_entry(item.id) + deleted_count += 1 + self.selected.clear() + + self.filter_items() + self.preview_panel.update_widgets() + + if len(self.selected) <= 1 and deleted_count == 0: + self.main_window.statusbar.showMessage( + "No files deleted. Check if any of the files are currently in use." + ) + elif len(self.selected) <= 1 and deleted_count == 1: + self.main_window.statusbar.showMessage(f"Deleted {deleted_count} file!") + elif len(self.selected) > 1 and deleted_count == 0: + self.main_window.statusbar.showMessage( + "No files deleted! Check if any of the files are currently in use." + ) + elif len(self.selected) > 1 and deleted_count < len(self.selected): + self.main_window.statusbar.showMessage( + f"Only deleted {deleted_count} file{'' if deleted_count == 1 else 's'}! Check if any of the files are currently in use" + ) + elif len(self.selected) > 1 and deleted_count == len(self.selected): + self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!") + self.main_window.statusbar.repaint() + def add_new_files_callback(self): """Runs when user initiates adding new files to the Library.""" # # if self.lib.files_not_in_library: @@ -1449,12 +1521,20 @@ class QtDriver(QObject): for i, item_thumb in enumerate(self.item_thumbs, start=0): if i < len(self.nav_frames[self.cur_frame_idx].contents): - filepath = "" + filepath: Path = None # Initialize if self.nav_frames[self.cur_frame_idx].contents[i][0] == ItemType.ENTRY: entry = self.lib.get_entry( self.nav_frames[self.cur_frame_idx].contents[i][1] ) - filepath = self.lib.library_dir / entry.path / entry.filename + filepath: Path = self.lib.library_dir / entry.path / entry.filename + + try: + item_thumb.delete_action.triggered.disconnect() + except RuntimeWarning: + pass + item_thumb.delete_action.triggered.connect( + lambda checked=False, f=filepath: self.delete_files_callback(f) + ) item_thumb.set_item_id(entry.id) item_thumb.assign_archived(entry.has_tag(self.lib, TAG_ARCHIVED)) @@ -1499,7 +1579,9 @@ class QtDriver(QObject): else collation.e_ids_and_pages[0][0] ) cover_e = self.lib.get_entry(cover_id) - filepath = self.lib.library_dir / cover_e.path / cover_e.filename + filepath: Path = ( + self.lib.library_dir / cover_e.path / cover_e.filename + ) item_thumb.set_count(str(len(collation.e_ids_and_pages))) item_thumb.update_clickable( clickable=( diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index 477a7cd3..8a90dcc8 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -132,6 +132,7 @@ class FieldContainer(QWidget): def set_copy_callback(self, callback: Optional[MethodType]): if self.copy_button.is_connected: self.copy_button.clicked.disconnect() + self.copy_button.is_connected = False self.copy_callback = callback self.copy_button.clicked.connect(callback) @@ -141,6 +142,7 @@ class FieldContainer(QWidget): def set_edit_callback(self, callback: Optional[MethodType]): if self.edit_button.is_connected: self.edit_button.clicked.disconnect() + self.edit_button.is_connected = False self.edit_callback = callback self.edit_button.clicked.connect(callback) @@ -150,10 +152,12 @@ class FieldContainer(QWidget): def set_remove_callback(self, callback: Optional[Callable]): if self.remove_button.is_connected: self.remove_button.clicked.disconnect() + self.remove_button.is_connected = False self.remove_callback = callback self.remove_button.clicked.connect(callback) - self.remove_button.is_connected = True + if callback is not None: + self.remove_button.is_connected = True def set_inner_widget(self, widget: "FieldWidget"): # widget.setStyleSheet('background-color:green;') diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 8d0636db..5abe6032 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -8,6 +8,7 @@ import time import typing from pathlib import Path from typing import Optional +import platform from PIL import Image, ImageQt from PySide6.QtCore import Qt, QSize, QEvent, QMimeData, QUrl @@ -30,7 +31,6 @@ from src.core.constants import ( from src.core.media_types import MediaCategories, MediaType from src.qt.flowlayout import FlowWidget from src.qt.helpers.file_opener import FileOpenerHelper -from src.qt.helpers.file_deleter import FileDeleterHelper from src.qt.widgets.thumb_renderer import ThumbRenderer from src.qt.widgets.thumb_button import ThumbButton @@ -194,16 +194,19 @@ class ItemThumb(FlowWidget): self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.opener = FileOpenerHelper("") - self.deleter = FileDeleterHelper("") open_file_action = QAction("Open file", self) open_file_action.triggered.connect(self.opener.open_file) open_explorer_action = QAction("Open file in explorer", self) open_explorer_action.triggered.connect(self.opener.open_explorer) - delete_action = QAction("Delete", self) - delete_action.triggered.connect(self.deleter.delete_file) + + trash_term: str = "Trash" + if platform.system() == "Windows": + trash_term = "Recycle Bin" + self.delete_action = QAction(f"Send file to {trash_term}", self) + self.thumb_button.addAction(open_file_action) self.thumb_button.addAction(open_explorer_action) - self.thumb_button.addAction(delete_action) + self.thumb_button.addAction(self.delete_action) # Static Badges ======================================================== @@ -445,8 +448,6 @@ class ItemThumb(FlowWidget): entry = self.lib.get_entry(self.item_id) filepath = self.lib.library_dir / entry.path / entry.filename self.opener.set_filepath(filepath) - self.deleter.set_filepath(filepath) - self.deleter.set_delete_callback(self._on_delete) def assign_favorite(self, value: bool): # Switching mode to None to bypass mode-specific operations when the @@ -547,9 +548,3 @@ class ItemThumb(FlowWidget): mimedata.setUrls(paths) drag.setMimeData(mimedata) drag.exec(Qt.DropAction.CopyAction) - - def _on_delete(self): - entry = self.lib.get_entry(self.item_id) - self.lib.remove_entry(self.item_id) - self.panel.driver.purge_item_from_navigation(entry.type, self.item_id) - self.panel.driver.filter_items() diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 45872471..3b94f827 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -4,6 +4,7 @@ import logging from pathlib import Path +import platform import time import typing from datetime import datetime as dt @@ -11,7 +12,7 @@ import cv2 import rawpy from PIL import Image, UnidentifiedImageError, ImageFont from PIL.Image import DecompressionBombError -from PySide6.QtCore import QModelIndex, Signal, Qt, QSize +from PySide6.QtCore import QModelIndex, Signal, Qt, QSize, QByteArray, QBuffer from PySide6.QtGui import QGuiApplication, QResizeEvent, QAction, QMovie from PySide6.QtWidgets import ( QWidget, @@ -45,6 +46,7 @@ from src.qt.widgets.text_line_edit import EditTextLine from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper from src.qt.widgets.video_player import VideoPlayer from src.qt.helpers.file_tester import is_readable_video +from src.qt.resource_manager import ResourceManager # Only import for type checking/autocompletion, will not be imported at runtime. @@ -98,6 +100,10 @@ class PreviewPanel(QWidget): self.open_file_action = QAction("Open file", self) self.open_explorer_action = QAction("Open file in explorer", self) + self.trash_term: str = "Trash" + if platform.system() == "Windows": + self.trash_term = "Recycle Bin" + self.delete_action = QAction(f"Send file to {self.trash_term}", self) self.preview_img = QPushButtonWrapper() self.preview_img.setMinimumSize(*self.img_button_size) @@ -105,6 +111,7 @@ class PreviewPanel(QWidget): self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.preview_img.addAction(self.open_file_action) self.preview_img.addAction(self.open_explorer_action) + self.preview_img.addAction(self.delete_action) self.preview_gif = QLabel() self.preview_gif.setMinimumSize(*self.img_button_size) @@ -112,10 +119,13 @@ class PreviewPanel(QWidget): self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor) self.preview_gif.addAction(self.open_file_action) self.preview_gif.addAction(self.open_explorer_action) + self.preview_gif.addAction(self.delete_action) self.preview_gif.hide() + self.gif_buffer: QBuffer = QBuffer() self.preview_vid = VideoPlayer(driver) self.preview_vid.hide() + self.preview_vid.addAction(self.delete_action) self.thumb_renderer = ThumbRenderer() self.thumb_renderer.updated.connect( lambda ts, i, s: (self.preview_img.setIcon(i)) @@ -490,6 +500,14 @@ class PreviewPanel(QWidget): ) if self.preview_img.is_connected: self.preview_img.clicked.disconnect() + self.preview_img.is_connected = False + + try: + self.delete_action.triggered.disconnect() + except RuntimeWarning: + pass + self.delete_action.setEnabled(False) + for i, c in enumerate(self.containers): c.setHidden(True) self.preview_img.show() @@ -529,6 +547,17 @@ class PreviewPanel(QWidget): ) self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) + try: + self.delete_action.triggered.disconnect() + except RuntimeError: + pass + self.delete_action.setText(f"Send file to {self.trash_term}") + self.delete_action.triggered.connect( + lambda checked=False, + f=filepath: self.driver.delete_files_callback(f) + ) + self.delete_action.setEnabled(True) + self.opener = FileOpenerHelper(filepath) self.open_file_action.triggered.connect(self.opener.open_file) self.open_explorer_action.triggered.connect( @@ -539,16 +568,24 @@ class PreviewPanel(QWidget): ext: str = filepath.suffix.lower() try: if filepath.suffix.lower() in [".gif"]: - movie = QMovie(str(filepath)) + with open(filepath, mode="rb") as f: + if self.preview_gif.movie(): + self.preview_gif.movie().stop() + self.gif_buffer.close() + + ba = f.read() + self.gif_buffer.setData(ba) + movie = QMovie(self.gif_buffer, QByteArray()) + self.preview_gif.setMovie(movie) + movie.start() + image = Image.open(str(filepath)) - self.preview_gif.setMovie(movie) self.resizeEvent( QResizeEvent( QSize(image.width, image.height), QSize(image.width, image.height), ) ) - movie.start() self.preview_img.hide() self.preview_vid.hide() self.preview_gif.show() @@ -660,6 +697,7 @@ class PreviewPanel(QWidget): # TODO: Implement a clickable label to use for the GIF preview. if self.preview_img.is_connected: self.preview_img.clicked.disconnect() + self.preview_img.is_connected = False self.preview_img.clicked.connect( lambda checked=False, filepath=filepath: open_file(filepath) ) @@ -701,6 +739,16 @@ class PreviewPanel(QWidget): ) self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) + try: + self.delete_action.triggered.disconnect() + except RuntimeError: + pass + self.delete_action.setText(f"Send files to {self.trash_term}") + self.delete_action.triggered.connect( + lambda checked=False, f=None: self.driver.delete_files_callback(f) + ) + self.delete_action.setEnabled(True) + ratio: float = self.devicePixelRatio() self.thumb_renderer.render( time.time(), @@ -1140,3 +1188,26 @@ class PreviewPanel(QWidget): # logging.info(result) if result == 3: callback() + + def stop_file_use(self): + """Stops the use of the currently previewed file. Used to release file permissions.""" + logging.info("[PreviewPanel] Stopping file use in video playback...") + # This swaps the video out for a placeholder so the previous video's file + # is no longer in use by this object. + self.preview_vid.play(ResourceManager.get_path("placeholder_mp4"), QSize(8, 8)) + self.preview_vid.hide() + + # NOTE: I'm keeping this here until #357 is merged in the case it still needs to be used. + # logging.info("[PreviewPanel] Stopping file use for animated image playback...") + # logging.info(self.preview_gif.movie()) + # if self.preview_gif.movie(): + # self.preview_gif.movie().stop() + # with open(ResourceManager.get_path("placeholder_gif"), mode="rb") as f: + # ba = f.read() + # self.gif_buffer.setData(ba) + # movie = QMovie(self.gif_buffer, QByteArray()) + # self.preview_gif.setMovie(movie) + # movie.start() + + # self.preview_gif.hide() + # logging.info(self.preview_gif.movie())