mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-01-29 14:20:48 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fc0dd03aa | ||
|
|
90b9af48e3 | ||
|
|
b2dbc5722b | ||
|
|
6490cc905d | ||
|
|
dfa4079b23 | ||
|
|
6ff7303321 | ||
|
|
4d405b5d77 | ||
|
|
bf8816f715 | ||
|
|
8c9b04d1ec | ||
|
|
5995e4d416 |
3
.github/workflows/apprun.yaml
vendored
3
.github/workflows/apprun.yaml
vendored
@@ -33,7 +33,8 @@ jobs:
|
||||
libxcb-xinerama0 \
|
||||
libopengl0 \
|
||||
libxcb-cursor0 \
|
||||
libpulse0
|
||||
libpulse0 \
|
||||
ffmpeg
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
VERSION: str = "9.4.0" # Major.Minor.Patch
|
||||
VERSION: str = "9.4.1" # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
|
||||
|
||||
# The folder & file names where TagStudio keeps its data relative to a library.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import time
|
||||
import traceback
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -736,7 +737,9 @@ class Library:
|
||||
"""Maps a full filepath to its corresponding Entry's ID."""
|
||||
self.filename_to_entry_id_map.clear()
|
||||
for entry in self.entries:
|
||||
self.filename_to_entry_id_map[(entry.path / entry.filename)] = entry.id
|
||||
self.filename_to_entry_id_map[
|
||||
(self.library_dir / entry.path / entry.filename)
|
||||
] = entry.id
|
||||
|
||||
# def _map_filenames_to_entry_ids(self):
|
||||
# """Maps the file paths of entries to their index in the library list."""
|
||||
@@ -883,54 +886,72 @@ class Library:
|
||||
|
||||
# Scans the directory for files, keeping track of:
|
||||
# - Total file count
|
||||
# - Files without library entries
|
||||
# for type in TYPES:
|
||||
start_time = time.time()
|
||||
# - Files without Library entries
|
||||
start_time_total = time.time()
|
||||
start_time_loop = time.time()
|
||||
ext_set = set(self.ext_list) # Should be slightly faster
|
||||
for f in self.library_dir.glob("**/*"):
|
||||
try:
|
||||
if (
|
||||
"$RECYCLE.BIN" not in f.parts
|
||||
and TS_FOLDER_NAME not in f.parts
|
||||
and "tagstudio_thumbs" not in f.parts
|
||||
and not f.is_dir()
|
||||
):
|
||||
if f.suffix.lower() not in self.ext_list and self.is_exclude_list:
|
||||
self.dir_file_count += 1
|
||||
file = f.relative_to(self.library_dir)
|
||||
if file not in self.filename_to_entry_id_map:
|
||||
self.files_not_in_library.append(file)
|
||||
elif f.suffix.lower() in self.ext_list and not self.is_exclude_list:
|
||||
self.dir_file_count += 1
|
||||
file = f.relative_to(self.library_dir)
|
||||
try:
|
||||
_ = self.filename_to_entry_id_map[file]
|
||||
except KeyError:
|
||||
# print(file)
|
||||
self.files_not_in_library.append(file)
|
||||
except PermissionError:
|
||||
logging.info(
|
||||
f"The File/Folder {f} cannot be accessed, because it requires higher permission!"
|
||||
)
|
||||
end_time = time.time()
|
||||
end_time_loop = time.time()
|
||||
# Yield output every 1/30 of a second
|
||||
if (end_time - start_time) > 0.034:
|
||||
if (end_time_loop - start_time_loop) > 0.034:
|
||||
yield self.dir_file_count
|
||||
start_time = time.time()
|
||||
# Sorts the files by date modified, descending.
|
||||
if len(self.files_not_in_library) <= 100000:
|
||||
start_time_loop = time.time()
|
||||
try:
|
||||
self.files_not_in_library = sorted(
|
||||
self.files_not_in_library,
|
||||
key=lambda t: -(self.library_dir / t).stat().st_ctime,
|
||||
)
|
||||
# Skip this file if it should be excluded
|
||||
ext: str = f.suffix.lower()
|
||||
if (ext in ext_set and self.is_exclude_list) or (
|
||||
ext not in ext_set and not self.is_exclude_list
|
||||
):
|
||||
continue
|
||||
|
||||
# Finish if the file/path is already mapped in the Library
|
||||
if self.filename_to_entry_id_map.get(f) is not None:
|
||||
# No other checks are required.
|
||||
self.dir_file_count += 1
|
||||
continue
|
||||
|
||||
# If the file is new, check for validity
|
||||
if (
|
||||
"$RECYCLE.BIN" in f.parts
|
||||
or TS_FOLDER_NAME in f.parts
|
||||
or "tagstudio_thumbs" in f.parts
|
||||
or f.is_dir()
|
||||
):
|
||||
continue
|
||||
|
||||
# Add the validated new file to the Library
|
||||
self.dir_file_count += 1
|
||||
self.files_not_in_library.append(f)
|
||||
|
||||
except PermissionError:
|
||||
logging.info(f'[LIBRARY] Cannot access "{f}": PermissionError')
|
||||
|
||||
yield self.dir_file_count
|
||||
end_time_total = time.time()
|
||||
logging.info(
|
||||
f"[LIBRARY] Scanned directories in {(end_time_total - start_time_total):.3f} seconds"
|
||||
)
|
||||
# Sorts the files by date modified, descending
|
||||
if len(self.files_not_in_library) <= 150000:
|
||||
try:
|
||||
if platform.system() == "Windows" or platform.system() == "Darwin":
|
||||
self.files_not_in_library = sorted(
|
||||
self.files_not_in_library,
|
||||
key=lambda t: -(t).stat().st_birthtime, # type: ignore[attr-defined]
|
||||
)
|
||||
else:
|
||||
self.files_not_in_library = sorted(
|
||||
self.files_not_in_library,
|
||||
key=lambda t: -(t).stat().st_ctime,
|
||||
)
|
||||
except (FileExistsError, FileNotFoundError):
|
||||
print(
|
||||
"[LIBRARY] [ERROR] Couldn't sort files, some were moved during the scanning/sorting process."
|
||||
logging.info(
|
||||
"[LIBRARY][ERROR] Couldn't sort files, some were moved during the scanning/sorting process."
|
||||
)
|
||||
pass
|
||||
else:
|
||||
print(
|
||||
"[LIBRARY][INFO] Not bothering to sort files because there's OVER 100,000! Better sorting methods will be added in the future."
|
||||
logging.info(
|
||||
"[LIBRARY][INFO] Not bothering to sort files because there's OVER 150,000! Better sorting methods will be added in the future."
|
||||
)
|
||||
|
||||
def refresh_missing_files(self):
|
||||
@@ -950,7 +971,7 @@ class Library:
|
||||
# Step [1/2]:
|
||||
# Remove this Entry from the Entries list.
|
||||
entry = self.get_entry(entry_id)
|
||||
path = entry.path / entry.filename
|
||||
path = self.library_dir / entry.path / entry.filename
|
||||
# logging.info(f'Removing path: {path}')
|
||||
|
||||
del self.filename_to_entry_id_map[path]
|
||||
@@ -1080,8 +1101,8 @@ class Library:
|
||||
)
|
||||
)
|
||||
for match in matches:
|
||||
file_1 = files[match[0]].relative_to(self.library_dir)
|
||||
file_2 = files[match[1]].relative_to(self.library_dir)
|
||||
file_1 = files[match[0]]
|
||||
file_2 = files[match[1]]
|
||||
|
||||
if (
|
||||
file_1 in self.filename_to_entry_id_map.keys()
|
||||
@@ -1282,8 +1303,7 @@ class Library:
|
||||
"""Adds files from the `files_not_in_library` list to the Library as Entries. Returns list of added indices."""
|
||||
new_ids: list[int] = []
|
||||
for file in self.files_not_in_library:
|
||||
path = Path(file)
|
||||
# print(os.path.split(file))
|
||||
path = Path(*file.parts[len(self.library_dir.parts) :])
|
||||
entry = Entry(
|
||||
id=self._next_entry_id, filename=path.name, path=path.parent, fields=[]
|
||||
)
|
||||
@@ -1294,8 +1314,6 @@ class Library:
|
||||
self.files_not_in_library.clear()
|
||||
return new_ids
|
||||
|
||||
self.files_not_in_library.clear()
|
||||
|
||||
def get_entry(self, entry_id: int) -> Entry:
|
||||
"""Returns an Entry object given an Entry ID."""
|
||||
return self.entries[self._entry_id_to_index_map[int(entry_id)]]
|
||||
@@ -1316,9 +1334,7 @@ class Library:
|
||||
"""Returns an Entry ID given the full filepath it points to."""
|
||||
try:
|
||||
if self.entries:
|
||||
return self.filename_to_entry_id_map[
|
||||
Path(filename).relative_to(self.library_dir)
|
||||
]
|
||||
return self.filename_to_entry_id_map[filename]
|
||||
except KeyError:
|
||||
return -1
|
||||
|
||||
|
||||
@@ -183,6 +183,8 @@ class MediaCategories:
|
||||
".crw",
|
||||
".dng",
|
||||
".nef",
|
||||
".orf",
|
||||
".raf",
|
||||
".raw",
|
||||
".rw2",
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# type: ignore
|
||||
# Copyright (C) 2022 James Robert (jiaaro).
|
||||
# Licensed under the MIT License.
|
||||
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
|
||||
# Vendored from pydub
|
||||
|
||||
from __future__ import division
|
||||
|
||||
@@ -729,7 +729,10 @@ class _AudioSegment(object):
|
||||
info = None
|
||||
else:
|
||||
# PATCHED
|
||||
info = _mediainfo_json(orig_file, read_ahead_limit=read_ahead_limit)
|
||||
try:
|
||||
info = _mediainfo_json(orig_file, read_ahead_limit=read_ahead_limit)
|
||||
except FileNotFoundError:
|
||||
raise ChildProcessError
|
||||
if info:
|
||||
audio_streams = [x for x in info['streams']
|
||||
if x['codec_type'] == 'audio']
|
||||
@@ -1400,4 +1403,4 @@ class _AudioSegment(object):
|
||||
"""
|
||||
fh = self.export()
|
||||
data = base64.b64encode(fh.read()).decode('ascii')
|
||||
return src.format(base64=data)
|
||||
return src.format(base64=data)
|
||||
|
||||
@@ -106,6 +106,7 @@ class DropImport:
|
||||
continue
|
||||
|
||||
dest_file = self.get_relative_path(file)
|
||||
full_dest_path: Path = self.driver.lib.library_dir / dest_file
|
||||
|
||||
if file in self.duplicate_files:
|
||||
duplicated_files_progress += 1
|
||||
@@ -115,14 +116,12 @@ class DropImport:
|
||||
if self.choice == 2: # rename
|
||||
new_name = self.get_renamed_duplicate_filename_in_lib(dest_file)
|
||||
dest_file = dest_file.with_name(new_name)
|
||||
self.driver.lib.files_not_in_library.append(dest_file)
|
||||
self.driver.lib.files_not_in_library.append(full_dest_path)
|
||||
else: # override is simply copying but not adding a new entry
|
||||
self.driver.lib.files_not_in_library.append(dest_file)
|
||||
self.driver.lib.files_not_in_library.append(full_dest_path)
|
||||
|
||||
(self.driver.lib.library_dir / dest_file).parent.mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
shutil.copyfile(file, self.driver.lib.library_dir / dest_file)
|
||||
(full_dest_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(file, full_dest_path)
|
||||
|
||||
fileCount += 1
|
||||
yield [fileCount, duplicated_files_progress]
|
||||
|
||||
65
tagstudio/src/qt/modals/ffmpeg_checker.py
Normal file
65
tagstudio/src/qt/modals/ffmpeg_checker.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from shutil import which
|
||||
import subprocess
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import Signal, Qt, QUrl
|
||||
from PySide6.QtGui import QPixmap, QDesktopServices
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
|
||||
class FfmpegChecker(QMessageBox):
|
||||
"""A warning dialog for if FFmpeg is missing."""
|
||||
|
||||
HELP_URL = "https://docs.tagstud.io/help/ffmpeg/"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.setWindowTitle("Warning: Missing dependency")
|
||||
self.setText("Warning: Could not find FFmpeg installation")
|
||||
self.setIcon(QMessageBox.Warning)
|
||||
# Blocks other application interactions until resolved
|
||||
self.setWindowModality(Qt.ApplicationModal)
|
||||
|
||||
self.setStandardButtons(
|
||||
QMessageBox.Help | QMessageBox.Ignore | QMessageBox.Cancel
|
||||
)
|
||||
self.setDefaultButton(QMessageBox.Ignore)
|
||||
# Enables the cancel button but hides it to allow for click X to close dialog
|
||||
self.button(QMessageBox.Cancel).hide()
|
||||
|
||||
self.ffmpeg = False
|
||||
self.ffprobe = False
|
||||
|
||||
def installed(self):
|
||||
"""Checks if both FFmpeg and FFprobe are installed and in the PATH."""
|
||||
if which("ffmpeg"):
|
||||
self.ffmpeg = True
|
||||
if which("ffprobe"):
|
||||
self.ffprobe = True
|
||||
|
||||
logging.info(
|
||||
f"[FFmpegChecker] FFmpeg found: {self.ffmpeg}, FFprobe found: {self.ffprobe}"
|
||||
)
|
||||
return self.ffmpeg and self.ffprobe
|
||||
|
||||
def show_warning(self):
|
||||
"""Displays the warning to the user and awaits respone."""
|
||||
missing = "FFmpeg"
|
||||
# If ffmpeg is installed but not ffprobe
|
||||
if not self.ffprobe and self.ffmpeg:
|
||||
missing = "FFprobe"
|
||||
|
||||
self.setText(f"Warning: Could not find {missing} installation")
|
||||
self.setInformativeText(
|
||||
f"{missing} is required for multimedia thumbnails and playback"
|
||||
)
|
||||
# Shows the dialog
|
||||
selection = self.exec()
|
||||
|
||||
# Selection will either be QMessageBox.Help or (QMessageBox.Ignore | QMessageBox.Cancel) which can be ignored
|
||||
if selection == QMessageBox.Help:
|
||||
QDesktopServices.openUrl(QUrl(self.HELP_URL))
|
||||
@@ -92,6 +92,7 @@ from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal
|
||||
from src.qt.modals.fix_dupes import FixDupeFilesModal
|
||||
from src.qt.modals.folders_to_tags import FoldersToTagsModal
|
||||
from src.qt.modals.drop_import import DropImport
|
||||
from src.qt.modals.ffmpeg_checker import FfmpegChecker
|
||||
|
||||
# this import has side-effect of import PySide resources
|
||||
import src.qt.resources_rc # pylint: disable=unused-import
|
||||
@@ -639,6 +640,9 @@ class QtDriver(QObject):
|
||||
if self.args.ci:
|
||||
# gracefully terminate the app in CI environment
|
||||
self.thumb_job_queue.put((self.SIGTERM.emit, []))
|
||||
else:
|
||||
# Startup Checks
|
||||
self.check_ffmpeg()
|
||||
|
||||
app.exec()
|
||||
|
||||
@@ -1091,7 +1095,13 @@ class QtDriver(QObject):
|
||||
)
|
||||
)
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.filter_items("")))
|
||||
r.done.connect(
|
||||
lambda: (
|
||||
pw.hide(),
|
||||
pw.deleteLater(),
|
||||
self.filter_items(self.main_window.searchField.text()),
|
||||
)
|
||||
)
|
||||
QThreadPool.globalInstance().start(r)
|
||||
|
||||
def new_file_macros_runnable(self, new_ids):
|
||||
@@ -1846,6 +1856,12 @@ class QtDriver(QObject):
|
||||
self.filter_items()
|
||||
self.main_window.toggle_landing_page(False)
|
||||
|
||||
def check_ffmpeg(self) -> None:
|
||||
"""Checks if FFmpeg is installed and displays a warning if not."""
|
||||
self.ffmpeg_checker = FfmpegChecker()
|
||||
if not self.ffmpeg_checker.installed():
|
||||
self.ffmpeg_checker.show_warning()
|
||||
|
||||
def create_collage(self) -> None:
|
||||
"""Generates and saves an image collage based on Library Entries."""
|
||||
|
||||
|
||||
@@ -492,7 +492,11 @@ class PreviewPanel(QWidget):
|
||||
def update_date_label(self, filepath: Path | None = None) -> None:
|
||||
"""Update the "Date Created" and "Date Modified" file property labels."""
|
||||
if filepath and filepath.is_file():
|
||||
created: dt = dt.fromtimestamp(filepath.stat().st_ctime)
|
||||
created: dt = None
|
||||
if platform.system() == "Windows" or platform.system() == "Darwin":
|
||||
created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined]
|
||||
else:
|
||||
created = dt.fromtimestamp(filepath.stat().st_ctime)
|
||||
modified: dt = dt.fromtimestamp(filepath.stat().st_mtime)
|
||||
self.date_created_label.setText(
|
||||
f"<b>Date Created:</b> {dt.strftime(created, "%a, %x, %X")}"
|
||||
|
||||
@@ -410,7 +410,7 @@ class ThumbRenderer(QObject):
|
||||
faded (bool): Whether or not to apply a faded version of the edge.
|
||||
Used for light themes.
|
||||
"""
|
||||
opacity: float = 0.8 if not faded else 0.6
|
||||
opacity: float = 1.0 if not faded else 0.8
|
||||
shade_reduction: float = (
|
||||
0
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
@@ -565,6 +565,7 @@ class ThumbRenderer(QObject):
|
||||
logging.error(
|
||||
f"[ThumbRenderer][WAVEFORM][ERROR]: Couldn't render waveform for {filepath.name} ({type(e).__name__})"
|
||||
)
|
||||
|
||||
return im
|
||||
|
||||
def _blender(self, filepath: Path) -> Image.Image:
|
||||
@@ -1057,7 +1058,12 @@ class ThumbRenderer(QObject):
|
||||
size=(adj_size, adj_size),
|
||||
pixel_ratio=pixel_ratio,
|
||||
)
|
||||
except (UnidentifiedImageError, DecompressionBombError, ValueError) as e:
|
||||
except (
|
||||
UnidentifiedImageError,
|
||||
DecompressionBombError,
|
||||
ValueError,
|
||||
ChildProcessError,
|
||||
) as e:
|
||||
logging.info(
|
||||
f"[ThumbRenderer][ERROR]: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user