Compare commits

..

65 Commits

Author SHA1 Message Date
Brady LaTessa
205a362bda feat(tags): add tag merging (#322)
* added merge tag context menu option, created modal, and created merge_tag logic

* forgot to close out the modal

* added ability to re-assign current tag

* ruff/mypy checks
2024-08-28 12:57:45 -07:00
Travis Abendshien
02c245504a ui: update file deletion message boxes 2024-08-27 17:12:58 -07:00
Travis Abendshien
047a2962d4 feat(ui): add delete file menu option + shortcut 2024-08-27 01:57:06 -07:00
Travis Abendshien
8a16b3adf9 feat(ui): add file deletion confirmation boxes 2024-08-27 01:34:30 -07:00
Travis Abendshien
91252ce710 feat: send deleted files to system trash
This refactors the file deletion code to send files to the system trash instead of performing a hard deletion. It also fixes deleting video files and GIFs loaded in the Preview Panel.
2024-08-26 21:07:22 -07:00
Travis Abendshien
39fcc65bcb chore: bump version to v9.4.0 2024-08-26 13:37:27 -07:00
Sean Krueger
732766d57a Bump Qt6 version to 6.7.1 2024-08-26 13:37:27 -07:00
Xarvex
ff5d226480 fix(flake): resolve mypy access to libraries 2024-08-26 13:37:27 -07:00
Sean Krueger
3c842f03ed Update Contributing documentation for dev on Nix
Moves the previous updated blurb from the README to the new CONTRIBUTING
file. Also reworks some wording to link to the Flake nix wiki page for
nix users who haven't enable flakes yet.
2024-08-26 13:37:27 -07:00
Sean Krueger
ba60a79b8f Install ruff via nixpkgs 2024-08-26 13:37:27 -07:00
Sean Krueger
df7a850c6f Add xcb as fallback when wayland fails to load
Mostly as a fallback for xserver.
2024-08-26 13:37:27 -07:00
Sean Krueger
34565af397 Setup and activate virtual environment via flake
When using the nix flake to generate a development shell, the python
virtual environment will now automatically be created and dependecies
from both requirements.txt and requirements-dev.txt will be installed.
This removes the need for using the setup script after entering the
dev shell. Exec bash must be the last thing called, as any other
commands past it will not get executed by the shell hook. Also removes
some duplicate dependencies that I found.
2024-08-26 13:37:26 -07:00
UnusualEgg
d3cd58df47 refactor: combine open launch args (#364)
Combine the `--open` and `-o` launch arguments into a single argument option.
2024-08-26 13:37:26 -07:00
peterbousaada
6b15a58e3f Swapped stacktrace to logging.exception in file_deleter.py 2024-08-26 13:37:26 -07:00
peterbousaada
8d19bef152 - removed unused imports
- swapped out `typing` to `collections.abc`
- removed unnecessary `str()` conversion in `FileDeleterHelper` constructor and `set_filepath`
2024-08-26 13:37:26 -07:00
peterbousaada
a074912ac8 Updated to use pathlib instead of os 2024-08-26 13:37:26 -07:00
peterbousaada
e0cc0dd5a7 Added the option to delete files in the right click context menu 2024-08-26 13:36:56 -07:00
Travis Abendshien
a037a3b1e2 chore: format docstrings with ruff 2024-08-24 17:28:07 -07:00
Travis Abendshien
5c4a3c5856 refactor: organize arguments, update docstrings
The ability to pass a border radius scaling argument is also included.
2024-08-24 17:22:06 -07:00
Travis Abendshien
12d69baa98 refactor: make some consts and args clearer 2024-08-24 16:41:49 -07:00
Travis Abendshien
c377b9d875 fix: remove theme_color redef 2024-08-23 13:10:34 -07:00
Travis Abendshien
9f688cd387 fix(ui) color for default icons follow theme 2024-08-23 13:09:14 -07:00
Travis Abendshien
148f792c34 refactor(ui): move loading icon to ResourceManager 2024-08-21 14:47:26 -07:00
Travis Abendshien
ccf3d788d0 chore: remove unused code 2024-08-21 13:15:55 -07:00
Travis Abendshien
a658fc4fe4 feat(ui): apply edge to default icon thumbs 2024-08-21 13:00:41 -07:00
Travis Abendshien
81dfb50b8f feat(ui): add default icons for audio+vector thumbs 2024-08-21 12:28:28 -07:00
Travis Abendshien
387baae6d6 Merge branch 'Alpha-v9.4' into thumbnails 2024-08-21 00:48:50 -07:00
Travis Abendshien
e4f7055ca7 fix(ui): thumb edges fading on refresh 2024-08-21 00:37:50 -07:00
Travis Abendshien
f91861d2fe fix: handle missing files in resource_manager 2024-08-20 23:44:39 -07:00
Travis Abendshien
a244098f8e refactor: remove edge from four_corner_gradient() 2024-08-20 23:44:03 -07:00
Travis Abendshien
c070f84e7f fix: remove leading dot in preview panel ext 2024-08-20 23:38:10 -07:00
Travis Abendshien
447b5e6894 feat(ui): add more default media types and icons
Add additional default icons for:
- Blender
- Presentation
- Program
- Spreadsheet
Add/expand additional media types:
- PDF
- Packages
2024-08-20 23:37:19 -07:00
Travis Abendshien
6883f9ef6d feat(ui): add media types and icon resources 2024-07-25 16:00:33 -07:00
Travis Abendshien
8d2e67ddad Squashed commit of the following:
commit 9a3c19d398
Author: Travis Abendshien <lvnvtravis@gmail.com>
Date:   Wed Jul 24 22:57:32 2024 -0700

    fix: add missing comma + sort extensions

commit 53b2db9b5f
Author: Travis Abendshien <lvnvtravis@gmail.com>
Date:   Wed Jul 24 14:46:16 2024 -0700

    refactor: move type constants to new media classes
2024-07-25 12:13:59 -07:00
Travis Abendshien
ad12d64f1e (fix): catch ffmpeg errors in file tester 2024-07-25 11:54:44 -07:00
Travis Abendshien
c6a5202c91 fix(ui): hide previous thumbnail before resizing 2024-07-22 07:33:49 -07:00
Travis Abendshien
39324142f1 feat(ui): add dynamic file thumb icons 2024-07-21 08:40:19 -07:00
Travis Abendshien
196c1ba7f3 fix(ui): hide gif preview in multi-selections 2024-07-20 12:53:34 -07:00
Travis Abendshien
91ee2428ca feat(ui): use system accent color for thumb selections 2024-07-20 09:48:59 -07:00
Travis Abendshien
ad53f10ecc fix: missing audio files properly handled 2024-07-20 08:24:26 -07:00
Travis Abendshien
ef8cc6cc85 fix: mkv files with "[0][0][0][0]" codec load properly 2024-07-20 08:21:09 -07:00
Travis Abendshien
086fc1e522 feat(ui): add resizable thumbnail options 2024-07-19 23:43:50 -07:00
Travis Abendshien
3bfeb3c409 fix(ui): blender previews follow app theme 2024-07-19 21:04:49 -07:00
Travis Abendshien
ffdfd6ccdf fix(ui): large font previews follow app theme 2024-07-19 20:25:14 -07:00
Travis Abendshien
598aa4f102 feat(ui) center and color small font previews 2024-07-19 10:06:34 -07:00
Travis Abendshien
c0e56dc7c8 feat(ui): add UI color palette dict 2024-07-19 10:04:48 -07:00
Travis Abendshien
c582f3d370 ruff format 2024-07-19 08:02:30 -07:00
Travis Abendshien
05a486048c Match additional UI to color scheme 2024-07-19 07:58:52 -07:00
Travis Abendshien
d2b5e31792 Fix most theme UI legibility issues 2024-07-19 07:58:26 -07:00
Travis Abendshien
15297140c3 Fix ItemThumb label text color in light mode 2024-07-19 07:55:40 -07:00
Travis Abendshien
3e00a771db Tweak waveform color and size 2024-07-19 07:55:39 -07:00
Travis Abendshien
32257f662f Add final return statement to _audio_waveform() 2024-07-19 07:55:39 -07:00
Travis Abendshien
127fed7aa9 Add final return statement to _album_artwork() 2024-07-19 07:55:39 -07:00
Travis Abendshien
cee42545f7 Improve and style waveform previews 2024-07-19 07:55:38 -07:00
Travis Abendshien
087176edae Add ".psd" to IMAGE_TYPES; Handle ID3NoHeaderError 2024-07-19 07:55:38 -07:00
Travis Abendshien
10d81b3fa1 Add readable video tester 2024-07-19 07:55:38 -07:00
Travis Abendshien
dc135f7b0e Add ".plist" to PLAINTEXT_TYPES 2024-07-19 07:47:55 -07:00
Travis Abendshien
d339f868a9 Add rough check for invalid video codecs 2024-07-19 07:47:54 -07:00
Travis Abendshien
3144440365 Add GIF preview support 2024-07-19 07:42:48 -07:00
Travis Abendshien
c1cd96f507 Add # type: ignore to fromstring method 2024-07-19 07:39:48 -07:00
Travis Abendshien
ff17b93119 Rename "audio_tags" variables for MyPy + typing 2024-07-19 07:39:48 -07:00
Travis Abendshien
6b892ce2bb Rename "cover" variables for MyPy 2024-07-19 07:39:48 -07:00
Travis Abendshien
7ce35192b5 Add support for waveform + album cover thumbnails 2024-07-19 07:39:48 -07:00
Travis Abendshien
3c27b37b56 Use chardet for character encoding detection 2024-07-19 07:39:47 -07:00
Travis Abendshien
34f347bf55 Fix text and RAW image handling
- Fix RAW images not being loaded correctly in the preview panel
- Fix trying to read size data from null images
- Refactor `os.stat` to `<Path object>.stat()`
- Remove unnecessary upper/lower conversions
- Improve encoding compatibility beyond UTF-8 when reading text files
- Code cleanup
2024-07-19 07:39:01 -07:00
38 changed files with 15390 additions and 28568 deletions

View File

@@ -1,16 +0,0 @@
# vi: ft=bash
#
# If you wish to use this file, copy or symlink it to `.envrc` for direnv to read it.
# This will use the flake development shell.
if ! has nix_direnv_version || ! nix_direnv_version 3.0.5; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.5/direnvrc" "sha256-RuwIS+QKFj/T9M2TFXScjBsLR6V3A17YVoEW/Q6AZ1w="
fi
devenv_root_file="$(mktemp -t devenv-root-XXXXXXXX)"
printf %s "${PWD}" >"${devenv_root_file}"
if ! use flake . --override-input devenv-root "file+file://${devenv_root_file}"; then
printf '%s\n' "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2
fi
rm "${devenv_root_file}"
unset devenv_root_file

View File

@@ -33,8 +33,7 @@ jobs:
libxcb-xinerama0 \
libopengl0 \
libxcb-cursor0 \
libpulse0 \
ffmpeg
libpulse0
- name: Install dependencies
run: |

4
.gitignore vendored
View File

@@ -253,7 +253,3 @@ compile_commands.json
.TagStudio
TagStudio.ini
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,qt
.envrc
.direnv
.devenv

View File

@@ -63,8 +63,6 @@ If you're interested in contributing to TagStudio, please take a look at the [co
To download TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagStudio/releases) section of the GitHub repository and download the latest release for your system under the "Assets" section. TagStudio is available for **Windows**, **macOS** _(Apple Silicon & Intel)_, and **Linux**. Windows and Linux builds are also available in portable versions if you want a more self-contained executable to move around.
For video thumbnails and playback, you'll also need [FFmpeg](https://ffmpeg.org/download.html) installed on your system.
> [!IMPORTANT]
> On macOS, you may be met with a message saying _""TagStudio" can't be opened because Apple cannot check it for malicious software."_ If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says _""TagStudio" was blocked from use because it is not from an identified developer."_ Click the "Open Anyway" button to allow TagStudio to run. You should only have to do this once after downloading the application.

547
flake.lock generated
View File

@@ -1,429 +1,12 @@
{
"nodes": {
"cachix": {
"inputs": {
"devenv": "devenv_2",
"flake-compat": [
"devenv",
"flake-compat"
],
"nixpkgs": [
"devenv",
"nixpkgs"
],
"pre-commit-hooks": [
"devenv",
"pre-commit-hooks"
]
},
"locked": {
"lastModified": 1712055811,
"narHash": "sha256-7FcfMm5A/f02yyzuavJe06zLa9hcMHsagE28ADcmQvk=",
"owner": "cachix",
"repo": "cachix",
"rev": "02e38da89851ec7fec3356a5c04bc8349cae0e30",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "cachix",
"type": "github"
}
},
"devenv": {
"inputs": {
"cachix": "cachix",
"flake-compat": "flake-compat_2",
"nix": "nix_2",
"nixpkgs": "nixpkgs_2",
"pre-commit-hooks": "pre-commit-hooks"
},
"locked": {
"lastModified": 1724763216,
"narHash": "sha256-oW2bwCrJpIzibCNK6zfIDaIQw765yMAuMSG2gyZfGv0=",
"owner": "cachix",
"repo": "devenv",
"rev": "1e4ef61205b9aa20fe04bf1c468b6a316281c4f1",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"devenv-root": {
"flake": false,
"locked": {
"narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=",
"type": "file",
"url": "file:///dev/null"
},
"original": {
"type": "file",
"url": "file:///dev/null"
}
},
"devenv_2": {
"inputs": {
"flake-compat": [
"devenv",
"cachix",
"flake-compat"
],
"nix": "nix",
"nixpkgs": "nixpkgs",
"poetry2nix": "poetry2nix",
"pre-commit-hooks": [
"devenv",
"cachix",
"pre-commit-hooks"
]
},
"locked": {
"lastModified": 1708704632,
"narHash": "sha256-w+dOIW60FKMaHI1q5714CSibk99JfYxm0CzTinYWr+Q=",
"owner": "cachix",
"repo": "devenv",
"rev": "2ee4450b0f4b95a1b90f2eb5ffea98b90e48c196",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "python-rewrite",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1673956053,
"narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-compat_2": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1725024810,
"narHash": "sha256-ODYRm8zHfLTH3soTFWE452ydPYz2iTvr9T8ftDMUQ3E=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "af510d4a62d071ea13925ce41c95e3dec816c01d",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_3": {
"inputs": {
"systems": "systems_3"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devenv",
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nix": {
"inputs": {
"flake-compat": "flake-compat",
"nixpkgs": [
"devenv",
"cachix",
"devenv",
"nixpkgs"
],
"nixpkgs-regression": "nixpkgs-regression"
},
"locked": {
"lastModified": 1712911606,
"narHash": "sha256-BGvBhepCufsjcUkXnEEXhEVjwdJAwPglCC2+bInc794=",
"owner": "domenkozar",
"repo": "nix",
"rev": "b24a9318ea3f3600c1e24b4a00691ee912d4de12",
"type": "github"
},
"original": {
"owner": "domenkozar",
"ref": "devenv-2.21",
"repo": "nix",
"type": "github"
}
},
"nix-github-actions": {
"inputs": {
"nixpkgs": [
"devenv",
"cachix",
"devenv",
"poetry2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1688870561,
"narHash": "sha256-4UYkifnPEw1nAzqqPOTL2MvWtm3sNGw1UTYTalkTcGY=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "165b1650b753316aa7f1787f3005a8d2da0f5301",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nix-github-actions",
"type": "github"
}
},
"nix2container": {
"inputs": {
"flake-utils": "flake-utils_3",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1724996935,
"narHash": "sha256-njRK9vvZ1JJsP8oV2OgkBrpJhgQezI03S7gzskCcHos=",
"owner": "nlewo",
"repo": "nix2container",
"rev": "fa6bb0a1159f55d071ba99331355955ae30b3401",
"type": "github"
},
"original": {
"owner": "nlewo",
"repo": "nix2container",
"type": "github"
}
},
"nix_2": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"nixpkgs": [
"devenv",
"nixpkgs"
],
"nixpkgs-regression": "nixpkgs-regression_2"
},
"locked": {
"lastModified": 1712911606,
"narHash": "sha256-BGvBhepCufsjcUkXnEEXhEVjwdJAwPglCC2+bInc794=",
"owner": "domenkozar",
"repo": "nix",
"rev": "b24a9318ea3f3600c1e24b4a00691ee912d4de12",
"type": "github"
},
"original": {
"owner": "domenkozar",
"ref": "devenv-2.21",
"repo": "nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1692808169,
"narHash": "sha256-x9Opq06rIiwdwGeK2Ykj69dNc2IvUH1fY55Wm7atwrE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9201b5ff357e781bf014d0330d18555695df7ba8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-qt6": {
"locked": {
"lastModified": 1718428119,
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
"type": "github"
}
},
"nixpkgs-regression": {
"locked": {
"lastModified": 1643052045,
"narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
}
},
"nixpkgs-regression_2": {
"locked": {
"lastModified": 1643052045,
"narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1710695816,
"narHash": "sha256-3Eh7fhEID17pv9ZxrPwCLfqXnYP006RKzSs0JptsN84=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "614b4613980a522ba49f0d194531beddbb7220d3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-23.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1713361204,
"narHash": "sha256-TA6EDunWTkc5FvDCqU3W2T3SFn0gRZqh6D/hJnM02MM=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "285676e87ad9f0ca23d8714a6ab61e7e027020c6",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1724819573,
"narHash": "sha256-GnR7/ibgIH1vhoy8cYdmXE6iyZqKqFxQSVkFgosBh6w=",
"lastModified": 1718318537,
"narHash": "sha256-4Zu0RYRcAY/VWuu6awwq4opuiD//ahpc2aFHg2CWqFY=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "71e91c409d1e654808b2621f28a327acfdad8dc2",
"rev": "e9ee548d90ff586a6471b4ae80ae9cfcbceb3420",
"type": "github"
},
"original": {
@@ -433,128 +16,26 @@
"type": "github"
}
},
"poetry2nix": {
"inputs": {
"flake-utils": "flake-utils",
"nix-github-actions": "nix-github-actions",
"nixpkgs": [
"devenv",
"cachix",
"devenv",
"nixpkgs"
]
},
"qt6Nixpkgs": {
"locked": {
"lastModified": 1692876271,
"narHash": "sha256-IXfZEkI0Mal5y1jr6IRWMqK8GW2/f28xJenZIPQqkY0=",
"owner": "nix-community",
"repo": "poetry2nix",
"rev": "d5006be9c2c2417dafb2e2e5034d83fabd207ee3",
"lastModified": 1716287118,
"narHash": "sha256-iUTrXABmJAkPRhwPB8GEP7k52OWHVSRtMzlKQ2kIrz4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "47da0aee5616a063015f10ea593688646f2377e4",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "poetry2nix",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"flake-utils": "flake-utils_2",
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"nixpkgs"
],
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1713775815,
"narHash": "sha256-Wu9cdYTnGQQwtT20QQMg7jzkANKQjwBD9iccfGKkfls=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "2ac4dcbf55ed43f3be0bae15e181f08a57af24a4",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "47da0aee5616a063015f10ea593688646f2377e4",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"devenv-root": "devenv-root",
"flake-parts": "flake-parts",
"nix2container": "nix2container",
"nixpkgs": "nixpkgs_3",
"nixpkgs-qt6": "nixpkgs-qt6",
"systems": "systems_4"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_3": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_4": {
"locked": {
"lastModified": 1689347949,
"narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
"owner": "nix-systems",
"repo": "default-linux",
"rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default-linux",
"type": "github"
"nixpkgs": "nixpkgs",
"qt6Nixpkgs": "qt6Nixpkgs"
}
}
},

275
flake.nix
View File

@@ -1,208 +1,111 @@
{
description = "TagStudio";
description = "Tag Studio Development Environment";
inputs = {
devenv.url = "github:cachix/devenv";
devenv-root = {
url = "file+file:///dev/null";
flake = false;
};
flake-parts = {
url = "github:hercules-ci/flake-parts";
inputs.nixpkgs-lib.follows = "nixpkgs";
};
nix2container = {
url = "github:nlewo/nix2container";
inputs.nixpkgs.follows = "nixpkgs";
};
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
# Pinned to Qt version 6.7.1
nixpkgs-qt6.url = "github:NixOS/nixpkgs/e6cea36f83499eb4e9cd184c8a8e823296b50ad5";
systems.url = "github:nix-systems/default-linux";
qt6Nixpkgs = {
# Commit bumping to qt6.7.1
url = "github:NixOS/nixpkgs/47da0aee5616a063015f10ea593688646f2377e4";
};
};
outputs =
{
flake-parts,
nixpkgs,
nixpkgs-qt6,
self,
systems,
...
}@inputs:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [ inputs.devenv.flakeModule ];
outputs = { self, nixpkgs, qt6Nixpkgs }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
systems = import systems;
qt6Pkgs = qt6Nixpkgs.legacyPackages.x86_64-linux;
in {
devShells.x86_64-linux.default = pkgs.mkShell {
name = "Tag Studio Virtual Environment";
venvDir = "./.venv";
perSystem =
{
config,
pkgs,
system,
...
}:
let
inherit (nixpkgs) lib;
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
pkgs.gcc-unwrapped
pkgs.zlib
pkgs.libglvnd
pkgs.glib
pkgs.stdenv.cc.cc
pkgs.fontconfig
pkgs.libxkbcommon
pkgs.xorg.libxcb
pkgs.freetype
pkgs.dbus
pkgs.zstd
# For PySide6 Multimedia
pkgs.libpulseaudio
pkgs.libkrb5
qt6Pkgs = import nixpkgs-qt6 { inherit system; };
in
{
formatter = pkgs.nixfmt-rfc-style;
qt6Pkgs.qt6.qtwayland
qt6Pkgs.qt6.full
qt6Pkgs.qt6.qtbase
];
devenv.shells = rec {
default = tagstudio;
buildInputs = with pkgs; [
cmake
gdb
zstd
python312Full
python312Packages.pip
python312Packages.pyusb # fixes the pyusb 'No backend available' when installed directly via pip
python312Packages.venvShellHook # Initializes a venv in $venvDir
ruff # Ruff cannot be installed via pip
mypy # MyPy cannot be installed via pip
tagstudio =
let
cfg = config.devenv.shells.tagstudio;
in
{
# NOTE: many things were simply transferred over from previous,
# there must be additional work in ensuring all relevant dependencies
# are in place (and no extraneous). I have already spent much
# work making this in the first place and just need to get it out
# there, especially after my promises. Would appreciate any help
# (possibly PRs!) on taking care of this. Otherwise, just expect
# this to get ironed out over time.
#
# Thank you! -Xarvex
libgcc
glib
libxkbcommon
freetype
binutils
dbus
coreutils
libGL
libGLU
fontconfig
xorg.libxcb
devenv.root =
let
devenvRoot = builtins.readFile inputs.devenv-root.outPath;
in
# If not overriden (/dev/null), --impure is necessary.
pkgs.lib.mkIf (devenvRoot != "") devenvRoot;
# this is for the shellhook portion
makeWrapper
bashInteractive
] ++ [
qt6Pkgs.qt6.qtbase
qt6Pkgs.qt6.full
qt6Pkgs.qt6.qtwayland
qt6Pkgs.qtcreator
name = "TagStudio";
# this is for the shellhook portion
qt6Pkgs.qt6.wrapQtAppsHook
];
# Derived from previous flake iteration.
packages =
(with pkgs; [
cmake
binutils
coreutils
dbus
fontconfig
freetype
gdb
glib
libGL
libGLU
libgcc
libxkbcommon
mypy
ruff
xorg.libxcb
zstd
])
++ (with qt6Pkgs; [
qt6.full
qt6.qtbase
qt6.qtwayland
qtcreator
]);
# Run after the virtual environment is created
postVenvCreation = ''
unset SOURCE_DATE_EPOCH
enterShell =
let
setQtEnv =
pkgs.runCommand "set-qt-env"
{
buildInputs = with qt6Pkgs.qt6; [
qtbase
];
echo Installing dependencies into virtual environment
pip install -r requirements.txt
pip install -r requirements-dev.txt
# Hacky solution to not fight with other dev deps
# May show failure if skipped due to same version with nixpkgs
pip uninstall -y mypy ruff
'';
nativeBuildInputs =
(with pkgs; [
makeShellWrapper
])
++ (with qt6Pkgs.qt6; [
wrapQtAppsHook
]);
}
''
makeShellWrapper "$(type -p sh)" "$out" "''${qtWrapperArgs[@]}"
sed "/^exec/d" -i "$out"
'';
in
''
source ${setQtEnv}
'';
# set the environment variables that Qt apps expect
postShellHook = ''
unset SOURCE_DATE_EPOCH
scripts.tagstudio.exec = ''
python ${cfg.devenv.root}/tagstudio/tag_studio.py
'';
export QT_QPA_PLATFORM="wayland;xcb"
export LIBRARY_PATH=/usr/lib:/usr/lib64:$LIBRARY_PATH
# export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib/:/run/opengl-driver/lib/
export QT_PLUGIN_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.qtPluginPrefix}
bashdir=$(mktemp -d)
makeWrapper "$(type -p bash)" "$bashdir/bash" "''${qtWrapperArgs[@]}"
env = {
QT_QPA_PLATFORM = "wayland;xcb";
echo Activating Virtual Environment
source $venvDir/bin/activate
export PYTHONPATH=$PWD/$venvDir/${pkgs.python312Full.sitePackages}:$PYTHONPATH
# Derived from previous flake iteration.
# Not desired given LD_LIBRARY_PATH pollution.
# See supposed alternative below, further research required.
LD_LIBRARY_PATH = lib.makeLibraryPath (
(with pkgs; [
dbus
fontconfig
freetype
gcc-unwrapped
glib
libglvnd
libkrb5
libpulseaudio
libva
libxkbcommon
openssl
stdenv.cc.cc.lib
wayland
xorg.libxcb
xorg.libXrandr
zlib
zstd
])
++ (with qt6Pkgs.qt6; [
qtbase
qtwayland
full
])
);
};
languages.python = {
enable = true;
venv = {
enable = true;
quiet = true;
requirements =
let
excludeDeps =
req: deps:
builtins.concatStringsSep "\n" (
builtins.filter (line: !(lib.any (elem: lib.hasPrefix elem line) deps)) (lib.splitString "\n" req)
);
in
''
${builtins.readFile ./requirements.txt}
${excludeDeps (builtins.readFile ./requirements-dev.txt) [
"mypy"
"ruff"
]}
'';
};
# Should be able to replace LD_LIBRARY_PATH?
# Was not quite able to get working,
# will be consulting cachix community. -Xarvex
# libraries = with pkgs; [ ];
};
};
};
};
exec "$bashdir/bash"
'';
};
};
}

View File

@@ -1,9 +1,9 @@
[tool.ruff]
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py", "**/vendored/"]
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py"]
[tool.mypy]
strict_optional = false
disable_error_code = ["union-attr", "annotation-unchecked", "import-untyped"]
explicit_package_bases = true
warn_unused_ignores = true
exclude = ['tests', 'src/qt/helpers/vendored']
exclude = ['tests']

View File

@@ -14,5 +14,4 @@ pydub==0.25.1
mutagen==1.47.0
numpy==1.26.4
ffmpeg-python==0.2.0
Send2Trash==1.8.3
vtf2img==0.1.0
Send2Trash==1.8.3

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 229 KiB

View File

@@ -1,4 +1,4 @@
VERSION: str = "9.4.2" # Major.Minor.Patch
VERSION: str = "9.4.0" # Major.Minor.Patch
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
# The folder & file names where TagStudio keeps its data relative to a library.

View File

@@ -7,7 +7,6 @@
import datetime
import logging
import os
import platform
import time
import traceback
import xml.etree.ElementTree as ET
@@ -480,7 +479,7 @@ class Library:
return tag_list
def open_library(self, path: str | Path, is_path_file: bool = False) -> int:
def open_library(self, path: str | Path) -> int:
"""
Opens a TagStudio v9+ Library.
Returns 0 if library does not exist, 1 if successfully opened, 2 if corrupted.
@@ -488,264 +487,242 @@ class Library:
return_code: int = 2
_path: Path = self._fix_lib_path(path) if not is_path_file else Path(path)
lib_path: Path = (
_path / TS_FOLDER_NAME / "ts_library.json" if not is_path_file else _path
)
logging.info(f"[LIBRARY] Library Save File Loaded From: {lib_path}")
_path: Path = self._fix_lib_path(path)
# if (lib_path).exists():
# json_dump: JsonLibary = None
if (_path / TS_FOLDER_NAME / "ts_library.json").exists():
try:
with open(
_path / TS_FOLDER_NAME / "ts_library.json",
"r",
encoding="utf-8",
) as file:
json_dump: JsonLibary = ujson.load(file)
self.library_dir = Path(_path)
self.verify_ts_folders()
major, minor, patch = json_dump["ts-version"].split(".")
try:
with open(
lib_path,
"r",
encoding="utf-8",
) as file:
json_dump = ujson.load(file)
except (ujson.JSONDecodeError, FileNotFoundError):
logging.info(
"[LIBRARY][ERROR] Blank/Corrupted Library file found. Searching for Auto Backup..."
)
backup_folder: Path = (
self._fix_lib_path(path) / TS_FOLDER_NAME / BACKUP_FOLDER_NAME
)
if backup_folder.exists():
auto_backup: Path = None
dir_obj = os.scandir(backup_folder)
for backup_file in dir_obj:
if backup_file.is_file() and "ts_library_backup_auto" in str(
backup_file
):
auto_backup = Path(backup_file)
break
if auto_backup and "ts_library_backup_auto" not in str(path):
logging.info(f"[LIBRARY] Loading Auto Backup: {auto_backup}")
return self.open_library(auto_backup, is_path_file=True)
else:
self.library_dir = self._fix_lib_path(path)
logging.info(f"[LIBRARY] Library Save Target Directory: {self.library_dir}")
self.verify_ts_folders()
major, minor, patch = json_dump["ts-version"].split(".")
# Load Extension List --------------------------------------
start_time = time.time()
if "ignored_extensions" in json_dump:
self.ext_list = json_dump.get(
"ignored_extensions", self.default_ext_exclude_list
)
else:
self.ext_list = json_dump.get("ext_list", self.default_ext_exclude_list)
# Sanitizes older lists (v9.2.1) that don't use leading periods.
# Without this, existing lists (including default lists)
# have to otherwise be updated by hand in order to restore
# previous functionality.
sanitized_list: list[str] = []
for ext in self.ext_list:
if not ext.startswith("."):
ext = "." + ext
sanitized_list.append(ext)
self.ext_list = sanitized_list
self.is_exclude_list = json_dump.get("is_exclude_list", True)
end_time = time.time()
logging.info(
f"[LIBRARY] Extension list loaded in {(end_time - start_time):.3f} seconds"
)
# Parse Tags -----------------------------------------------
if "tags" in json_dump.keys():
start_time = time.time()
# Step 1: Verify default built-in tags are present.
json_dump["tags"] = self.verify_default_tags(json_dump["tags"])
for tag in json_dump["tags"]:
# Step 2: Create a Tag object and append it to the internal Tags list,
# then map that Tag's ID to its index in the Tags list.
id = int(tag.get("id", 0))
# Don't load tags with duplicate IDs
if id not in {t.id for t in self.tags}:
if id >= self._next_tag_id:
self._next_tag_id = id + 1
name = tag.get("name", "")
shorthand = tag.get("shorthand", "")
aliases = tag.get("aliases", [])
subtag_ids = tag.get("subtag_ids", [])
color = tag.get("color", "")
t = Tag(
id=id,
name=name,
shorthand=shorthand,
aliases=aliases,
subtags_ids=subtag_ids,
color=color,
# Load Extension List --------------------------------------
start_time = time.time()
if "ignored_extensions" in json_dump:
self.ext_list = json_dump.get(
"ignored_extensions", self.default_ext_exclude_list
)
else:
self.ext_list = json_dump.get(
"ext_list", self.default_ext_exclude_list
)
# NOTE: This does NOT use the add_tag_to_library() method!
# That method is only used for Tags added at runtime.
# This process uses the same inner methods, but waits until all of the
# Tags are registered in the Tags list before creating the Tag clusters.
self.tags.append(t)
self._map_tag_id_to_index(t, -1)
self._map_tag_strings_to_tag_id(t)
else:
logging.info(f"[LIBRARY]Skipping Tag with duplicate ID: {tag}")
# Sanitizes older lists (v9.2.1) that don't use leading periods.
# Without this, existing lists (including default lists)
# have to otherwise be updated by hand in order to restore
# previous functionality.
sanitized_list: list[str] = []
for ext in self.ext_list:
if not ext.startswith("."):
ext = "." + ext
sanitized_list.append(ext)
self.ext_list = sanitized_list
# Step 3: Map each Tag's subtags together now that all Tag objects in it.
for t in self.tags:
self._map_tag_id_to_cluster(t)
self.is_exclude_list = json_dump.get("is_exclude_list", True)
end_time = time.time()
logging.info(
f"[LIBRARY] Extension list loaded in {(end_time - start_time):.3f} seconds"
)
end_time = time.time()
logging.info(
f"[LIBRARY] Tags loaded in {(end_time - start_time):.3f} seconds"
)
# Parse Tags -----------------------------------------------
if "tags" in json_dump.keys():
start_time = time.time()
# Parse Entries --------------------------------------------
if entries := json_dump.get("entries"):
start_time = time.time()
for entry in entries:
if "id" in entry:
id = int(entry["id"])
if id >= self._next_entry_id:
self._next_entry_id = id + 1
else:
# Version 9.1.x+ Compatibility
id = self._next_entry_id
self._next_entry_id += 1
# Step 1: Verify default built-in tags are present.
json_dump["tags"] = self.verify_default_tags(json_dump["tags"])
filename = entry.get("filename", "")
e_path = entry.get("path", "")
fields: list = []
if "fields" in entry:
# Cast JSON str keys to ints
for tag in json_dump["tags"]:
# Step 2: Create a Tag object and append it to the internal Tags list,
# then map that Tag's ID to its index in the Tags list.
for f in entry["fields"]:
f[int(list(f.keys())[0])] = f[list(f.keys())[0]]
del f[list(f.keys())[0]]
fields = entry["fields"]
id = int(tag.get("id", 0))
# Look through fields for legacy Collation data ----
if int(major) >= 9 and int(minor) < 1:
for f in fields:
if self.get_field_attr(f, "type") == "collation":
# NOTE: This legacy support will be removed in
# a later version, probably 9.2.
# Legacy Collation data present in v9.0.x
# DATA SHAPE: {name: str, page: int}
# Don't load tags with duplicate IDs
if id not in {t.id for t in self.tags}:
if id >= self._next_tag_id:
self._next_tag_id = id + 1
# We'll do an inefficient linear search each
# time to convert the legacy data.
matched = False
collation_id = -1
for c in self.collations:
if (
c.title
== self.get_field_attr(f, "content")["name"]
):
c.e_ids_and_pages.append(
(
id,
int(
self.get_field_attr(f, "content")[
"page"
]
),
)
)
matched = True
collation_id = c.id
if not matched:
c = Collation(
id=self._next_collation_id,
title=self.get_field_attr(f, "content")["name"],
e_ids_and_pages=[],
sort_order="",
)
collation_id = self._next_collation_id
self._next_collation_id += 1
c.e_ids_and_pages.append(
(
id,
int(
self.get_field_attr(f, "content")[
"page"
name = tag.get("name", "")
shorthand = tag.get("shorthand", "")
aliases = tag.get("aliases", [])
subtag_ids = tag.get("subtag_ids", [])
color = tag.get("color", "")
t = Tag(
id=id,
name=name,
shorthand=shorthand,
aliases=aliases,
subtags_ids=subtag_ids,
color=color,
)
# NOTE: This does NOT use the add_tag_to_library() method!
# That method is only used for Tags added at runtime.
# This process uses the same inner methods, but waits until all of the
# Tags are registered in the Tags list before creating the Tag clusters.
self.tags.append(t)
self._map_tag_id_to_index(t, -1)
self._map_tag_strings_to_tag_id(t)
else:
logging.info(
f"[LIBRARY]Skipping Tag with duplicate ID: {tag}"
)
# Step 3: Map each Tag's subtags together now that all Tag objects in it.
for t in self.tags:
self._map_tag_id_to_cluster(t)
end_time = time.time()
logging.info(
f"[LIBRARY] Tags loaded in {(end_time - start_time):.3f} seconds"
)
# Parse Entries --------------------------------------------
if entries := json_dump.get("entries"):
start_time = time.time()
for entry in entries:
if "id" in entry:
id = int(entry["id"])
if id >= self._next_entry_id:
self._next_entry_id = id + 1
else:
# Version 9.1.x+ Compatibility
id = self._next_entry_id
self._next_entry_id += 1
filename = entry.get("filename", "")
e_path = entry.get("path", "")
fields: list = []
if "fields" in entry:
# Cast JSON str keys to ints
for f in entry["fields"]:
f[int(list(f.keys())[0])] = f[list(f.keys())[0]]
del f[list(f.keys())[0]]
fields = entry["fields"]
# Look through fields for legacy Collation data ----
if int(major) >= 9 and int(minor) < 1:
for f in fields:
if self.get_field_attr(f, "type") == "collation":
# NOTE: This legacy support will be removed in
# a later version, probably 9.2.
# Legacy Collation data present in v9.0.x
# DATA SHAPE: {name: str, page: int}
# We'll do an inefficient linear search each
# time to convert the legacy data.
matched = False
collation_id = -1
for c in self.collations:
if (
c.title
== self.get_field_attr(f, "content")[
"name"
]
),
)
)
self.collations.append(c)
self._map_collation_id_to_index(c, -1)
f_id = self.get_field_attr(f, "id")
f.clear()
f[int(f_id)] = collation_id
# Collation Field data present in v9.1.x+
# DATA SHAPE: int
elif int(major) >= 9 and int(minor) >= 1:
pass
):
c.e_ids_and_pages.append(
(
id,
int(
self.get_field_attr(
f, "content"
)["page"]
),
)
)
matched = True
collation_id = c.id
if not matched:
c = Collation(
id=self._next_collation_id,
title=self.get_field_attr(f, "content")[
"name"
],
e_ids_and_pages=[],
sort_order="",
)
collation_id = self._next_collation_id
self._next_collation_id += 1
c.e_ids_and_pages.append(
(
id,
int(
self.get_field_attr(
f, "content"
)["page"]
),
)
)
self.collations.append(c)
self._map_collation_id_to_index(c, -1)
f_id = self.get_field_attr(f, "id")
f.clear()
f[int(f_id)] = collation_id
# Collation Field data present in v9.1.x+
# DATA SHAPE: int
elif int(major) >= 9 and int(minor) >= 1:
pass
e = Entry(
id=int(id),
filename=filename,
path=e_path,
fields=fields,
)
self.entries.append(e)
self._map_entry_id_to_index(e, -1)
e = Entry(
id=int(id),
filename=filename,
path=e_path,
fields=fields,
)
self.entries.append(e)
self._map_entry_id_to_index(e, -1)
end_time = time.time()
logging.info(
f"[LIBRARY] Entries loaded in {(end_time - start_time):.3f} seconds"
)
end_time = time.time()
logging.info(
f"[LIBRARY] Entries loaded in {(end_time - start_time):.3f} seconds"
)
# Parse Collations -----------------------------------------
if "collations" in json_dump.keys():
start_time = time.time()
for collation in json_dump["collations"]:
# Step 1: Create a Collation object and append it to
# the internal Collations list, then map that
# Collation's ID to its index in the Collations list.
# Parse Collations -----------------------------------------
if "collations" in json_dump.keys():
start_time = time.time()
for collation in json_dump["collations"]:
# Step 1: Create a Collation object and append it to
# the internal Collations list, then map that
# Collation's ID to its index in the Collations list.
id = int(collation.get("id", 0))
if id >= self._next_collation_id:
self._next_collation_id = id + 1
id = int(collation.get("id", 0))
if id >= self._next_collation_id:
self._next_collation_id = id + 1
title = collation.get("title", "")
e_ids_and_pages = collation.get("e_ids_and_pages", [])
sort_order = collation.get("sort_order", "")
cover_id = collation.get("cover_id", -1)
title = collation.get("title", "")
e_ids_and_pages = collation.get("e_ids_and_pages", [])
sort_order = collation.get("sort_order", "")
cover_id = collation.get("cover_id", -1)
c = Collation(
id=id,
title=title,
e_ids_and_pages=e_ids_and_pages,
sort_order=sort_order,
cover_id=cover_id,
)
c = Collation(
id=id,
title=title,
e_ids_and_pages=e_ids_and_pages, # type: ignore
sort_order=sort_order,
cover_id=cover_id,
)
# NOTE: This does NOT use the add_collation_to_library() method
# which is intended to be used at runtime. However, there is
# currently no reason why it couldn't be used here, and is
# instead not used for consistency.
self.collations.append(c)
self._map_collation_id_to_index(c, -1)
end_time = time.time()
logging.info(
f"[LIBRARY] Collations loaded in {(end_time - start_time):.3f} seconds"
)
# NOTE: This does NOT use the add_collation_to_library() method
# which is intended to be used at runtime. However, there is
# currently no reason why it couldn't be used here, and is
# instead not used for consistency.
self.collations.append(c)
self._map_collation_id_to_index(c, -1)
end_time = time.time()
logging.info(
f"[LIBRARY] Collations loaded in {(end_time - start_time):.3f} seconds"
)
return_code = 1
self.save_library_backup_to_disk(is_auto=True)
return_code = 1
except ujson.JSONDecodeError:
logging.info("[LIBRARY][ERROR]: Empty JSON file!")
# If the Library is loaded, continue other processes.
if return_code == 1:
@@ -759,9 +736,7 @@ 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[
(self.library_dir / entry.path / entry.filename)
] = entry.id
self.filename_to_entry_id_map[(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."""
@@ -820,13 +795,13 @@ class Library:
filename = "ts_library.json"
self.verify_ts_folders()
json_library: JsonLibary = self.to_json()
with open(
self.library_dir / TS_FOLDER_NAME / filename, "w", encoding="utf-8"
) as outfile:
outfile.flush()
ujson.dump(
json_library,
self.to_json(),
outfile,
ensure_ascii=False,
escape_forward_slashes=False,
@@ -837,22 +812,16 @@ class Library:
f"[LIBRARY] Library saved to disk in {(end_time - start_time):.3f} seconds"
)
def save_library_backup_to_disk(self, is_auto: bool = False) -> str:
def save_library_backup_to_disk(self) -> str:
"""
Saves a backup file of the Library to disk at the default TagStudio folder location.
Returns the filename used, including the date and time."""
logging.info(f"[LIBRARY] Saving Library Backup to Disk...")
start_time = time.time()
filename = (
"ts_library_backup_auto.json"
if is_auto
else f'ts_library_backup_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.json'
)
filename = f'ts_library_backup_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.json'
self.verify_ts_folders()
json_library: JsonLibary = self.to_json()
with open(
self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename,
"w",
@@ -860,7 +829,7 @@ class Library:
) as outfile:
outfile.flush()
ujson.dump(
json_library,
self.to_json(),
outfile,
ensure_ascii=False,
escape_forward_slashes=False,
@@ -914,72 +883,54 @@ class Library:
# Scans the directory for files, keeping track of:
# - Total file count
# - Files without Library entries
start_time_total = time.time()
start_time_loop = time.time()
ext_set = set(self.ext_list) # Should be slightly faster
# - Files without library entries
# for type in TYPES:
start_time = time.time()
for f in self.library_dir.glob("**/*"):
end_time_loop = time.time()
# Yield output every 1/30 of a second
if (end_time_loop - start_time_loop) > 0.034:
yield self.dir_file_count
start_time_loop = time.time()
try:
# 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()
"$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()
):
continue
# Add the validated new file to the Library
self.dir_file_count += 1
self.files_not_in_library.append(f)
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'[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):
logging.info(
"[LIBRARY][ERROR] Couldn't sort files, some were moved during the scanning/sorting process."
f"The File/Folder {f} cannot be accessed, because it requires higher permission!"
)
end_time = time.time()
# Yield output every 1/30 of a second
if (end_time - start_time) > 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:
try:
self.files_not_in_library = sorted(
self.files_not_in_library,
key=lambda t: -(self.library_dir / t).stat().st_ctime,
)
except (FileExistsError, FileNotFoundError):
print(
"[LIBRARY] [ERROR] Couldn't sort files, some were moved during the scanning/sorting process."
)
pass
else:
logging.info(
"[LIBRARY][INFO] Not bothering to sort files because there's OVER 150,000! Better sorting methods will be added in the future."
print(
"[LIBRARY][INFO] Not bothering to sort files because there's OVER 100,000! Better sorting methods will be added in the future."
)
def refresh_missing_files(self):
@@ -999,7 +950,7 @@ class Library:
# Step [1/2]:
# Remove this Entry from the Entries list.
entry = self.get_entry(entry_id)
path = self.library_dir / entry.path / entry.filename
path = entry.path / entry.filename
# logging.info(f'Removing path: {path}')
del self.filename_to_entry_id_map[path]
@@ -1129,22 +1080,25 @@ class Library:
)
)
for match in matches:
file_1 = files[match[0]]
file_2 = files[match[1]]
# print(f'MATCHED ({match[2]}%): \n {files[match[0]]} \n-> {files[match[1]]}')
file_1 = files[match[0]].relative_to(self.library_dir)
file_2 = files[match[1]].relative_to(self.library_dir)
if (
file_1 in self.filename_to_entry_id_map.keys()
file_1.resolve in self.filename_to_entry_id_map.keys()
and file_2 in self.filename_to_entry_id_map.keys()
):
self.dupe_files.append(
(files[match[0]], files[match[1]], match[2])
)
print("")
for dupe in self.dupe_files:
print(
f"[LIBRARY] MATCHED ({dupe[2]}%): \n {dupe[0]} \n-> {dupe[1]}",
end="\n",
)
# self.dupe_files.append(full_path)
def remove_missing_files(self):
deleted = []
@@ -1331,7 +1285,8 @@ 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.parts[len(self.library_dir.parts) :])
path = Path(file)
# print(os.path.split(file))
entry = Entry(
id=self._next_entry_id, filename=path.name, path=path.parent, fields=[]
)
@@ -1342,6 +1297,8 @@ 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)]]
@@ -1362,7 +1319,9 @@ class Library:
"""Returns an Entry ID given the full filepath it points to."""
try:
if self.entries:
return self.filename_to_entry_id_map[filename]
return self.filename_to_entry_id_map[
Path(filename).relative_to(self.library_dir)
]
except KeyError:
return -1
@@ -1393,18 +1352,10 @@ class Library:
only_missing: bool = "missing" in query or "no file" in query
allow_adv: bool = "filename:" in query_words
tag_only: bool = "tag_id:" in query_words
tag_only_ids: list[int] = []
if allow_adv:
query_words.remove("filename:")
if tag_only:
query_words.remove("tag_id:")
if query_words and query_words[0].isdigit():
tag_only_ids.append(int(query_words[0]))
tag_only_ids.extend(self.get_tag_cluster(int(query_words[0])))
else:
logging.error(
f"[Library][ERROR] Invalid Tag ID in query: {query_words}"
)
# TODO: Expand this to allow for dynamic fields to work.
only_no_author: bool = "no author" in query or "no artist" in query
@@ -1431,9 +1382,15 @@ class Library:
all_tag_terms.remove(all_tag_terms[i])
break
# print(all_tag_terms)
# non_entry_count = 0
# Iterate over all Entries =============================================================
for entry in self.entries:
allowed_ext: bool = entry.filename.suffix.lower() not in self.ext_list
# try:
# entry: Entry = self.entries[self.file_to_library_index_map[self._source_filenames[i]]]
# print(f'{entry}')
if allowed_ext == self.is_exclude_list:
# If the entry has tags of any kind, append them to this main tag list.
@@ -1475,6 +1432,7 @@ class Library:
# elif query in entry.path.lower():
# NOTE: This searches path and filenames.
if allow_adv:
if [q for q in query_words if (q in str(entry.path).lower())]:
results.append((ItemType.ENTRY, entry.id))
@@ -1483,14 +1441,17 @@ class Library:
]:
results.append((ItemType.ENTRY, entry.id))
elif tag_only:
for id in tag_only_ids:
if entry.has_tag(self, id) and entry.id not in results:
results.append((ItemType.ENTRY, entry.id))
break
if entry.has_tag(self, int(query_words[0])):
results.append((ItemType.ENTRY, entry.id))
# elif query in entry.filename.lower():
# self.filtered_entries.append(index)
elif entry_tags:
# function to add entry to results
def add_entry(entry: Entry):
# self.filter_entries.append()
# self.filtered_file_list.append(file)
# results.append((SearchItemType.ENTRY, entry.id))
added = False
for f in entry.fields:
if self.get_field_attr(f, "type") == "collation":
@@ -1560,6 +1521,26 @@ class Library:
add_entry(entry)
break
# sys.stdout.write(
# f'\r[INFO][FILTER]: {len(self.filtered_file_list)} matches found')
# sys.stdout.flush()
# except:
# # # Put this here to have new non-registered images show up
# # if query == "untagged" or query == "no author" or query == "no artist":
# # self.filtered_file_list.append(file)
# # non_entry_count = non_entry_count + 1
# pass
# end_time = time.time()
# print(
# f'[INFO][FILTER]: {len(self.filtered_entries)} matches found ({(end_time - start_time):.3f} seconds)')
# if non_entry_count:
# print(
# f'[INFO][FILTER]: There are {non_entry_count} new files in {self.source_dir} that do not have entries. These will not appear in most filtered results.')
# if not self.filtered_entries:
# print("[INFO][FILTER]: Filter returned no results.")
else:
for entry in self.entries:
added = False
@@ -1584,6 +1565,8 @@ class Library:
if not added:
results.append((ItemType.ENTRY, entry.id))
# for file in self._source_filenames:
# self.filtered_file_list.append(file)
results.reverse()
return results
@@ -1912,6 +1895,31 @@ class Library:
for t in self.tags:
self._map_tag_strings_to_tag_id(t)
def merge_tag(self, source_tag: Tag, target_tag: Tag) -> None:
source_tag_id: int = source_tag.id
target_tag_id: int = target_tag.id
if source_tag.name not in target_tag.aliases:
target_tag.aliases.append(source_tag.name)
for alias in source_tag.aliases:
if alias not in target_tag.aliases:
target_tag.aliases.append(alias)
for subtag_id in source_tag.subtag_ids:
if subtag_id not in target_tag.subtag_ids:
target_tag.subtag_ids.append(subtag_id)
for entry in self.entries:
for field in entry.fields:
if self.get_field_attr(field, "type") == "tag_box":
if source_tag_id in self.get_field_attr(field, "content"):
self.get_field_attr(field, "content").remove(source_tag_id)
if target_tag_id not in self.get_field_attr(field, "content"):
self.get_field_attr(field, "content").append(target_tag_id)
self.remove_tag(source_tag_id)
def get_tag_ref_count(self, tag_id: int) -> tuple[int, int]:
"""Returns an int tuple (entry_ref_count, subtag_ref_count) of Tag reference counts."""
entry_ref_count: int = 0
@@ -2068,7 +2076,7 @@ class Library:
# elif meta_tags_field_indices:
# priority_field_index = meta_tags_field_indices[0]
if priority_field_index >= 0:
if priority_field_index > 0:
self.update_entry_field(
entry_id, priority_field_index, [matching[0]], "append"
)
@@ -2326,7 +2334,7 @@ class Library:
return self.tags[self._tag_id_to_index_map[int(tag_id)]]
def get_tag_cluster(self, tag_id: int) -> list[int]:
"""Returns a list of Tag IDs that reference this Tag as its parent."""
"""Returns a list of Tag IDs that reference this Tag."""
if tag_id in self._tag_id_to_cluster_map:
return self._tag_id_to_cluster_map[int(tag_id)]
return []

View File

@@ -36,7 +36,6 @@ class MediaType(str, Enum):
PRESENTATION: str = "presentation"
PROGRAM: str = "program"
SHORTCUT: str = "shortcut"
SOURCE_ENGINE: str = "source_engine"
SPREADSHEET: str = "spreadsheet"
TEXT: str = "text"
VIDEO: str = "video"
@@ -183,8 +182,6 @@ class MediaCategories:
".crw",
".dng",
".nef",
".orf",
".raf",
".raw",
".rw2",
}
@@ -246,20 +243,6 @@ class MediaCategories:
".ts",
".txt",
".xml",
".vmt",
".fgd",
".nut",
".cfg",
".conf",
".vdf",
".vcfg",
".gi",
".inf",
".vqlayout",
".qss",
".vsc",
".kv3",
".vsnd_template",
}
_PRESENTATION_SET: set[str] = {
".key",
@@ -268,9 +251,6 @@ class MediaCategories:
".pptx",
}
_PROGRAM_SET: set[str] = {".app", ".exe"}
_SOURCE_ENGINE_SET: set[str] = {
".vtf",
}
_SHORTCUT_SET: set[str] = {".desktop", ".lnk", ".url"}
_SPREADSHEET_SET: set[str] = {
".csv",
@@ -409,11 +389,6 @@ class MediaCategories:
extensions=_SHORTCUT_SET,
is_iana=False,
)
SOURCE_ENGINE_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.SOURCE_ENGINE,
extensions=_SOURCE_ENGINE_SET,
is_iana=False,
)
SPREADSHEET_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.SPREADSHEET,
extensions=_SPREADSHEET_SET,
@@ -454,7 +429,6 @@ class MediaCategories:
PRESENTATION_TYPES,
PROGRAM_TYPES,
SHORTCUT_TYPES,
SOURCE_ENGINE_TYPES,
SPREADSHEET_TYPES,
TEXT_TYPES,
VIDEO_TYPES,

