mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-07-21 13:06:26 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b66695ab3 | |||
| 6aa0cf74f9 | |||
| 49b450c3a4 | |||
| a1dfa62e4a |
@@ -7,6 +7,7 @@
|
||||
# pyright: reportDeprecated=false
|
||||
|
||||
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
@@ -93,6 +94,7 @@ from tagstudio.core.library.alchemy.fields import (
|
||||
TextFieldTemplate,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
|
||||
from tagstudio.core.library.alchemy.metadata import FileMetadata
|
||||
from tagstudio.core.library.alchemy.models import (
|
||||
Entry,
|
||||
Folder,
|
||||
@@ -105,6 +107,7 @@ from tagstudio.core.library.alchemy.models import (
|
||||
from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder
|
||||
from tagstudio.core.library.ignore import migrate_ext_list
|
||||
from tagstudio.core.library.json.library import Library as JsonLibrary
|
||||
from tagstudio.core.utils.stat import get_date_created, get_date_modified
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
@@ -596,7 +599,7 @@ class Library:
|
||||
session.commit()
|
||||
logger.info(f"[Library][Migration][{v}] Completed DB Migration")
|
||||
|
||||
assert loaded_db_version == DB_VERSION, (
|
||||
assert loaded_db_version >= DB_VERSION, (
|
||||
"Ran all migrations, but the DB is still not on the newest version"
|
||||
)
|
||||
logger.info(f"[Library] Library migrated to DB version {DB_VERSION}")
|
||||
@@ -924,7 +927,11 @@ class Library:
|
||||
return entry
|
||||
|
||||
def get_entry_full(
|
||||
self, entry_id: int, with_fields: bool = True, with_tags: bool = True
|
||||
self,
|
||||
entry_id: int,
|
||||
with_fields: bool = True,
|
||||
with_tags: bool = True,
|
||||
with_metadata: bool = True,
|
||||
) -> Entry | None:
|
||||
"""Load entry and join with all joins and all tags."""
|
||||
# NOTE: TODO: Currently this method makes multiple separate queries to the db and combines
|
||||
@@ -954,6 +961,11 @@ class Library:
|
||||
)
|
||||
)
|
||||
|
||||
if with_metadata:
|
||||
entry_stmt = entry_stmt.outerjoin(Entry.file_metadata).options(
|
||||
selectinload(Entry.file_metadata),
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
entry = session.scalar(entry_stmt)
|
||||
if with_tags:
|
||||
@@ -1147,10 +1159,79 @@ class Library:
|
||||
session.query(Entry).where(Entry.id.in_(sub_list)).delete()
|
||||
session.commit()
|
||||
|
||||
def has_entry_with_path(self, path: Path) -> bool:
|
||||
"""Check if an entry with this path is in the library."""
|
||||
def get_entry_id_from_path(self, path: Path) -> int:
|
||||
"""Attempt to return an Entry ID given a filepath, else return -1."""
|
||||
with Session(self.engine) as session:
|
||||
return session.query(exists().where(Entry.path == path)).scalar()
|
||||
return session.scalar(select(Entry.id).where(Entry.path == path).limit(1)) or -1
|
||||
|
||||
# def update_entry_file_metadata(
|
||||
# self, entry_id: int, date_created: datetime | None, date_modified: datetime | None
|
||||
# ):
|
||||
# with Session(self.engine) as session:
|
||||
# stmt = update(FileMetadata).where(
|
||||
# and_(
|
||||
# FileMetadata.entry_id == entry_id,
|
||||
# )
|
||||
# )
|
||||
# if date_created:
|
||||
# stmt = stmt.values(date_created=date_created)
|
||||
# if date_modified:
|
||||
# stmt = stmt.values(date_modified=date_modified)
|
||||
|
||||
# session.execute(stmt)
|
||||
# session.commit()
|
||||
|
||||
def refresh_file_entry_stats(self, entry_id: int, path: Path | None):
|
||||
"""Updates a file entry's associated stat() data."""
|
||||
needs_update = False
|
||||
|
||||
entry = self.get_entry_full(
|
||||
entry_id, with_fields=False, with_tags=False, with_metadata=True
|
||||
)
|
||||
if not entry:
|
||||
return
|
||||
|
||||
if not path:
|
||||
full_path = unwrap(self.library_dir) / entry.path
|
||||
else:
|
||||
full_path = unwrap(self.library_dir) / path
|
||||
|
||||
logger.info(full_path)
|
||||
|
||||
file_date_created = get_date_created(full_path)
|
||||
file_date_modified = get_date_modified(full_path)
|
||||
|
||||
# Log info
|
||||
if entry.date_created != file_date_created:
|
||||
logger.info(f"Difference in date_created!: {entry.date_created}/{file_date_created}")
|
||||
needs_update = True
|
||||
else:
|
||||
logger.info("No difference in date_created.")
|
||||
|
||||
if entry.date_modified != file_date_modified:
|
||||
logger.info(f"Difference in date_modified!: {entry.date_modified}/{file_date_modified}")
|
||||
needs_update = True
|
||||
else:
|
||||
logger.info("No difference in date_modified")
|
||||
|
||||
if needs_update:
|
||||
return
|
||||
else:
|
||||
logger.info(f"Updating entry file_metadata for {full_path}")
|
||||
|
||||
with Session(self.engine) as session:
|
||||
stmt = update(FileMetadata).where(
|
||||
and_(
|
||||
FileMetadata.entry_id == entry_id,
|
||||
)
|
||||
)
|
||||
if file_date_created:
|
||||
stmt = stmt.values(date_created=file_date_created)
|
||||
if file_date_modified:
|
||||
stmt = stmt.values(date_modified=file_date_modified)
|
||||
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
|
||||
def get_paths(self, limit: int = -1) -> list[str]:
|
||||
path_strings: list[str] = []
|
||||
@@ -1468,7 +1549,7 @@ class Library:
|
||||
|
||||
Returns True if the action succeeded and False if the path already exists.
|
||||
"""
|
||||
if self.has_entry_with_path(path):
|
||||
if self.get_entry_id_from_path(path) >= 0:
|
||||
return False
|
||||
if isinstance(entry_id, Entry):
|
||||
entry_id = entry_id.id
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime as dt
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, null
|
||||
from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
|
||||
|
||||
|
||||
from tagstudio.core.library.alchemy.db import Base, PathType
|
||||
|
||||
from tagstudio.core.library.alchemy.joins import TagParent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
|
||||
|
||||
class FileMetadata(Base):
|
||||
"""Table that includes file data and metadata obtained from os.stat() for entries."""
|
||||
|
||||
__tablename__ = "file_metadata"
|
||||
|
||||
entry_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("entries.id"), primary_key=True, nullable=False
|
||||
)
|
||||
|
||||
# NOTE: These dates are stored as floats because that's their natural form from os.stat()
|
||||
# and comparisons are quicker without having to convert to/from datetime objects.
|
||||
date_created: Mapped[float | None]
|
||||
date_modified: Mapped[float | None]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry_id: int,
|
||||
date_created: float | None = None,
|
||||
date_modified: float | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.entry_id = entry_id
|
||||
|
||||
# # Path data
|
||||
# self.path = path
|
||||
# self.filename = path.name
|
||||
# self.suffix = path.suffix.lstrip(".").lower()
|
||||
|
||||
# File metadata
|
||||
self.date_created = date_created # st_birthtime on Windows and Mac, st_ctime on Linux
|
||||
self.date_modified = date_modified # st_mtime
|
||||
|
||||
|
||||
class ExifMetadata(Base):
|
||||
"""Contains Exif metadata for a entries."""
|
||||
|
||||
__tablename__ = "exif_metadata"
|
||||
|
||||
entry_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("entries.id"), primary_key=True, nullable=False
|
||||
)
|
||||
date_taken: Mapped[dt | None]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry_id: int,
|
||||
date_taken: dt | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.entry_id = entry_id
|
||||
self.date_taken = date_taken # Exif.Image.DateTime
|
||||
|
||||
|
||||
class DimensionMetadata(Base):
|
||||
"""Contains dimension metadata for entries (e.g. image and video files)."""
|
||||
|
||||
__tablename__ = "dimension_metadata"
|
||||
|
||||
entry_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("entries.id"), primary_key=True, nullable=False
|
||||
)
|
||||
width: Mapped[int] = mapped_column(nullable=False)
|
||||
height: Mapped[int] = mapped_column(nullable=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry_id: int,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.entry_id = entry_id
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
|
||||
class DurationMetadata(Base):
|
||||
"""Contains duration metadata for entries (e.g. audio and video files)."""
|
||||
|
||||
__tablename__ = "duration_metadata"
|
||||
|
||||
entry_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("entries.id"), primary_key=True, nullable=False
|
||||
)
|
||||
duration: Mapped[float] = mapped_column(nullable=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry_id: int,
|
||||
duration: float,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.entry_id = entry_id
|
||||
self.duration = duration
|
||||
@@ -17,6 +17,8 @@ from tagstudio.core.library.alchemy.fields import (
|
||||
TextField,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.joins import TagParent
|
||||
from tagstudio.core.library.alchemy.metadata import FileMetadata
|
||||
from tagstudio.core.utils.stat import get_date_created, get_date_modified
|
||||
|
||||
|
||||
class Namespace(Base):
|
||||
@@ -182,6 +184,7 @@ class Tag(Base):
|
||||
return self.name >= other.name
|
||||
|
||||
|
||||
# TODO: Use or replace these with an actual multi-root implementation
|
||||
class Folder(Base):
|
||||
__tablename__ = "folders"
|
||||
|
||||
@@ -196,15 +199,16 @@ class Entry(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# TODO: Use or replace these with an actual multi-root implementation
|
||||
folder_id: Mapped[int] = mapped_column(ForeignKey("folders.id"))
|
||||
folder: Mapped[Folder] = relationship("Folder")
|
||||
|
||||
# TODO: Possibly move to FileMetadata table if Entry is split into Entry/FileEntry (see #588)
|
||||
path: Mapped[Path] = mapped_column(PathType, unique=True)
|
||||
filename: Mapped[str] = mapped_column()
|
||||
suffix: Mapped[str] = mapped_column()
|
||||
date_created: Mapped[dt | None]
|
||||
date_modified: Mapped[dt | None]
|
||||
date_added: Mapped[dt | None]
|
||||
|
||||
date_added: Mapped[dt | None] # The date this entry was added to the library
|
||||
|
||||
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
|
||||
|
||||
@@ -217,6 +221,11 @@ class Entry(Base):
|
||||
cascade="all, delete",
|
||||
)
|
||||
|
||||
file_metadata: Mapped["FileMetadata"] = relationship(
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
@property
|
||||
def fields(self) -> list[BaseField]:
|
||||
fields: list[BaseField] = []
|
||||
@@ -232,30 +241,35 @@ class Entry(Base):
|
||||
def is_archived(self) -> bool:
|
||||
return any(tag.id == TAG_ARCHIVED for tag in self.tags)
|
||||
|
||||
@property
|
||||
def date_created(self) -> float | None:
|
||||
return self.file_metadata.date_created if self.file_metadata else None
|
||||
|
||||
@property
|
||||
def date_modified(self) -> float | None:
|
||||
return self.file_metadata.date_modified if self.file_metadata else None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
folder: Folder,
|
||||
fields: list[BaseField],
|
||||
id: int | None = None,
|
||||
date_created: dt | None = None,
|
||||
date_modified: dt | None = None,
|
||||
date_added: dt | None = None,
|
||||
# date_created: float | None = None,
|
||||
# date_modified: float | None = None,
|
||||
path_for_file_metadata: Path | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.path = path
|
||||
self.folder = folder
|
||||
|
||||
self.id = id # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
self.folder = folder # NOTE: Currently unused
|
||||
self.path = path
|
||||
self.filename = path.name
|
||||
self.suffix = path.suffix.lstrip(".").lower()
|
||||
|
||||
# The date the file associated with this entry was created.
|
||||
# st_birthtime on Windows and Mac, st_ctime on Linux.
|
||||
self.date_created = date_created
|
||||
# The date the file associated with this entry was last modified: st_mtime.
|
||||
self.date_modified = date_modified
|
||||
# The date this entry was added to the library.
|
||||
self.date_added = date_added
|
||||
self.date_added = date_added # The date this entry was added to the library
|
||||
|
||||
for field in fields:
|
||||
if isinstance(field, TextField):
|
||||
@@ -265,6 +279,13 @@ class Entry(Base):
|
||||
else:
|
||||
raise ValueError(f"Invalid field type: {field}")
|
||||
|
||||
if path_for_file_metadata:
|
||||
self.file_metadata = FileMetadata(
|
||||
entry_id=self.id,
|
||||
date_created=get_date_created(path_for_file_metadata),
|
||||
date_modified=get_date_modified(path_for_file_metadata),
|
||||
)
|
||||
|
||||
def has_tag(self, tag: Tag) -> bool:
|
||||
return tag in self.tags
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime as dt
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
import platform
|
||||
|
||||
import structlog
|
||||
from wcmatch import pathlib
|
||||
@@ -38,12 +39,14 @@ class RefreshTracker:
|
||||
while index < len(self.files_not_in_library):
|
||||
yield index
|
||||
end = min(len(self.files_not_in_library), index + batch_size)
|
||||
lib_dir = unwrap(self.library.library_dir)
|
||||
entries = [
|
||||
Entry(
|
||||
path=entry_path,
|
||||
folder=unwrap(self.library.folder),
|
||||
fields=[],
|
||||
date_added=dt.now(),
|
||||
path_for_file_metadata=(lib_dir / entry_path),
|
||||
)
|
||||
for entry_path in self.files_not_in_library[index:end]
|
||||
]
|
||||
@@ -144,8 +147,11 @@ class RefreshTracker:
|
||||
dir_file_count += 1
|
||||
self.library.included_files.add(f)
|
||||
|
||||
if not self.library.has_entry_with_path(f):
|
||||
entry_id = self.library.get_entry_id_from_path(f)
|
||||
if entry_id < 0:
|
||||
self.files_not_in_library.append(f)
|
||||
else:
|
||||
self.library.refresh_file_entry_stats(entry_id, path=f)
|
||||
|
||||
end_time_total = time()
|
||||
yield dir_file_count
|
||||
@@ -189,8 +195,12 @@ class RefreshTracker:
|
||||
|
||||
relative_path = f.relative_to(library_dir)
|
||||
|
||||
if not self.library.has_entry_with_path(relative_path):
|
||||
entry_id = self.library.get_entry_id_from_path(relative_path)
|
||||
if entry_id < 0:
|
||||
self.files_not_in_library.append(relative_path)
|
||||
else:
|
||||
self.library.refresh_file_entry_stats(entry_id, path=relative_path)
|
||||
|
||||
except ValueError:
|
||||
logger.info("[Refresh]: ValueError when refreshing directory with wcmatch!")
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_date_modified(path: Path) -> float:
|
||||
return path.stat().st_mtime
|
||||
|
||||
|
||||
def get_date_created(path: Path) -> float:
|
||||
if platform.system() in {"Windows", "Darwin"}:
|
||||
return path.stat().st_birthtime
|
||||
else:
|
||||
return path.stat().st_ctime
|
||||
@@ -49,3 +49,14 @@ def is_version_outdated(current: str, latest: str) -> bool:
|
||||
return vcur.patch < vlat.patch
|
||||
else:
|
||||
return vcur.prerelease is not None or vcur.build is not None
|
||||
|
||||
|
||||
def format_duration(duration: int | float) -> str:
|
||||
"""Format a duration in seconds as M:SS or H:MM:SS."""
|
||||
try:
|
||||
seconds = int(float(duration))
|
||||
hours, seconds = divmod(seconds, 3600)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
return f"{hours}:{minutes:02}:{seconds:02}" if hours else f"{minutes}:{seconds:02}"
|
||||
except (OverflowError, ValueError):
|
||||
return "-:--"
|
||||
|
||||
@@ -32,8 +32,6 @@ Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
class PreviewThumb(PreviewThumbView):
|
||||
__current_file: Path
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver"):
|
||||
super().__init__(library, driver)
|
||||
|
||||
@@ -114,7 +112,7 @@ class PreviewThumb(PreviewThumbView):
|
||||
|
||||
def display_file(self, filepath: Path) -> FileAttributeData:
|
||||
"""Render a single file preview."""
|
||||
self.__current_file = filepath
|
||||
self._current_file = filepath
|
||||
|
||||
ext = filepath.suffix.lower()
|
||||
|
||||
@@ -150,21 +148,26 @@ class PreviewThumb(PreviewThumbView):
|
||||
|
||||
@override
|
||||
def _open_file_action_callback(self):
|
||||
open_file(
|
||||
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
|
||||
)
|
||||
if self._current_file:
|
||||
open_file(
|
||||
self._current_file,
|
||||
windows_start_command=self.__driver.settings.windows_start_command,
|
||||
)
|
||||
|
||||
@override
|
||||
def _open_explorer_action_callback(self):
|
||||
open_file(self.__current_file, file_manager=True)
|
||||
if self._current_file:
|
||||
open_file(self._current_file, file_manager=True)
|
||||
|
||||
@override
|
||||
def _delete_action_callback(self):
|
||||
if bool(self.__current_file):
|
||||
self.__driver.delete_files_callback(self.__current_file)
|
||||
if self._current_file:
|
||||
self.__driver.delete_files_callback(self._current_file)
|
||||
|
||||
@override
|
||||
def _button_wrapper_callback(self):
|
||||
open_file(
|
||||
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
|
||||
)
|
||||
if self._current_file:
|
||||
open_file(
|
||||
self._current_file,
|
||||
windows_start_command=self.__driver.settings.windows_start_command,
|
||||
)
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import structlog
|
||||
from PIL import Image, ImageChops, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.helpers.file_tester import is_readable_video
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class CollageIconRenderer(QObject):
|
||||
rendered = Signal(Image.Image)
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, library: Library):
|
||||
QObject.__init__(self)
|
||||
self.lib = library
|
||||
|
||||
def render(
|
||||
self,
|
||||
entry_id: int,
|
||||
size: tuple[int, int],
|
||||
data_tint_mode: bool,
|
||||
data_only_mode: bool,
|
||||
keep_aspect: bool,
|
||||
):
|
||||
entry = unwrap(self.lib.get_entry(entry_id))
|
||||
filepath = unwrap(self.lib.library_dir) / entry.path
|
||||
color: str = ""
|
||||
|
||||
try:
|
||||
if data_tint_mode or data_only_mode:
|
||||
color = "#28bb48" if entry.tags else "#e22c3c"
|
||||
|
||||
if data_only_mode:
|
||||
pic = Image.new("RGB", size, color)
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
if not data_only_mode:
|
||||
logger.info(
|
||||
"Combining icons",
|
||||
entry=entry,
|
||||
color=self.get_file_color(filepath.suffix.lower()),
|
||||
)
|
||||
|
||||
ext: str = filepath.suffix.lower()
|
||||
if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES):
|
||||
try:
|
||||
with Image.open(filepath) as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
pic = pic.resize(size)
|
||||
if data_tint_mode and color:
|
||||
pic = pic.convert(mode="RGB")
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
self.rendered.emit(pic)
|
||||
except DecompressionBombError as e:
|
||||
logger.info(f"[ERROR] One of the images was too big ({e})")
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.VIDEO_TYPES
|
||||
) and is_readable_video(filepath):
|
||||
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
success, frame = video.read()
|
||||
# NOTE: Depending on the video format, compression, and
|
||||
# frame count, seeking halfway does not work and the thumb
|
||||
# must be pulled from the earliest available frame.
|
||||
max_frame_seek: int = 10
|
||||
for i in range(
|
||||
0,
|
||||
min(
|
||||
max_frame_seek,
|
||||
math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT)),
|
||||
),
|
||||
):
|
||||
success, frame = video.read()
|
||||
if not success:
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, i)
|
||||
else:
|
||||
break
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
with Image.fromarray(frame, mode="RGB") as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
pic = pic.resize(size)
|
||||
if data_tint_mode and color:
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
self.rendered.emit(pic)
|
||||
except (UnidentifiedImageError, FileNotFoundError):
|
||||
logger.error("Couldn't read entry", entry=entry.path)
|
||||
with Image.open(
|
||||
str(Path(__file__).parents[1] / "resources/qt/images/thumb_broken_512.png")
|
||||
) as pic:
|
||||
pic.thumbnail(size)
|
||||
if data_tint_mode and color:
|
||||
pic = pic.convert(mode="RGB")
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Collage operation cancelled.")
|
||||
except Exception:
|
||||
logger.exception("render failed", entry=entry.path)
|
||||
|
||||
self.done.emit()
|
||||
|
||||
def get_file_color(self, ext: str):
|
||||
if ext.lower() == "gif":
|
||||
return "\033[93m"
|
||||
if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES):
|
||||
return "\033[37m"
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.VIDEO_TYPES):
|
||||
return "\033[96m"
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.PLAINTEXT_TYPES):
|
||||
return "\033[92m"
|
||||
else:
|
||||
return "\033[97m"
|
||||
@@ -7,7 +7,6 @@ import platform
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime as dt
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
@@ -20,6 +19,7 @@ from tagstudio.core.enums import ShowFilepathOption
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.ignore import Ignore
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.utils.str_formatting import format_duration
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
@@ -224,15 +224,7 @@ class FileAttributes(QWidget):
|
||||
|
||||
if stats.duration is not None:
|
||||
stats_label_text = add_newline(stats_label_text)
|
||||
try:
|
||||
dur_str = str(timedelta(seconds=float(stats.duration)))[:-7]
|
||||
if dur_str.startswith("0:"):
|
||||
dur_str = dur_str[2:]
|
||||
if dur_str.startswith("0"):
|
||||
dur_str = dur_str[1:]
|
||||
except OverflowError:
|
||||
dur_str = "-:--"
|
||||
stats_label_text += f"{dur_str}"
|
||||
stats_label_text += format_duration(stats.duration)
|
||||
|
||||
if font_family:
|
||||
stats_label_text = add_newline(stats_label_text)
|
||||
|
||||
@@ -51,6 +51,7 @@ class PreviewPanelView(QWidget):
|
||||
self._containers = FieldContainers(
|
||||
self.lib, driver
|
||||
) # TODO: this should be name mangled, but is still needed on the controller side atm
|
||||
self.__current_stats: FileAttributeData | None = None
|
||||
|
||||
preview_section = QWidget()
|
||||
preview_layout = QVBoxLayout(preview_section)
|
||||
@@ -132,6 +133,7 @@ class PreviewPanelView(QWidget):
|
||||
def __connect_callbacks(self) -> None:
|
||||
self.__add_field_button.clicked.connect(self._add_field_button_callback)
|
||||
self.__add_tag_button.clicked.connect(self._add_tag_button_callback)
|
||||
self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback)
|
||||
|
||||
def _add_field_button_callback(self) -> None:
|
||||
raise NotImplementedError()
|
||||
@@ -139,6 +141,25 @@ class PreviewPanelView(QWidget):
|
||||
def _add_tag_button_callback(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
|
||||
if len(self._selected) != 1:
|
||||
return
|
||||
|
||||
if filepath != self._thumb.current_file:
|
||||
return
|
||||
|
||||
if self.__current_stats is None:
|
||||
self.__current_stats = FileAttributeData()
|
||||
|
||||
if stats.width is not None:
|
||||
self.__current_stats.width = stats.width
|
||||
if stats.height is not None:
|
||||
self.__current_stats.height = stats.height
|
||||
if stats.duration is not None:
|
||||
self.__current_stats.duration = stats.duration
|
||||
|
||||
self._file_attrs.update_stats(filepath, self.__current_stats)
|
||||
|
||||
def _set_selection_callback(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -155,6 +176,7 @@ class PreviewPanelView(QWidget):
|
||||
# No Items Selected
|
||||
if len(selected) == 0:
|
||||
self._thumb.hide_preview()
|
||||
self.__current_stats = None
|
||||
self._file_attrs.update_stats()
|
||||
self._file_attrs.update_date_label()
|
||||
self._containers.hide_containers()
|
||||
@@ -167,9 +189,12 @@ class PreviewPanelView(QWidget):
|
||||
entry: Entry = unwrap(self.lib.get_entry(entry_id))
|
||||
|
||||
filepath: Path = unwrap(self.lib.library_dir) / entry.path
|
||||
if filepath != self._thumb.current_file:
|
||||
self.__current_stats = None
|
||||
|
||||
if update_preview:
|
||||
stats: FileAttributeData = self._thumb.display_file(filepath)
|
||||
self.__current_stats = stats
|
||||
self._file_attrs.update_stats(filepath, stats)
|
||||
self._file_attrs.update_date_label(filepath)
|
||||
self._containers.update_from_entry(entry_id)
|
||||
@@ -182,6 +207,7 @@ class PreviewPanelView(QWidget):
|
||||
elif len(selected) > 1:
|
||||
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
|
||||
self._thumb.hide_preview() # TODO: Render mixed selection
|
||||
self.__current_stats = None
|
||||
self._file_attrs.update_multi_selection(len(selected))
|
||||
self._file_attrs.update_date_label()
|
||||
self._containers.hide_containers() # TODO: Allow for mixed editing
|
||||
|
||||
@@ -34,11 +34,13 @@ class PreviewThumbView(QWidget):
|
||||
"""The Preview Panel Widget."""
|
||||
|
||||
check_ffmpeg = Signal(bool)
|
||||
stats_updated = Signal(Path, FileAttributeData)
|
||||
|
||||
__img_button_size: tuple[int, int]
|
||||
__image_ratio: float
|
||||
|
||||
__filepath: Path | None
|
||||
_current_file: Path | None
|
||||
__should_render_on_resize: bool
|
||||
__rendered_res: tuple[int, int]
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
@@ -47,6 +49,8 @@ class PreviewThumbView(QWidget):
|
||||
self.__img_button_size = (266, 266)
|
||||
self.__image_ratio = 1.0
|
||||
|
||||
self.__should_render_on_resize = False
|
||||
|
||||
self.__image_layout = QStackedLayout(self)
|
||||
self.__image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.__image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
||||
@@ -92,6 +96,10 @@ class PreviewThumbView(QWidget):
|
||||
self.__media_player.addAction(open_file_action)
|
||||
self.__media_player.addAction(open_explorer_action)
|
||||
self.__media_player.addAction(delete_action)
|
||||
# QMediaPlayer loads duration asynchronously after setSource().
|
||||
self.__media_player.player.durationChanged.connect(
|
||||
self.__media_player_duration_changed_callback
|
||||
)
|
||||
|
||||
# Need to watch for this to resize the player appropriately.
|
||||
self.__media_player.player.hasVideoChanged.connect(
|
||||
@@ -128,6 +136,16 @@ class PreviewThumbView(QWidget):
|
||||
def __media_player_video_changed_callback(self, video: bool) -> None:
|
||||
self.__update_image_size((self.size().width(), self.size().height()))
|
||||
|
||||
def __media_player_duration_changed_callback(self, duration_ms: int) -> None:
|
||||
filepath = self.__media_player.filepath
|
||||
if filepath is None or duration_ms <= 0:
|
||||
return
|
||||
|
||||
self.stats_updated.emit(
|
||||
filepath,
|
||||
FileAttributeData(duration=duration_ms // 1000),
|
||||
)
|
||||
|
||||
def __thumb_renderer_updated_callback(
|
||||
self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path
|
||||
) -> None:
|
||||
@@ -207,7 +225,8 @@ class PreviewThumbView(QWidget):
|
||||
self.__preview_gif.hide()
|
||||
|
||||
def __render_thumb(self, filepath: Path) -> None:
|
||||
self.__filepath = filepath
|
||||
self.__should_render_on_resize = True
|
||||
|
||||
self.__rendered_res = (
|
||||
math.ceil(self.__img_button_size[0] * THUMB_SIZE_FACTOR),
|
||||
math.ceil(self.__img_button_size[1] * THUMB_SIZE_FACTOR),
|
||||
@@ -221,17 +240,16 @@ class PreviewThumbView(QWidget):
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
|
||||
def __update_media_player(self, filepath: Path) -> int:
|
||||
"""Display either audio or video.
|
||||
|
||||
Returns the duration of the audio / video.
|
||||
"""
|
||||
def __update_media_player(self, filepath: Path) -> None:
|
||||
"""Display either audio or video."""
|
||||
self.__media_player.play(filepath)
|
||||
return self.__media_player.player.duration() * 1000
|
||||
|
||||
def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData:
|
||||
self.__should_render_on_resize = False
|
||||
|
||||
self.__switch_preview(MediaType.VIDEO)
|
||||
stats = FileAttributeData(duration=self.__update_media_player(filepath))
|
||||
self.__update_media_player(filepath)
|
||||
stats = FileAttributeData()
|
||||
|
||||
if size is not None:
|
||||
stats.width = size.width()
|
||||
@@ -250,10 +268,13 @@ class PreviewThumbView(QWidget):
|
||||
def _display_audio(self, filepath: Path) -> FileAttributeData:
|
||||
self.__switch_preview(MediaType.AUDIO)
|
||||
self.__render_thumb(filepath)
|
||||
return FileAttributeData(duration=self.__update_media_player(filepath))
|
||||
self.__update_media_player(filepath)
|
||||
return FileAttributeData()
|
||||
|
||||
def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None:
|
||||
"""Update the animated image preview from a filepath."""
|
||||
self.__should_render_on_resize = False
|
||||
|
||||
stats = FileAttributeData()
|
||||
|
||||
# Ensure that any movie and buffer from previous animations are cleared.
|
||||
@@ -296,17 +317,26 @@ class PreviewThumbView(QWidget):
|
||||
def hide_preview(self) -> None:
|
||||
"""Completely hide the file preview."""
|
||||
self.__switch_preview(None)
|
||||
self.__filepath = None
|
||||
self._current_file = None
|
||||
self.__should_render_on_resize = False
|
||||
|
||||
@override
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
self.__update_image_size((self.size().width(), self.size().height()))
|
||||
|
||||
if self.__filepath is not None and self.__rendered_res < self.__img_button_size:
|
||||
self.__render_thumb(self.__filepath)
|
||||
if (
|
||||
self._current_file is not None
|
||||
and self.__should_render_on_resize
|
||||
and self.__rendered_res < self.__img_button_size
|
||||
):
|
||||
self.__render_thumb(self._current_file)
|
||||
|
||||
return super().resizeEvent(event)
|
||||
|
||||
@property
|
||||
def media_player(self) -> MediaPlayer:
|
||||
return self.__media_player
|
||||
|
||||
@property
|
||||
def current_file(self) -> Path | None:
|
||||
return self._current_file
|
||||
|
||||
@@ -81,9 +81,9 @@ def test_library_add_file(library: Library):
|
||||
fields=[TextField(name="Title", value="I'm a Test Title")],
|
||||
)
|
||||
|
||||
assert not library.has_entry_with_path(entry.path)
|
||||
assert not library.get_entry_id_from_path(entry.path)
|
||||
assert library.add_entries([entry])
|
||||
assert library.has_entry_with_path(entry.path)
|
||||
assert library.get_entry_id_from_path(entry.path)
|
||||
|
||||
|
||||
def test_create_tag(library: Library, generate_tag: Callable[..., Tag]):
|
||||
@@ -347,8 +347,8 @@ def test_merge_entries(library: Library):
|
||||
entry_b_: Entry = unwrap(library.get_entry_full(entry_b_id))
|
||||
|
||||
assert library.merge_entries(entry_a_, entry_b_)
|
||||
assert not library.has_entry_with_path(Path("a"))
|
||||
assert library.has_entry_with_path(Path("b"))
|
||||
assert not library.get_entry_id_from_path(Path("a"))
|
||||
assert library.get_entry_id_from_path(Path("b"))
|
||||
|
||||
entry_b_merged = unwrap(library.get_entry_full(entry_b_id))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user