View File

@@ -284,12 +284,6 @@ _UI_COLORS: dict = {
ColorType.LIGHT_ACCENT: "#FFFFFF",
ColorType.DARK_ACCENT: "#1e1e1e",
},
"red": {
ColorType.PRIMARY: "#e22c3c",
ColorType.BORDER: "#e54252",
ColorType.LIGHT_ACCENT: "#f39caa",
ColorType.DARK_ACCENT: "#440d12",
},
"green": {
ColorType.PRIMARY: "#28bb48",
ColorType.BORDER: "#43c568",
@@ -302,6 +296,12 @@ _UI_COLORS: dict = {
ColorType.LIGHT_ACCENT: "#EFD4FB",
ColorType.DARK_ACCENT: "#3E1555",
},
"red": {
ColorType.PRIMARY: "#e22c3c",
ColorType.BORDER: "#e54252",
ColorType.LIGHT_ACCENT: "#f39caa",
ColorType.DARK_ACCENT: "#440d12",
},
"theme_dark": {
ColorType.PRIMARY: "#333333",
ColorType.BORDER: "#555555",
@@ -317,18 +317,18 @@ _UI_COLORS: dict = {
}
def get_tag_color(color_type, color):
def get_tag_color(type, color):
color = color.lower()
try:
if color_type == ColorType.TEXT:
return get_tag_color(_TAG_COLORS[color][color_type], color)
if type == ColorType.TEXT:
return get_tag_color(_TAG_COLORS[color][type], color)
else:
return _TAG_COLORS[color][color_type]
return _TAG_COLORS[color][type]
except KeyError:
return "#FF00FF"
def get_ui_color(color_type: ColorType, color: str):
def get_ui_color(type: ColorType, color: str):
"""Returns a hex value given a color name and ColorType."""
color = color.lower()
return _UI_COLORS.get(color).get(color_type)
return _UI_COLORS.get(color).get(type)

View File

@@ -31,7 +31,7 @@ class TagStudioCore:
json_dump = {}
info = {}
_filepath: Path = Path(filepath)
_filepath = _filepath.parent / (_filepath.name + ".json")
_filepath = _filepath.parent / (_filepath.stem + ".json")
# NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar
# files may be downloaded with indices starting at 1 rather than 0, unlike the posts.

View File

@@ -6,8 +6,6 @@
import ffmpeg
from pathlib import Path
from src.qt.helpers.vendored.ffmpeg import _probe
def is_readable_video(filepath: Path | str):
"""Test if a video is in a readable format. Examples of unreadable videos
@@ -17,7 +15,7 @@ def is_readable_video(filepath: Path | str):
filepath (Path | str):
"""
try:
probe = _probe(Path(filepath))
probe = ffmpeg.probe(Path(filepath))
for stream in probe["streams"]:
# DRM check
if stream.get("codec_tag_string") in [

View File

@@ -9,7 +9,17 @@ def four_corner_gradient(
image: Image.Image, size: tuple[int, int], mask: Image.Image
) -> Image.Image:
if image.size != size:
# Old 1 color method.
# bg_col = image.copy().resize((1, 1)).getpixel((0,0))
# bg = Image.new(mode='RGB',size=size,color=bg_col)
# bg.thumbnail((1, 1))
# bg = bg.resize(size, resample=Image.Resampling.NEAREST)
# Small gradient background. Looks decent, and is only a one-liner.
# bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize(size,resample=Image.Resampling.BILINEAR)
# Four-Corner Gradient Background.
# Not exactly a one-liner, but it's (subjectively) really cool.
tl = image.getpixel((0, 0))
tr = image.getpixel(((image.size[0] - 1), 0))
bl = image.getpixel((0, (image.size[1] - 1)))
@@ -31,7 +41,13 @@ def four_corner_gradient(
final = Image.new("RGBA", bg.size, (0, 0, 0, 0))
final.paste(bg, mask=mask.getchannel(0))
# bg.putalpha(mask)
# final = bg
else:
# image.putalpha(mask)
# final = image
final = Image.new("RGBA", size, (0, 0, 0, 0))
final.paste(image, mask=mask.getchannel(0))

View File

@@ -4,7 +4,7 @@
# https://creativecommons.org/licenses/by-sa/4.0/
# Modified for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtGui import QBrush, QColor, QPainter, QPixmap
from PySide6.QtGui import QPixmap, QPainter, QBrush
from PySide6.QtWidgets import (
QProxyStyle,
)
@@ -18,10 +18,10 @@ class RoundedPixmapStyle(QProxyStyle):
def drawItemPixmap(self, painter, rectangle, alignment, pixmap):
painter.save()
pix = QPixmap(pixmap.size())
pix.fill(QColor("transparent"))
pix.fill("#00000000")
p = QPainter(pix)
p.setBrush(QBrush(pixmap))
p.setPen(QColor("transparent"))
p.setPen("#00000000")
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.drawRoundedRect(pixmap.rect(), self._radius, self._radius)
p.end()

View File

@@ -1,64 +0,0 @@
import subprocess
import sys
def promptless_Popen(
args,
bufsize=-1,
executable=None,
stdin=None,
stdout=None,
stderr=None,
preexec_fn=None,
close_fds=True,
shell=False,
cwd=None,
env=None,
universal_newlines=None,
startupinfo=None,
restore_signals=True,
start_new_session=False,
pass_fds=(),
*,
group=None,
extra_groups=None,
user=None,
umask=-1,
encoding=None,
errors=None,
text=None,
pipesize=-1,
process_group=None,
):
creation_flags = 0
if sys.platform == "win32":
creation_flags = subprocess.CREATE_NO_WINDOW
return subprocess.Popen(
args=args,
bufsize=bufsize,
executable=executable,
stdin=stdin,
stdout=stdout,
stderr=stderr,
preexec_fn=preexec_fn,
close_fds=close_fds,
shell=shell,
cwd=cwd,
env=env,
universal_newlines=universal_newlines,
startupinfo=startupinfo,
creationflags=creation_flags,
restore_signals=restore_signals,
start_new_session=start_new_session,
pass_fds=pass_fds,
group=group,
extra_groups=extra_groups,
user=user,
umask=umask,
encoding=encoding,
errors=errors,
text=text,
pipesize=pipesize,
process_group=process_group,
)

View File

@@ -1,34 +0,0 @@
# Copyright (C) 2022 Karl Kroening (kkroening).
# Licensed under the GPL-3.0 License.
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
import subprocess
import json
import sys
import ffmpeg
from src.qt.helpers.silent_popen import promptless_Popen
def _probe(filename, cmd='ffprobe', timeout=None, **kwargs):
"""Run ffprobe on the specified file and return a JSON representation of the output.
Raises:
:class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code,
an :class:`Error` is returned with a generic error message.
The stderr output can be retrieved by accessing the
``stderr`` property of the exception.
"""
args = [cmd, '-show_format', '-show_streams', '-of', 'json']
args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs)
args += [filename]
# PATCHED
p = promptless_Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
communicate_kwargs = {}
if timeout is not None:
communicate_kwargs['timeout'] = timeout
out, err = p.communicate(**communicate_kwargs)
if p.returncode != 0:
raise ffmpeg.Error('ffprobe', out, err)
return json.loads(out.decode('utf-8'))

File diff suppressed because it is too large Load Diff

View File

@@ -1,88 +0,0 @@
import json
import re
import subprocess
from pydub.utils import (
get_prober_name,
fsdecode,
_fd_or_path_or_tempfile,
get_extra_info,
)
from src.qt.helpers.silent_popen import promptless_Popen
def _mediainfo_json(filepath, read_ahead_limit=-1):
"""Return json dictionary with media info(codec, duration, size, bitrate...) from filepath
"""
prober = get_prober_name()
command_args = [
"-v", "info",
"-show_format",
"-show_streams",
]
try:
command_args += [fsdecode(filepath)]
stdin_parameter = None
stdin_data = None
except TypeError:
if prober == 'ffprobe':
command_args += ["-read_ahead_limit", str(read_ahead_limit),
"cache:pipe:0"]
else:
command_args += ["-"]
stdin_parameter = subprocess.PIPE
file, close_file = _fd_or_path_or_tempfile(filepath, 'rb', tempfile=False)
file.seek(0)
stdin_data = file.read()
if close_file:
file.close()
command = [prober, '-of', 'json'] + command_args
# PATCHED
res = promptless_Popen(command, stdin=stdin_parameter, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, stderr = res.communicate(input=stdin_data)
output = output.decode("utf-8", 'ignore')
stderr = stderr.decode("utf-8", 'ignore')
try:
info = json.loads(output)
except json.decoder.JSONDecodeError:
# If ffprobe didn't give any information, just return it
# (for example, because the file doesn't exist)
return None
if not info:
return info
extra_info = get_extra_info(stderr)
audio_streams = [x for x in info['streams'] if x['codec_type'] == 'audio']
if len(audio_streams) == 0:
return info
# We just operate on the first audio stream in case there are more
stream = audio_streams[0]
def set_property(stream, prop, value):
if prop not in stream or stream[prop] == 0:
stream[prop] = value
for token in extra_info[stream['index']]:
m = re.match(r'([su]([0-9]{1,2})p?) \(([0-9]{1,2}) bit\)$', token)
m2 = re.match(r'([su]([0-9]{1,2})p?)( \(default\))?$', token)
if m:
set_property(stream, 'sample_fmt', m.group(1))
set_property(stream, 'bits_per_sample', int(m.group(2)))
set_property(stream, 'bits_per_raw_sample', int(m.group(3)))
elif m2:
set_property(stream, 'sample_fmt', m2.group(1))
set_property(stream, 'bits_per_sample', int(m2.group(2)))
set_property(stream, 'bits_per_raw_sample', int(m2.group(2)))
elif re.match(r'(flt)p?( \(default\))?$', token):
set_property(stream, 'sample_fmt', token)
set_property(stream, 'bits_per_sample', 32)
set_property(stream, 'bits_per_raw_sample', 32)
elif re.match(r'(dbl)p?( \(default\))?$', token):
set_property(stream, 'sample_fmt', token)
set_property(stream, 'bits_per_sample', 64)
set_property(stream, 'bits_per_raw_sample', 64)
return info

View File

@@ -5,28 +5,30 @@
import logging
from PySide6.QtCore import Qt, Signal
from PySide6.QtCore import Signal, Qt
from PySide6.QtWidgets import (
QComboBox,
QFrame,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QTextEdit,
QVBoxLayout,
QWidget,
QVBoxLayout,
QLabel,
QPushButton,
QLineEdit,
QScrollArea,
QFrame,
QTextEdit,
QComboBox,
)
from src.core.constants import TAG_COLORS
from src.core.library import Library, Tag
from src.core.palette import ColorType, get_tag_color
from src.qt.modals.tag_search import TagSearchPanel
from src.qt.widgets.panel import PanelModal, PanelWidget
from src.core.constants import TAG_COLORS
from src.qt.widgets.panel import PanelWidget, PanelModal
from src.qt.widgets.tag import TagWidget
from src.qt.modals.tag_search import TagSearchPanel
ERROR = "[ERROR]"
WARNING = "[WARNING]"
INFO = "[INFO]"
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
@@ -34,7 +36,7 @@ logging.basicConfig(format="%(message)s", level=logging.INFO)
class BuildTagPanel(PanelWidget):
on_edit = Signal(Tag)
def __init__(self, library, tag_id: int = -1, tag_name: str = "New Tag"):
def __init__(self, library, tag_id: int = -1):
super().__init__()
self.lib: Library = library
# self.callback = callback
@@ -115,7 +117,7 @@ class BuildTagPanel(PanelWidget):
self.subtags_add_button = QPushButton()
self.subtags_add_button.setText("+")
tsp = TagSearchPanel(self.lib)
tsp.tag_created.connect(lambda x: self.add_subtag_callback(x))
tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x))
self.add_tag_modal = PanelModal(tsp, "Add Parent Tags", "Add Parent Tags")
self.subtags_add_button.clicked.connect(self.add_tag_modal.show)
self.subtags_layout.addWidget(self.subtags_add_button)
@@ -161,7 +163,7 @@ class BuildTagPanel(PanelWidget):
if tag_id >= 0:
self.tag = self.lib.get_tag(tag_id)
else:
self.tag = Tag(-1, tag_name, "", [], [], "")
self.tag = Tag(-1, "New Tag", "", [], [], "")
self.set_tag(self.tag)
def add_subtag_callback(self, tag_id: int):

View File

@@ -106,7 +106,6 @@ 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
@@ -116,12 +115,14 @@ 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(full_dest_path)
self.driver.lib.files_not_in_library.append(dest_file)
else: # override is simply copying but not adding a new entry
self.driver.lib.files_not_in_library.append(full_dest_path)
self.driver.lib.files_not_in_library.append(dest_file)
(full_dest_path).parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(file, 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)
fileCount += 1
yield [fileCount, duplicated_files_progress]

View File

@@ -1,65 +0,0 @@
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))

View File

@@ -342,8 +342,8 @@ class ModifiedTagWidget(
f"font-weight: 600;"
f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: {math.ceil(self.devicePixelRatio())}px;"
f"border-style:inset;"
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
f"padding-right: 4px;"
f"padding-bottom: 1px;"
f"padding-left: 4px;"

View File

@@ -0,0 +1,63 @@
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QComboBox, QPushButton
from PySide6.QtCore import Qt
import logging
class MergeTagModal(QDialog):
def __init__(self, library, current_tag):
super().__init__()
self.lib = library
self.current_tag = current_tag
self.init_ui()
def init_ui(self):
layout = QVBoxLayout()
layout.addWidget(QLabel("Selected tag:"))
self.selected_tag_dropdown = QComboBox(self)
for tag in self.lib.tags:
self.selected_tag_dropdown.addItem(tag.display_name(self.lib), tag)
self.selected_tag_dropdown.setCurrentIndex(self.find_current_tag_index())
self.selected_tag_dropdown.currentIndexChanged.connect(self.update_current_tag)
layout.addWidget(self.selected_tag_dropdown)
arrow_label = QLabel("")
arrow_label.setAlignment(Qt.AlignCenter)
layout.addWidget(arrow_label)
layout.addWidget(QLabel("Select tag to merge with:"))
self.tag2_dropdown = QComboBox(self)
self.update_tag2_dropdown()
layout.addWidget(self.tag2_dropdown)
self.merge_button = QPushButton("Merge", self)
layout.addWidget(self.merge_button)
self.merge_button.clicked.connect(self.merge_tags)
self.setLayout(layout)
def find_current_tag_index(self):
for index in range(self.selected_tag_dropdown.count()):
if self.selected_tag_dropdown.itemData(index) == self.current_tag:
return index
return 0
def update_current_tag(self):
self.current_tag = self.selected_tag_dropdown.currentData()
self.update_tag2_dropdown()
def update_tag2_dropdown(self):
self.tag2_dropdown.clear()
for tag in self.lib.tags:
if tag.id != self.current_tag.id:
self.tag2_dropdown.addItem(tag.display_name(self.lib), tag)
def merge_tags(self):
target_tag = self.tag2_dropdown.currentData()
if target_tag and self.current_tag != target_tag:
self.lib.merge_tag(self.current_tag, target_tag)
self.accept()
else:
logging.error("MergeTagModal: Invalid tag selection.")
self.reject()

View File

@@ -2,37 +2,33 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import typing
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtCore import Signal, Qt, QSize
from PySide6.QtWidgets import (
QFrame,
QWidget,
QVBoxLayout,
QHBoxLayout,
QLineEdit,
QScrollArea,
QVBoxLayout,
QWidget,
QFrame,
)
from src.core.constants import TAG_COLORS
from src.qt.modals.build_tag import BuildTagPanel
from src.qt.widgets.panel import PanelModal, PanelWidget
from src.qt.widgets.tag import TagWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.core.library import Library, Tag
from src.qt.ts_qt import QtDriver
from src.core.constants import TAG_COLORS
from src.core.library import Library
from src.qt.widgets.panel import PanelWidget, PanelModal
from src.qt.widgets.tag import TagWidget
from src.qt.modals.build_tag import BuildTagPanel
class TagDatabasePanel(PanelWidget):
tag_chosen = Signal(int)
def __init__(self, library: "Library", driver: "QtDriver"):
def __init__(self, library):
super().__init__()
self.lib: Library = library
self.driver: QtDriver = driver
# self.callback = callback
self.first_tag_id = -1
self.tag_limit = 30
# self.selected_tag: int = 0
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
@@ -137,14 +133,9 @@ class TagDatabasePanel(PanelWidget):
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
tag: Tag = self.lib.get_tag(tag_id)
tw = TagWidget(self.lib, tag, True, False)
tw.on_edit.connect(lambda checked=False, t=tag: (self.edit_tag(t.id)))
tw.on_click.connect(
lambda checked=False, q=f"tag_id: {tag_id}": (
self.driver.main_window.searchField.setText(q),
self.driver.filter_items(q),
)
tw = TagWidget(self.lib, self.lib.get_tag(tag_id), True, False)
tw.on_edit.connect(
lambda checked=False, t=self.lib.get_tag(tag_id): (self.edit_tag(t.id))
)
row.addWidget(tw)
self.scroll_layout.addWidget(container)

View File

@@ -5,38 +5,42 @@
import logging
import math
import src.qt.modals.build_tag as bt
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtCore import Signal, Qt, QSize
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLineEdit,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
QVBoxLayout,
QHBoxLayout,
QPushButton,
QLineEdit,
QScrollArea,
QFrame,
)
from src.core.constants import TAG_COLORS
from src.core.library import Library
from src.core.palette import ColorType, get_tag_color
from src.qt.widgets.panel import PanelModal, PanelWidget
from src.qt.widgets.panel import PanelWidget
from src.qt.widgets.tag import TagWidget
ERROR = "[ERROR]"
WARNING = "[WARNING]"
INFO = "[INFO]"
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
class TagSearchPanel(PanelWidget):
tag_created = Signal(int)
tag_chosen = Signal(int)
def __init__(self, library: "Library"):
def __init__(self, library):
super().__init__()
self.lib: Library = library
self.first_tag_id: int | None = None
# self.callback = callback
self.first_tag_id = None
self.tag_limit = 100
# self.selected_tag: int = 0
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
@@ -52,38 +56,57 @@ class TagSearchPanel(PanelWidget):
lambda checked=False: self.on_return(self.search_field.text())
)
# self.content_container = QWidget()
# self.content_layout = QHBoxLayout(self.content_container)
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
# self.scroll_area.setStyleSheet('background: #000000;')
self.scroll_area.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
)
# self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
# sa.setMaximumWidth(self.preview_size[0])
self.scroll_area.setWidget(self.scroll_contents)
# self.add_button = QPushButton()
# self.root_layout.addWidget(self.add_button)
# self.add_button.setText('Add Tag')
# # self.done_button.clicked.connect(lambda checked=False, x=1101: (callback(x), self.hide()))
# self.add_button.clicked.connect(lambda checked=False, x=1101: callback(x))
# # self.setLayout(self.root_layout)
self.root_layout.addWidget(self.search_field)
self.root_layout.addWidget(self.scroll_area)
self.update_tags("")
# def reset(self):
# self.search_field.setText('')
# self.update_tags('')
# self.search_field.setFocus()
def on_return(self, text: str):
if text and self.first_tag_id is not None:
# callback(self.first_tag_id)
self.tag_created.emit(self.first_tag_id)
self.tag_chosen.emit(self.first_tag_id)
self.search_field.setText("")
self.update_tags()
elif text:
self.create_and_add_tag(text)
self.parentWidget().hide()
else:
self.search_field.setFocus()
self.parentWidget().hide()
def update_tags(self, query: str = ""):
# for c in self.scroll_layout.children():
# c.widget().deleteLater()
while self.scroll_layout.count():
# logging.info(f"I'm deleting { self.scroll_layout.itemAt(0).widget()}")
self.scroll_layout.takeAt(0).widget().deleteLater()
found_tags = self.lib.search_tags(query, include_cluster=True)[: self.tag_limit]
@@ -130,7 +153,10 @@ class TagSearchPanel(PanelWidget):
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
# f'padding-top: 1.5px;'
# f'padding-right: 4px;'
f"padding-bottom: 5px;"
# f'padding-left: 4px;'
f"font-size: 20px;"
f"}}"
f"QPushButton::hover"
@@ -141,95 +167,15 @@ class TagSearchPanel(PanelWidget):
f"}}"
)
ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_created.emit(x))
ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_chosen.emit(x))
l.addWidget(tw)
l.addWidget(ab)
self.scroll_layout.addWidget(c)
# Add a create tag button if a query is entered
if query:
c = self.create_tag_button(query)
self.scroll_layout.addWidget(c)
self.search_field.setFocus()
def create_tag_button(self, name: str) -> QWidget:
"""Construct a "Create Tag" button.
Args:
name (str): The name of the tag to give.
"""
c = QWidget()
l = QHBoxLayout(c)
l.setContentsMargins(0, 0, 0, 0)
l.setSpacing(3)
create_and_add_button = QPushButton(self)
create_and_add_button.setFlat(True)
create_and_add_button.setText(
f"Create && Add \"{name.replace("&", "&&")}\" Tag"
)
inner_layout = QHBoxLayout()
inner_layout.setObjectName("innerLayout")
inner_layout.setContentsMargins(2, 2, 2, 2)
create_and_add_button.setLayout(inner_layout)
create_and_add_button.setMinimumSize(math.ceil(22 * 1.5), 22)
create_and_add_button.setStyleSheet(
f"QPushButton{{"
f"background: {get_tag_color(ColorType.PRIMARY, "dark gray")};"
f"color: {get_tag_color(ColorType.TEXT, "dark gray")};"
f"font-weight: 600;"
f"border-color:{get_tag_color(ColorType.BORDER, "dark gray")};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: {math.ceil(self.devicePixelRatio())}px;"
f"padding-right: 4px;"
f"padding-bottom: 1px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, "dark gray")};"
f"}}"
)
create_and_add_button.clicked.connect(
lambda x=name: self.create_and_add_tag(name)
)
l.addWidget(create_and_add_button)
return c
def create_and_add_tag(self, name: str):
"""Open "Add Tag" panel to create and add a new tag with the given name.
Args:
name (str): The name of the tag to give.
"""
self.add_tag_modal = PanelModal(
bt.BuildTagPanel(self.lib, tag_name=name),
"New Tag",
"Add Tag",
has_save=True,
)
self.add_tag_modal.saved.connect(lambda n=name: self.on_tag_modal_saved(n))
self.add_tag_modal.save_button.setFocus()
self.add_tag_modal.show()
def on_tag_modal_saved(self, name):
"""Callback for actions to perform when a new "Create & Add" tag is confirmed.
Args:
name (str): The name of the tag to give.
"""
panel: bt.BuildTagPanel = self.add_tag_modal.widget
self.tag_created.emit(self.lib.add_tag_to_library(panel.build_tag()))
self.add_tag_modal.hide()
self.update_tags(name)
def showEvent(self, event):
# Clear search field and focus when showing modal
self.search_field.setText("")
self.search_field.setFocus()
# def enterEvent(self, event: QEnterEvent) -> None:
# self.search_field.setFocus()
# return super().enterEvent(event)
# self.focusOutEvent

File diff suppressed because it is too large Load Diff

View File

@@ -92,7 +92,6 @@ 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
@@ -293,20 +292,8 @@ class QtDriver(QObject):
splash_pixmap = QPixmap(":/images/splash.png")
splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio())
splash_pixmap = splash_pixmap.scaledToWidth(
math.floor(
min(
(
QGuiApplication.primaryScreen().geometry().width()
* self.main_window.devicePixelRatio()
)
/ 4,
splash_pixmap.width(),
)
),
Qt.TransformationMode.SmoothTransformation,
)
self.splash = QSplashScreen(splash_pixmap, Qt.WindowStaysOnTopHint) # type: ignore
# self.splash.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.splash.show()
if os.name == "nt":
@@ -495,24 +482,14 @@ class QtDriver(QObject):
window_menu.addAction(check_action)
# Tools Menu ===========================================================
def create_fix_unlinked_entries_modal():
"""Postpone the creation of the modal until it is needed."""
if not hasattr(self, "unlinked_modal"):
self.unlinked_modal = FixUnlinkedEntriesModal(self.lib, self)
self.unlinked_modal.show()
fix_unlinked_entries_action = QAction("Fix &Unlinked Entries", menu_bar)
fix_unlinked_entries_action.triggered.connect(create_fix_unlinked_entries_modal)
fue_modal = FixUnlinkedEntriesModal(self.lib, self)
fix_unlinked_entries_action.triggered.connect(lambda: fue_modal.show())
tools_menu.addAction(fix_unlinked_entries_action)
def create_dupe_files_modal():
"""Postpone the creation of the modal until it is needed."""
if not hasattr(self, "dupe_modal"):
self.dupe_modal = FixDupeFilesModal(self.lib, self)
self.dupe_modal.show()
fix_dupe_files_action = QAction("Fix Duplicate &Files", menu_bar)
fix_dupe_files_action.triggered.connect(create_dupe_files_modal)
fdf_modal = FixDupeFilesModal(self.lib, self)
fix_dupe_files_action.triggered.connect(lambda: fdf_modal.show())
tools_menu.addAction(fix_dupe_files_action)
create_collage_action = QAction("Create Collage", menu_bar)
@@ -563,14 +540,9 @@ class QtDriver(QObject):
)
window_menu.addAction(show_libs_list_action)
def create_folders_tags_modal():
"""Postpone the creation of the modal until it is needed."""
if not hasattr(self, "folders_modal"):
self.folders_modal = FoldersToTagsModal(self.lib, self)
self.folders_modal.show()
folders_to_tags_action = QAction("Folders to Tags", menu_bar)
folders_to_tags_action.triggered.connect(create_folders_tags_modal)
ftt_modal = FoldersToTagsModal(self.lib, self)
folders_to_tags_action.triggered.connect(lambda: ftt_modal.show())
macros_menu.addAction(folders_to_tags_action)
# Help Menu ============================================================
@@ -640,9 +612,6 @@ 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()
@@ -856,10 +825,7 @@ class QtDriver(QObject):
def show_tag_database(self):
self.modal = PanelModal(
TagDatabasePanel(self.lib, self),
"Library Tags",
"Library Tags",
has_save=False,
TagDatabasePanel(self.lib), "Library Tags", "Library Tags", has_save=False
)
self.modal.show()
@@ -900,10 +866,7 @@ class QtDriver(QObject):
pending.append(filepath)
if pending:
return_code = self.delete_file_confirmation(len(pending), pending[0])
logging.info(return_code)
# If there was a confirmation and not a cancellation
if return_code == 2 and return_code != 3:
if self.delete_file_confirmation(len(pending), pending[0]) == 3:
for i, f in enumerate(pending):
if (origin_path == f) or (not origin_path):
self.preview_panel.stop_file_use()
@@ -920,23 +883,25 @@ class QtDriver(QObject):
self.selected.clear()
if deleted_count > 0:
self.refresh_frame(self.nav_frames[self.cur_frame_idx].contents)
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.")
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.")
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 missing or in use."
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!")
else:
self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!")
self.main_window.statusbar.repaint()
def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int:
@@ -947,18 +912,17 @@ class QtDriver(QObject):
filename(Path | None): The filename to show if only one file is to be deleted.
"""
trash_term: str = "Trash"
perm_warning: str = ""
if platform.system() == "Windows":
trash_term = "Recycle Bin"
# NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the Recycle Bin.
# This is done without any warning, so this message is currently the best way I've got to inform the user.
# https://github.com/arsenetar/send2trash/issues/28
# This warning is applied to all platforms until at least macOS and Linux can be verified to
# not exhibit this same behavior.
perm_warning: str = (
f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, 'red')}'>"
f"<b>WARNING!</b> If this file can't be moved to the {trash_term}, "
f"</b>it will be <b>permanently deleted!</b></h4>"
)
# NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the Recycle Bin.
# This is done without any warning, so this message is currently the best way I've got to inform the user.
# https://github.com/arsenetar/send2trash/issues/28
perm_warning = (
f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, 'red')}'>"
f"<b>WARNING!</b> If this file can't be moved to the Recycle Bin, "
f"</b>it will be <b>permanently deleted!</b></h4>"
)
msg = QMessageBox()
msg.setTextFormat(Qt.TextFormat.RichText)
@@ -977,10 +941,8 @@ class QtDriver(QObject):
"<h4>This will remove them from TagStudio <i>AND</i> your file system!</h4>"
f"{perm_warning}<br>"
)
yes_button: QPushButton = msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole)
msg.addButton("&No", QMessageBox.ButtonRole.NoRole)
msg.setDefaultButton(yes_button)
msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole)
return msg.exec()
@@ -1095,13 +1057,7 @@ class QtDriver(QObject):
)
)
r = CustomRunnable(lambda: iterator.run())
r.done.connect(
lambda: (
pw.hide(),
pw.deleteLater(),
self.filter_items(self.main_window.searchField.text()),
)
)
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.filter_items("")))
QThreadPool.globalInstance().start(r)
def new_file_macros_runnable(self, new_ids):
@@ -1131,7 +1087,7 @@ class QtDriver(QObject):
"""Runs a specific Macro on an Entry given a Macro name."""
entry = self.lib.get_entry(entry_id)
path = self.lib.library_dir / entry.path / entry.filename
source = "" if entry.path == Path(".") else entry.path.parts[0].lower()
source = entry.path.parts[0]
if name == "sidecar":
self.lib.add_generic_data_to_entry(
self.core.get_gdl_sidecar(path, source), entry_id
@@ -1290,8 +1246,6 @@ class QtDriver(QObject):
Args:
index (int): The index of the item_thumbs/ComboBox list to use.
"""
SPACING_DIVISOR: int = 10
MIN_SPACING: int = 12
# Index 2 is the default (Medium)
if index < len(self.thumb_sizes) and index >= 0:
self.thumb_size = self.thumb_sizes[index][1]
@@ -1310,9 +1264,7 @@ class QtDriver(QObject):
it.setMinimumSize(self.thumb_size, self.thumb_size)
it.setMaximumSize(self.thumb_size, self.thumb_size)
it.thumb_button.thumb_size = (self.thumb_size, self.thumb_size)
self.flow_container.layout().setSpacing(
min(self.thumb_size // SPACING_DIVISOR, MIN_SPACING)
)
self.flow_container.layout().setSpacing(min(self.thumb_size // 10, 12))
def mouse_navigation(self, event: QMouseEvent):
# print(event.button())
@@ -1856,12 +1808,6 @@ 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."""

View File

@@ -3,7 +3,7 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import math
import os
import traceback
from pathlib import Path
@@ -12,15 +12,25 @@ from PIL import Image, ImageChops, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from PySide6.QtCore import (
QObject,
QThread,
Signal,
QRunnable,
Qt,
QThreadPool,
QSize,
QEvent,
QTimer,
QSettings,
)
from src.core.library import Library
from src.core.media_types import MediaCategories, MediaType
from src.qt.helpers.file_tester import is_readable_video
ERROR = "[ERROR]"
WARNING = "[WARNING]"
INFO = "[INFO]"
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
@@ -44,6 +54,7 @@ class CollageIconRenderer(QObject):
):
entry = self.lib.get_entry(entry_id)
filepath = self.lib.library_dir / entry.path / entry.filename
file_type = os.path.splitext(filepath)[1].lower()[1:]
color: str = ""
try:
@@ -75,11 +86,14 @@ class CollageIconRenderer(QObject):
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:
logging.info(
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}/{entry.filename}\033[0m"
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}{os.sep}{entry.filename}\033[0m"
)
# sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}')
# sys.stdout.flush()
ext: str = filepath.suffix.lower()
if MediaType.IMAGE in MediaCategories.get_types(ext):
try:
@@ -95,6 +109,7 @@ class CollageIconRenderer(QObject):
pic = ImageChops.hard_light(
pic, Image.new("RGB", size, color)
)
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
except DecompressionBombError as e:
logging.info(f"[ERROR] One of the images was too big ({e})")
@@ -106,22 +121,12 @@ class CollageIconRenderer(QObject):
(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)),
),
):
if not success:
# 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.
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
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:
@@ -132,9 +137,12 @@ class CollageIconRenderer(QObject):
pic = ImageChops.hard_light(
pic, Image.new("RGB", size, color)
)
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
except (UnidentifiedImageError, FileNotFoundError):
logging.info(f"\n{ERROR} Couldn't read {entry.path}/{entry.filename}")
logging.info(
f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}"
)
with Image.open(
str(
Path(__file__).parents[2]
@@ -145,16 +153,22 @@ class CollageIconRenderer(QObject):
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:
# self.quit(save=False, backup=True)
run = False
# clear()
logging.info("\n")
logging.info(f"{INFO} Collage operation cancelled.")
except Exception:
logging.info(f"{ERROR} {entry.path}/{entry.filename}")
clear_scr = False
except:
logging.info(f"{ERROR} {entry.path}{os.sep}{entry.filename}")
traceback.print_exc()
logging.info("Continuing...")
self.done.emit()
# logging.info('Done!')
# NOTE: Depreciated
def get_file_color(self, ext: str):

View File

@@ -4,14 +4,15 @@
import math
import os
from types import FunctionType, MethodType
from pathlib import Path
from typing import Optional, cast, Callable
from typing import Optional, cast, Callable, Any
from PIL import Image, ImageQt
from PySide6.QtCore import Qt, QEvent
from PySide6.QtGui import QPixmap, QEnterEvent
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
from src.qt.helpers.color_overlay import theme_fg_overlay
@@ -35,17 +36,17 @@ class FieldContainer(QWidget):
def __init__(self, title: str = "Field", inline: bool = True) -> None:
super().__init__()
# self.mode:str = mode
self.setObjectName("fieldContainer")
# self.item = item
self.title: str = title
self.inline: bool = inline
# self.editable:bool = editable
self.copy_callback: FunctionType = None
self.edit_callback: FunctionType = None
self.remove_callback: Callable = None
button_size = 24
self.clipboard_icon_128 = theme_fg_overlay(FieldContainer.clipboard_icon_128)
self.edit_icon_128 = theme_fg_overlay(FieldContainer.edit_icon_128)
self.trash_icon_128 = theme_fg_overlay(FieldContainer.trash_icon_128)
# self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;')
self.clipboard_icon_128 = theme_fg_overlay(FieldContainer.clipboard_icon_128)
self.edit_icon_128 = theme_fg_overlay(FieldContainer.edit_icon_128)
@@ -54,6 +55,7 @@ class FieldContainer(QWidget):
self.root_layout = QVBoxLayout(self)
self.root_layout.setObjectName("baseLayout")
self.root_layout.setContentsMargins(0, 0, 0, 0)
# self.setStyleSheet('background-color:red;')
self.inner_layout = QVBoxLayout()
self.inner_layout.setObjectName("innerLayout")
@@ -65,6 +67,7 @@ class FieldContainer(QWidget):
self.root_layout.addWidget(self.inner_container)
self.title_container = QWidget()
# self.title_container.setStyleSheet('background:black;')
self.title_layout = QHBoxLayout(self.title_container)
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.title_layout.setObjectName("fieldLayout")
@@ -77,7 +80,9 @@ class FieldContainer(QWidget):
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet("font-weight: bold; font-size: 14px;")
# self.title_widget.setStyleSheet('background-color:orange;')
self.title_widget.setText(title)
# self.inner_layout.addWidget(self.title_widget)
self.title_layout.addWidget(self.title_widget)
self.title_layout.addStretch(2)
@@ -119,8 +124,11 @@ class FieldContainer(QWidget):
self.field_layout.setObjectName("fieldLayout")
self.field_layout.setContentsMargins(0, 0, 0, 0)
self.field_container.setLayout(self.field_layout)
# self.field_container.setStyleSheet('background-color:#666600;')
self.inner_layout.addWidget(self.field_container)
# self.set_inner_widget(mode)
def set_copy_callback(self, callback: Optional[MethodType]):
if self.copy_button.is_connected:
self.copy_button.clicked.disconnect()
@@ -152,7 +160,12 @@ class FieldContainer(QWidget):
self.remove_button.is_connected = True
def set_inner_widget(self, widget: "FieldWidget"):
# widget.setStyleSheet('background-color:green;')
# self.inner_container.dumpObjectTree()
# logging.info('')
if self.field_layout.itemAt(0):
# logging.info(f'Removing {self.field_layout.itemAt(0)}')
# self.field_layout.removeItem(self.field_layout.itemAt(0))
self.field_layout.itemAt(0).widget().deleteLater()
self.field_layout.addWidget(widget)
@@ -168,7 +181,12 @@ class FieldContainer(QWidget):
def set_inline(self, inline: bool):
self.inline = inline
# def set_editable(self, editable:bool):
# self.editable = editable
def enterEvent(self, event: QEnterEvent) -> None:
# if self.field_layout.itemAt(1):
# self.field_layout.itemAt(1).
# NOTE: You could pass the hover event to the FieldWidget if needed.
if self.copy_callback:
self.copy_button.setHidden(False)
@@ -193,4 +211,5 @@ class FieldWidget(QWidget):
def __init__(self, title) -> None:
super().__init__()
# self.item = item
self.title = title

View File

@@ -4,7 +4,6 @@
import contextlib
import logging
import os
import platform
import time
import typing
from pathlib import Path
@@ -197,16 +196,7 @@ class ItemThumb(FlowWidget):
self.opener = FileOpenerHelper("")
open_file_action = QAction("Open file", self)
open_file_action.triggered.connect(self.opener.open_file)
system = platform.system()
open_explorer_action = QAction(
"Open in explorer", self
) # Default (mainly going to be for linux)
if system == "Darwin":
open_explorer_action = QAction("Reveal in Finder", self)
elif system == "Windows":
open_explorer_action = QAction("Open in Explorer", self)
open_explorer_action = QAction("Open file in explorer", self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
trash_term: str = "Trash"

View File

@@ -3,7 +3,6 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import os
from pathlib import Path
import platform
import time
@@ -99,35 +98,13 @@ class PreviewPanel(QWidget):
image_layout = QHBoxLayout(self.image_container)
image_layout.setContentsMargins(0, 0, 0, 0)
file_label_style = "font-size: 12px"
properties_style = (
f"background-color:{self.label_bg_color};"
"color:#FFFFFF;"
"font-family:Oxanium;"
"font-weight:bold;"
"font-size:12px;"
"border-radius:3px;"
"padding-top: 4px;"
"padding-right: 1px;"
"padding-bottom: 1px;"
"padding-left: 1px;"
)
date_style = "font-size:12px;"
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.open_explorer_action = QAction(
"Open in explorer", self
) # Default text (Linux, etc.)
if platform.system() == "Darwin":
self.open_explorer_action = QAction("Reveal in Finder", self)
elif platform.system() == "Windows":
self.open_explorer_action = QAction("Open in Explorer", self)
self.preview_img = QPushButtonWrapper()
self.preview_img.setMinimumSize(*self.img_button_size)
self.preview_img.setFlat(True)
@@ -173,26 +150,31 @@ class PreviewPanel(QWidget):
image_layout.addWidget(self.preview_vid)
image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter)
self.image_container.setMinimumSize(*self.img_button_size)
self.file_label = FileOpenerLabel("filename")
self.file_label.setTextFormat(Qt.TextFormat.RichText)
self.file_label = FileOpenerLabel("Filename")
self.file_label.setWordWrap(True)
self.file_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
self.file_label.setStyleSheet(file_label_style)
self.file_label.setStyleSheet("font-weight: bold; font-size: 12px")
self.date_created_label = QLabel("dateCreatedLabel")
self.date_created_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.date_created_label.setTextFormat(Qt.TextFormat.RichText)
self.date_created_label.setStyleSheet(date_style)
self.date_modified_label = QLabel("dateModifiedLabel")
self.date_modified_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.date_modified_label.setTextFormat(Qt.TextFormat.RichText)
self.date_modified_label.setStyleSheet(date_style)
self.dimensions_label = QLabel("dimensionsLabel")
self.dimensions_label = QLabel("Dimensions")
self.dimensions_label.setWordWrap(True)
# self.dim_label.setTextInteractionFlags(
# Qt.TextInteractionFlag.TextSelectableByMouse)
properties_style = (
f"background-color:{self.label_bg_color};"
"color:#FFFFFF;"
"font-family:Oxanium;"
"font-weight:bold;"
"font-size:12px;"
"border-radius:3px;"
"padding-top: 4px;"
"padding-right: 1px;"
"padding-bottom: 1px;"
"padding-left: 1px;"
)
self.dimensions_label.setStyleSheet(properties_style)
self.scroll_layout = QVBoxLayout()
@@ -230,15 +212,7 @@ class PreviewPanel(QWidget):
)
scroll_area.setWidget(scroll_container)
date_container = QWidget()
date_layout = QVBoxLayout(date_container)
date_layout.setContentsMargins(0, 2, 0, 0)
date_layout.setSpacing(0)
date_layout.addWidget(self.date_created_label)
date_layout.addWidget(self.date_modified_label)
info_layout.addWidget(self.file_label)
info_layout.addWidget(date_container)
info_layout.addWidget(self.dimensions_label)
info_layout.addWidget(scroll_area)
@@ -489,32 +463,6 @@ class PreviewPanel(QWidget):
for field_item in field_list:
self.lib.add_field_to_entry(item_id, field_item.row())
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 = 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")}"
)
self.date_modified_label.setText(
f"<b>Date Modified:</b> {dt.strftime(modified, "%a, %x, %X")}"
)
self.date_created_label.setHidden(False)
self.date_modified_label.setHidden(False)
elif filepath:
self.date_created_label.setText("<b>Date Created:</b> <i>N/A</i>")
self.date_modified_label.setText("<b>Date Modified:</b> <i>N/A</i>")
self.date_created_label.setHidden(False)
self.date_modified_label.setHidden(False)
else:
self.date_created_label.setHidden(True)
self.date_modified_label.setHidden(True)
# def update_widgets(self, item: Union[Entry, Collation, Tag]):
def update_widgets(self):
"""
@@ -531,12 +479,11 @@ class PreviewPanel(QWidget):
# 0 Selected Items
if not self.driver.selected:
if self.selected or not self.initialized:
self.file_label.setText("<i>No Items Selected</i>")
self.file_label.setText("No Items Selected")
self.file_label.setFilePath("")
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
self.dimensions_label.setText("")
self.update_date_label()
self.preview_img.setContextMenuPolicy(
Qt.ContextMenuPolicy.NoContextMenu
)
@@ -592,17 +539,7 @@ class PreviewPanel(QWidget):
ratio,
update_on_ratio_change=True,
)
file_str: str = ""
separator: str = (
f"<a style='color: #777777'><b>{os.path.sep}</a>" # Gray
)
for i, part in enumerate(filepath.parts):
part_ = part.strip(os.path.sep)
if i != len(filepath.parts) - 1:
file_str += f"{"\u200b".join(part_)}{separator}</b>"
else:
file_str += f"<br><b>{"\u200b".join(part_)}</b>"
self.file_label.setText(file_str)
self.file_label.setText("\u200b".join(str(filepath)))
self.file_label.setCursor(Qt.CursorShape.PointingHandCursor)
self.preview_img.setContextMenuPolicy(
@@ -731,7 +668,6 @@ class PreviewPanel(QWidget):
self.dimensions_label.setText(
f"{ext.upper()[1:]}{format_size(filepath.stat().st_size)}"
)
self.update_date_label(filepath)
if not filepath.is_file():
raise FileNotFoundError
@@ -741,14 +677,12 @@ class PreviewPanel(QWidget):
logging.info(
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
)
self.update_date_label()
except (FileNotFoundError, cv2.error) as e:
self.dimensions_label.setText(f"{ext.upper()[1:]}")
logging.info(
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
)
self.update_date_label()
except (
UnidentifiedImageError,
DecompressionBombError,
@@ -759,7 +693,6 @@ class PreviewPanel(QWidget):
logging.info(
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
)
self.update_date_label(filepath)
# TODO: Implement a clickable label to use for the GIF preview.
if self.preview_img.is_connected:
@@ -795,11 +728,8 @@ class PreviewPanel(QWidget):
self.preview_gif.hide()
self.preview_vid.stop()
self.preview_vid.hide()
self.update_date_label()
if self.selected != self.driver.selected:
self.file_label.setText(
f"<b>{len(self.driver.selected)}</b> Items Selected"
)
self.file_label.setText(f"{len(self.driver.selected)} Items Selected")
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
self.file_label.setFilePath("")
self.dimensions_label.setText("")

View File

@@ -15,6 +15,7 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton
from src.core.library import Library, Tag
from src.core.palette import ColorType, get_tag_color
from src.qt.modals.merge_tag import MergeTagModal
ERROR = f"[ERROR]"
@@ -74,6 +75,12 @@ class TagWidget(QWidget):
# search_for_tag_action.triggered.connect(on_click_callback)
search_for_tag_action.triggered.connect(self.on_click.emit)
self.bg_button.addAction(search_for_tag_action)
add_to_search_action = QAction("Add to Search", self)
self.bg_button.addAction(add_to_search_action)
merge_tag_action = QAction("Merge Tag", self)
merge_tag_action.triggered.connect(self.show_merge_tag_modal)
self.bg_button.addAction(merge_tag_action)
self.inner_layout = QHBoxLayout()
self.inner_layout.setObjectName("innerLayout")
@@ -232,6 +239,9 @@ class TagWidget(QWidget):
# pass
# # self.bg.clicked.connect(lambda checked=False, filepath=filepath: open_file(filepath))
# # self.bg.clicked.connect(function)
def show_merge_tag_modal(self):
modal = MergeTagModal(self.lib, self.tag)
modal.exec_()
def enterEvent(self, event: QEnterEvent) -> None:
if self.has_remove:

View File

@@ -93,7 +93,7 @@ class TagBoxWidget(FieldWidget):
f"}}"
)
tsp = TagSearchPanel(self.lib)
tsp.tag_created.connect(lambda x: self.add_tag_callback(x))
tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x))
self.add_modal = PanelModal(tsp, title, "Add Tags")
self.add_button.clicked.connect(
lambda: (tsp.update_tags(), self.add_modal.show()) # type: ignore

View File

@@ -8,7 +8,6 @@ import math
from copy import deepcopy
from io import BytesIO
from pathlib import Path
import struct
import cv2
import numpy as np
@@ -27,8 +26,7 @@ from PIL import (
)
from PIL.Image import DecompressionBombError
from pillow_heif import register_avif_opener, register_heif_opener
from pydub import exceptions
from src.qt.helpers.vendored.pydub.audio_segment import _AudioSegment as AudioSegment # type: ignore
from pydub import AudioSegment, exceptions
from PySide6.QtCore import QObject, QSize, Qt, Signal
from PySide6.QtGui import QGuiApplication, QPixmap
from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT
@@ -41,7 +39,6 @@ from src.qt.helpers.file_tester import is_readable_video
from src.qt.helpers.gradient import four_corner_gradient
from src.qt.helpers.text_wrapper import wrap_full_text
from src.qt.resource_manager import ResourceManager
from vtf2img import Parser
ImageFile.LOAD_TRUNCATED_IMAGES = True
@@ -57,6 +54,13 @@ class ThumbRenderer(QObject):
updated = Signal(float, QPixmap, QSize, str)
updated_ratio = Signal(float)
# TODO: Make dynamic font sizes given different pixel ratios
FONT_PIXEL_RATIO: float = 1
ext_font = ImageFont.truetype(
Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf",
math.floor(12 * FONT_PIXEL_RATIO),
)
def __init__(self) -> None:
"""Initialize the class."""
super().__init__()
@@ -108,10 +112,9 @@ class ThumbRenderer(QObject):
pixel_ratio (float): The screen pixel ratio.
scale_radius (bool): Option to scale the radius up (Used for Preview Panel).
"""
THUMB_SCALE: int = 512
radius_scale: float = 1
if scale_radius:
radius_scale = max(size[0], size[1]) / THUMB_SCALE
radius_scale = max(size[0], size[1]) / 512
item: Image.Image = self.thumb_masks.get((*size, pixel_ratio, radius_scale))
if not item:
@@ -410,9 +413,9 @@ class ThumbRenderer(QObject):
faded (bool): Whether or not to apply a faded version of the edge.
Used for light themes.
"""
opacity: float = 1.0 if not faded else 0.8
opacity: float = 0.75 if not faded else 0.6
shade_reduction: float = (
0
0.15
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else 0.3
)
@@ -565,7 +568,6 @@ 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:
@@ -604,33 +606,6 @@ class ThumbRenderer(QObject):
)
return im
def _source_engine(self, filepath: Path) -> Image.Image:
"""
This is a function to convert the VTF (Valve Texture Format) files to thumbnails, it works using the VTF2IMG library for PILLOW.
"""
parser = Parser(filepath)
im: Image.Image = None
try:
im = parser.get_image()
except (
AttributeError,
UnidentifiedImageError,
FileNotFoundError,
TypeError,
struct.error,
) as e:
if str(e) == "expected string or buffer":
logging.info(
f"[ThumbRenderer][VTF][INFO] {filepath.name} Doesn't have an embedded thumbnail. ({type(e).__name__})"
)
else:
logging.error(
f"[ThumbRenderer][VTF][ERROR]: Couldn't render thumbnail for {filepath.name} ({type(e).__name__})"
)
return im
def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image:
"""Render a small font preview ("Aa") thumbnail from a font file.
@@ -882,21 +857,12 @@ class ThumbRenderer(QObject):
cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
)
# 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
success, frame = video.read()
if not success:
# 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.
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
im = Image.fromarray(frame)
except (
@@ -951,6 +917,13 @@ class ThumbRenderer(QObject):
"thumb_loading", theme_color, (adj_size, adj_size), pixel_ratio
)
if ThumbRenderer.FONT_PIXEL_RATIO != pixel_ratio:
ThumbRenderer.FONT_PIXEL_RATIO = pixel_ratio
ThumbRenderer.ext_font = ImageFont.truetype(
Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf",
math.floor(12 * ThumbRenderer.FONT_PIXEL_RATIO),
)
if is_loading:
final = loading_thumb.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
@@ -1001,14 +974,8 @@ class ThumbRenderer(QObject):
elif MediaType.BLENDER in MediaCategories.get_types(ext):
image = self._blender(_filepath)
# VTF ==========================================================
elif MediaType.SOURCE_ENGINE in MediaCategories.get_types(ext):
image = self._source_engine(_filepath)
# No Rendered Thumbnail ========================================
if not _filepath.exists():
raise FileNotFoundError
elif not image:
if not image:
raise UnidentifiedImageError
orig_x, orig_y = image.size
@@ -1061,8 +1028,6 @@ class ThumbRenderer(QObject):
except (
UnidentifiedImageError,
DecompressionBombError,
ValueError,
ChildProcessError,
) as e:
logging.info(
f"[ThumbRenderer][ERROR]: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})"

View File

@@ -4,7 +4,6 @@
import logging
from pathlib import Path
import platform
import typing
from PySide6.QtCore import (
@@ -82,8 +81,6 @@ class VideoPlayer(QGraphicsView):
self.scene().addItem(self.video_preview)
self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
self.setStyleSheet("border-style:solid;border-width:0px;")
# Set up the video tint.
self.video_tint = self.scene().addRect(
0,
@@ -132,16 +129,7 @@ class VideoPlayer(QGraphicsView):
open_file_action = QAction("Open file", self)
open_file_action.triggered.connect(self.opener.open_file)
system = platform.system()
open_explorer_action = QAction(
"Open in explorer", self
) # Default (mainly going to be for linux)
if system == "Darwin":
open_explorer_action = QAction("Reveal in Finder", self)
elif system == "Windows":
open_explorer_action = QAction("Open in Explorer", self)
open_explorer_action = QAction("Open file in explorer", self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
self.addAction(open_file_action)
self.addAction(open_explorer_action)