Compare commits

..

5 Commits

Author SHA1 Message Date
Timmy Welch
f45231c662 Fix enabling original hash widgets 2025-03-27 19:12:51 -07:00
Timmy Welch
89d82be504 Use proper function names for BS4 2025-03-27 19:01:46 -07:00
Timmy Welch
2eb889a596 Build the release with python 3.12 2025-03-27 18:56:57 -07:00
Timmy Welch
47742ac8ee Download appimage for the current platform 2025-03-27 18:56:38 -07:00
Timmy Welch
08efc75782 Switch to PyQt6 2025-03-27 18:56:00 -07:00
61 changed files with 942 additions and 1372 deletions

View File

@ -15,7 +15,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: [3.9]
python-version: [3.12]
os: [ubuntu-22.04, macos-13, macos-14, windows-latest]
steps:

View File

@ -10,7 +10,7 @@ repos:
- id: name-tests-test
- id: requirements-txt-fixer
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v2.8.0
rev: v2.7.0
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/asottile/pyupgrade
@ -29,11 +29,11 @@ repos:
- id: isort
args: [--af,--add-import, 'from __future__ import annotations']
- repo: https://github.com/psf/black
rev: 25.1.0
rev: 24.4.2
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 7.2.0
rev: 7.1.2
hooks:
- id: flake8
additional_dependencies: [flake8-encodings, flake8-builtins, flake8-print, flake8-no-nested-comprehensions]

View File

@ -19,5 +19,3 @@ pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
kcgthb <kcgthb@users.noreply.github.com>
Kilian Cavalotti <kcgthb@users.noreply.github.com>
David Bugl <david.bugl@gmx.at>
HSN <64664577+N-Hertstein@users.noreply.github.com>
Emmanuel Ferdman <emmanuelferdman@gmail.com>

View File

@ -41,7 +41,7 @@ Please open a [GitHub Pull Request](https://github.com/comictagger/comictagger/p
Currently only python 3.9 is supported however 3.10 will probably work if you try it
Those on linux should install `Pillow` from the system package manager if possible and if the GUI `pyqt5` should be installed from the system package manager
Those on linux should install `Pillow` from the system package manager if possible and if the GUI `PyQt6` should be installed from the system package manager
Those on macOS will need to ensure that you are using python3 in x86 mode either by installing an x86 only version of python or using the universal installer and using `python3-intel64` instead of `python3`

View File

@ -131,13 +131,6 @@ winget install ComicTagger.ComicTagger
<sub><b>abuchanan920</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/N-Hertstein">
<img src="https://avatars.githubusercontent.com/u/64664577?v=4" width="100;" alt="N-Hertstein"/>
<br />
<sub><b>N-Hertstein</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/kcgthb">
<img src="https://avatars.githubusercontent.com/u/186807?v=4" width="100;" alt="kcgthb"/>
@ -165,14 +158,6 @@ winget install ComicTagger.ComicTagger
<br />
<sub><b>Sn1cket</b></sub>
</a>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/emmanuel-ferdman">
<img src="https://avatars.githubusercontent.com/u/35470921?v=4" width="100;" alt="emmanuel-ferdman"/>
<br />
<sub><b>emmanuel-ferdman</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/jpcranford">
@ -180,7 +165,8 @@ winget install ComicTagger.ComicTagger
<br />
<sub><b>jpcranford</b></sub>
</a>
</td>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/PawlakMarek">
<img src="https://avatars.githubusercontent.com/u/26022173?v=4" width="100;" alt="PawlakMarek"/>
@ -208,8 +194,7 @@ winget install ComicTagger.ComicTagger
<br />
<sub><b>thFrgttn</b></sub>
</a>
</td></tr>
<tr>
</td>
<td align="center">
<a href="https://github.com/tlc">
<img src="https://avatars.githubusercontent.com/u/19436?v=4" width="100;" alt="tlc"/>

View File

@ -3,14 +3,16 @@ from __future__ import annotations
import argparse
import os
import pathlib
import platform
try:
import niquests as requests
except ImportError:
import requests
arch = platform.machine()
parser = argparse.ArgumentParser()
parser.add_argument("APPIMAGETOOL", default="build/appimagetool-x86_64.AppImage", type=pathlib.Path, nargs="?")
parser.add_argument("APPIMAGETOOL", default=f"build/appimagetool-{arch}.AppImage", type=pathlib.Path, nargs="?")
opts = parser.parse_args()
opts.APPIMAGETOOL = opts.APPIMAGETOOL.absolute()
@ -27,7 +29,8 @@ if opts.APPIMAGETOOL.exists():
raise SystemExit(0)
urlretrieve(
"https://github.com/AppImage/appimagetool/releases/latest/download/appimagetool-x86_64.AppImage", opts.APPIMAGETOOL
f"https://github.com/AppImage/appimagetool/releases/latest/download/appimagetool-{arch}.AppImage",
opts.APPIMAGETOOL,
)
os.chmod(opts.APPIMAGETOOL, 0o0700)

View File

@ -1,7 +1,6 @@
from __future__ import annotations
import pathlib
from collections.abc import Collection
from typing import Protocol, runtime_checkable
@ -31,8 +30,6 @@ class Archiver(Protocol):
"""
hashable: bool = True
supported_extensions: Collection[str] = set()
def __init__(self) -> None:
self.path = pathlib.Path()

View File

@ -17,7 +17,6 @@ class FolderArchiver(Archiver):
def __init__(self) -> None:
super().__init__()
self.comment_file_name = "ComicTaggerFolderComment.txt"
self._filename_list: list[str] = []
def get_comment(self) -> str:
try:
@ -26,10 +25,8 @@ class FolderArchiver(Archiver):
return ""
def set_comment(self, comment: str) -> bool:
self._filename_list = []
if comment:
if (self.path / self.comment_file_name).exists() or comment:
return self.write_file(self.comment_file_name, comment.encode("utf-8"))
(self.path / self.comment_file_name).unlink(missing_ok=True)
return True
def supports_comment(self) -> bool:
@ -45,7 +42,6 @@ class FolderArchiver(Archiver):
return data
def remove_file(self, archive_file: str) -> bool:
self._filename_list = []
try:
(self.path / archive_file).unlink(missing_ok=True)
except OSError as e:
@ -55,7 +51,6 @@ class FolderArchiver(Archiver):
return True
def write_file(self, archive_file: str, data: bytes) -> bool:
self._filename_list = []
try:
file_path = self.path / archive_file
file_path.parent.mkdir(exist_ok=True, parents=True)
@ -68,14 +63,11 @@ class FolderArchiver(Archiver):
return True
def get_filename_list(self) -> list[str]:
if self._filename_list:
return self._filename_list
filenames = []
try:
for root, _dirs, files in os.walk(self.path):
for f in files:
filenames.append(os.path.relpath(os.path.join(root, f), self.path).replace(os.path.sep, "/"))
self._filename_list = filenames
return filenames
except OSError as e:
logger.error("Error listing files in folder archive [%s]: %s", e, self.path)
@ -86,7 +78,6 @@ class FolderArchiver(Archiver):
def copy_from_archive(self, other_archive: Archiver) -> bool:
"""Replace the current zip with one copied from another archive"""
self._filename_list = []
try:
for filename in other_archive.get_filename_list():
data = other_archive.read_file(filename)

View File

@ -8,6 +8,7 @@ import platform
import shutil
import subprocess
import tempfile
import time
from comicapi.archivers import Archiver
@ -23,11 +24,6 @@ logger = logging.getLogger(__name__)
if not rar_support:
logger.error("rar unavailable")
# windows only, keeps the cmd.exe from popping up
STARTUPINFO = None
if platform.system() == "Windows":
STARTUPINFO = subprocess.STARTUPINFO() # type: ignore
STARTUPINFO.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore
class RarArchiver(Archiver):
@ -35,22 +31,22 @@ class RarArchiver(Archiver):
enabled = rar_support
exe = "rar"
supported_extensions = frozenset({".cbr", ".rar"})
_rar: rarfile.RarFile | None = None
_rar_setup: rarfile.ToolSetup | None = None
_writeable: bool | None = None
def __init__(self) -> None:
super().__init__()
self._filename_list: list[str] = []
# windows only, keeps the cmd.exe from popping up
if platform.system() == "Windows":
self.startupinfo = subprocess.STARTUPINFO() # type: ignore
self.startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore
else:
self.startupinfo = None
def get_comment(self) -> str:
rarc = self.get_rar_obj()
return (rarc.comment if rarc else "") or ""
def set_comment(self, comment: str) -> bool:
self._reset()
if rar_support and self.exe:
try:
# write comment to temp file
@ -71,7 +67,7 @@ class RarArchiver(Archiver):
]
result = subprocess.run(
proc_args,
startupinfo=STARTUPINFO,
startupinfo=self.startupinfo,
stdin=subprocess.DEVNULL,
capture_output=True,
encoding="utf-8",
@ -85,11 +81,16 @@ class RarArchiver(Archiver):
result.stderr,
)
return False
if platform.system() == "Darwin":
time.sleep(1)
except OSError as e:
logger.exception("Error writing comment to rar archive [%s]: %s", e, self.path)
return False
return True
return False
else:
return True
else:
return False
def supports_comment(self) -> bool:
return True
@ -119,6 +120,7 @@ class RarArchiver(Archiver):
except OSError as e:
logger.error("Error reading rar archive [%s]: %s :: %s :: tries #%d", e, self.path, archive_file, tries)
time.sleep(1)
except Exception as e:
logger.error(
"Unexpected exception reading rar archive [%s]: %s :: %s :: tries #%d",
@ -139,19 +141,20 @@ class RarArchiver(Archiver):
raise OSError
def remove_file(self, archive_file: str) -> bool:
self._reset()
if self.exe:
working_dir = os.path.dirname(os.path.abspath(self.path))
# use external program to remove file from Rar archive
result = subprocess.run(
[self.exe, "d", f"-w{working_dir}", "-c-", self.path, archive_file],
startupinfo=STARTUPINFO,
startupinfo=self.startupinfo,
stdin=subprocess.DEVNULL,
capture_output=True,
encoding="utf-8",
cwd=self.path.absolute().parent,
)
if platform.system() == "Darwin":
time.sleep(1)
if result.returncode != 0:
logger.error(
"Error removing file from rar archive [exitcode: %d]: %s :: %s",
@ -161,10 +164,10 @@ class RarArchiver(Archiver):
)
return False
return True
return False
else:
return False
def write_file(self, archive_file: str, data: bytes) -> bool:
self._reset()
if self.exe:
archive_path = pathlib.PurePosixPath(archive_file)
archive_name = archive_path.name
@ -184,11 +187,13 @@ class RarArchiver(Archiver):
self.path,
],
input=data,
startupinfo=STARTUPINFO,
startupinfo=self.startupinfo,
capture_output=True,
cwd=self.path.absolute().parent,
)
if platform.system() == "Darwin":
time.sleep(1)
if result.returncode != 0:
logger.error(
"Error writing rar archive [exitcode: %d]: %s :: %s :: %s",
@ -198,12 +203,12 @@ class RarArchiver(Archiver):
result.stderr,
)
return False
return True
return False
else:
return True
else:
return False
def get_filename_list(self) -> list[str]:
if self._filename_list:
return self._filename_list
rarc = self.get_rar_obj()
tries = 0
if rar_support and rarc:
@ -217,9 +222,9 @@ class RarArchiver(Archiver):
except OSError as e:
logger.error("Error listing files in rar archive [%s]: %s :: attempt #%d", e, self.path, tries)
time.sleep(1)
else:
self._filename_list = namelist
return namelist
return []
@ -228,7 +233,6 @@ class RarArchiver(Archiver):
def copy_from_archive(self, other_archive: Archiver) -> bool:
"""Replace the current archive with one copied from another archive"""
self._reset()
try:
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = pathlib.Path(tmp_dir)
@ -246,7 +250,7 @@ class RarArchiver(Archiver):
result = subprocess.run(
[self.exe, "a", f"-w{working_dir}", "-r", "-c-", str(rar_path.absolute()), "."],
cwd=rar_cwd.absolute(),
startupinfo=STARTUPINFO,
startupinfo=self.startupinfo,
stdin=subprocess.DEVNULL,
capture_output=True,
encoding="utf-8",
@ -271,10 +275,27 @@ class RarArchiver(Archiver):
@classmethod
@functools.cache
def _log_not_writeable(cls, exe: str) -> None:
logger.warning("Unable to find a useable copy of %r, will not be able to write rar files", str)
logger.warning("Unable to find a useable copy of %r, will not be able to write rar files", exe)
def is_writable(self) -> bool:
return bool(self._writeable and bool(self.exe and (os.path.exists(self.exe) or shutil.which(self.exe))))
writeable = False
try:
if bool(self.exe and (os.path.exists(self.exe) or shutil.which(self.exe))):
writeable = (
subprocess.run(
(self.exe,),
startupinfo=self.startupinfo,
capture_output=True,
cwd=self.path.absolute().parent,
)
.stdout.strip()
.startswith(b"RAR")
)
except OSError:
...
if not writeable:
self._log_not_writeable(self.exe or "rar")
return False
def extension(self) -> str:
return ".cbr"
@ -283,62 +304,27 @@ class RarArchiver(Archiver):
return "RAR"
@classmethod
def _setup_rar(cls) -> None:
if cls._rar_setup is None:
assert rarfile
def is_valid(cls, path: pathlib.Path) -> bool:
if rar_support:
# Try using exe
orig = rarfile.UNRAR_TOOL
rarfile.UNRAR_TOOL = cls.exe
try:
cls._rar_setup = rarfile.tool_setup(sevenzip=False, sevenzip2=False, force=True)
return rarfile.is_rarfile(str(path)) and rarfile.tool_setup(sevenzip=False, sevenzip2=False, force=True)
except rarfile.RarCannotExec:
rarfile.UNRAR_TOOL = orig
try:
cls._rar_setup = rarfile.tool_setup(force=True)
except rarfile.RarCannotExec as e:
logger.info(e)
if cls._writeable is None:
try:
cls._writeable = (
subprocess.run(
(cls.exe,),
startupinfo=STARTUPINFO,
capture_output=True,
# cwd=cls.path.absolute().parent,
)
.stdout.strip()
.startswith(b"RAR")
)
except OSError:
cls._writeable = False
if not cls._writeable:
cls._log_not_writeable(cls.exe or "rar")
@classmethod
def is_valid(cls, path: pathlib.Path) -> bool:
if rar_support:
assert rarfile
cls._setup_rar()
# Fallback to standard
try:
return rarfile.is_rarfile(str(path))
return rarfile.is_rarfile(str(path)) and rarfile.tool_setup(force=True)
except rarfile.RarCannotExec as e:
logger.info(e)
return False
def _reset(self) -> None:
self._rar = None
self._filename_list = []
def get_rar_obj(self) -> rarfile.RarFile | None:
if self._rar is not None:
return self._rar
if rar_support:
try:
rarc = rarfile.RarFile(str(self.path))
self._rar = rarc
except (OSError, rarfile.RarFileError) as e:
logger.error("Unable to get rar object [%s]: %s", e, self.path)
else:

View File

@ -22,11 +22,9 @@ class SevenZipArchiver(Archiver):
"""7Z implementation"""
enabled = z7_support
supported_extensions = frozenset({".7z", ".cb7"})
def __init__(self) -> None:
super().__init__()
self._filename_list: list[str] = []
# @todo: Implement Comment?
def get_comment(self) -> str:
@ -47,7 +45,6 @@ class SevenZipArchiver(Archiver):
return data
def remove_file(self, archive_file: str) -> bool:
self._filename_list = []
return self.rebuild([archive_file])
def write_file(self, archive_file: str, data: bytes) -> bool:
@ -55,7 +52,6 @@ class SevenZipArchiver(Archiver):
# archive w/o the indicated file. Very sucky, but maybe
# another solution can be found
files = self.get_filename_list()
self._filename_list = []
if archive_file in files:
if not self.rebuild([archive_file]):
return False
@ -70,13 +66,10 @@ class SevenZipArchiver(Archiver):
return False
def get_filename_list(self) -> list[str]:
if self._filename_list:
return self._filename_list
try:
with py7zr.SevenZipFile(self.path, "r") as zf:
namelist: list[str] = [file.filename for file in zf.list() if not file.is_directory]
self._filename_list = namelist
return namelist
except (py7zr.Bad7zFile, OSError) as e:
logger.error("Error listing files in 7zip archive [%s]: %s", e, self.path)
@ -91,7 +84,6 @@ class SevenZipArchiver(Archiver):
This recompresses the zip archive, without the files in the exclude_list
"""
self._filename_list = []
try:
# py7zr treats all archives as if they used solid compression
# so we need to get the filename list first to read all the files at once
@ -114,7 +106,6 @@ class SevenZipArchiver(Archiver):
def copy_from_archive(self, other_archive: Archiver) -> bool:
"""Replace the current zip with one copied from another archive"""
self._filename_list = []
try:
with py7zr.SevenZipFile(self.path, "w") as zout:
for filename in other_archive.get_filename_list():

View File

@ -15,110 +15,17 @@ from comicapi.archivers import Archiver
logger = logging.getLogger(__name__)
class ZipFile(zipfile.ZipFile):
def remove(self, zinfo_or_arcname): # type: ignore
"""Remove a member from the archive."""
if self.mode not in ("w", "x", "a"):
raise ValueError("remove() requires mode 'w', 'x', or 'a'")
if not self.fp:
raise ValueError("Attempt to write to ZIP archive that was already closed")
if self._writing: # type: ignore[attr-defined]
raise ValueError("Can't write to ZIP archive while an open writing handle exists")
# Make sure we have an existing info object
if isinstance(zinfo_or_arcname, zipfile.ZipInfo):
zinfo = zinfo_or_arcname
# make sure zinfo exists
if zinfo not in self.filelist:
raise KeyError("There is no item %r in the archive" % zinfo_or_arcname)
else:
# get the info object
zinfo = self.getinfo(zinfo_or_arcname)
return self._remove_members({zinfo})
def _remove_members(self, members, *, remove_physical=True, chunk_size=2**20): # type: ignore
"""Remove members in a zip file.
All members (as zinfo) should exist in the zip; otherwise the zip file
will erroneously end in an inconsistent state.
"""
fp = self.fp
assert fp
entry_offset = 0
member_seen = False
# get a sorted filelist by header offset, in case the dir order
# doesn't match the actual entry order
filelist = sorted(self.filelist, key=lambda x: x.header_offset)
for i in range(len(filelist)):
info = filelist[i]
is_member = info in members
if not (member_seen or is_member):
continue
# get the total size of the entry
try:
offset = filelist[i + 1].header_offset
except IndexError:
offset = self.start_dir
entry_size = offset - info.header_offset
if is_member:
member_seen = True
entry_offset += entry_size
# update caches
self.filelist.remove(info)
try:
del self.NameToInfo[info.filename]
except KeyError:
pass
continue
# update the header and move entry data to the new position
if remove_physical:
old_header_offset = info.header_offset
info.header_offset -= entry_offset
read_size = 0
while read_size < entry_size:
fp.seek(old_header_offset + read_size)
data = fp.read(min(entry_size - read_size, chunk_size))
fp.seek(info.header_offset + read_size)
fp.write(data)
fp.flush()
read_size += len(data)
# Avoid missing entry if entries have a duplicated name.
# Reverse the order as NameToInfo normally stores the last added one.
for info in reversed(self.filelist):
self.NameToInfo.setdefault(info.filename, info)
# update state
if remove_physical:
self.start_dir -= entry_offset
self._didModify = True
# seek to the start of the central dir
fp.seek(self.start_dir)
class ZipArchiver(Archiver):
"""ZIP implementation"""
supported_extensions = frozenset((".cbz", ".zip"))
def __init__(self) -> None:
super().__init__()
self._filename_list: list[str] = []
def supports_comment(self) -> bool:
return True
def get_comment(self) -> str:
with ZipFile(self.path, "r") as zf:
with zipfile.ZipFile(self.path, "r") as zf:
encoding = chardet.detect(zf.comment, True)
if encoding["confidence"] > 60:
try:
@ -130,12 +37,12 @@ class ZipArchiver(Archiver):
return comment
def set_comment(self, comment: str) -> bool:
with ZipFile(self.path, mode="a") as zf:
with zipfile.ZipFile(self.path, mode="a") as zf:
zf.comment = bytes(comment, "utf-8")
return True
def read_file(self, archive_file: str) -> bytes:
with ZipFile(self.path, mode="r") as zf:
with zipfile.ZipFile(self.path, mode="r") as zf:
try:
data = zf.read(archive_file)
except (zipfile.BadZipfile, OSError) as e:
@ -144,26 +51,20 @@ class ZipArchiver(Archiver):
return data
def remove_file(self, archive_file: str) -> bool:
files = self.get_filename_list()
self._filename_list = []
try:
with ZipFile(self.path, mode="a", allowZip64=True, compression=zipfile.ZIP_DEFLATED) as zf:
if archive_file in files:
zf.remove(archive_file)
return True
except (zipfile.BadZipfile, OSError) as e:
logger.error("Error writing zip archive [%s]: %s :: %s", e, self.path, archive_file)
return False
return self.rebuild([archive_file])
def write_file(self, archive_file: str, data: bytes) -> bool:
# At the moment, no other option but to rebuild the whole
# zip archive w/o the indicated file. Very sucky, but maybe
# another solution can be found
files = self.get_filename_list()
self._filename_list = []
try:
# now just add the archive file as a new one
with ZipFile(self.path, mode="a", allowZip64=True, compression=zipfile.ZIP_DEFLATED) as zf:
with zipfile.ZipFile(self.path, mode="a", allowZip64=True, compression=zipfile.ZIP_DEFLATED) as zf:
_patch_zipfile(zf)
if archive_file in files:
zf.remove(archive_file)
zf.remove(archive_file) # type: ignore
zf.writestr(archive_file, data)
return True
except (zipfile.BadZipfile, OSError) as e:
@ -171,12 +72,10 @@ class ZipArchiver(Archiver):
return False
def get_filename_list(self) -> list[str]:
if self._filename_list:
return self._filename_list
try:
with ZipFile(self.path, mode="r") as zf:
self._filename_list = [file.filename for file in zf.infolist() if not file.is_dir()]
return self._filename_list
with zipfile.ZipFile(self.path, mode="r") as zf:
namelist = [file.filename for file in zf.infolist() if not file.is_dir()]
return namelist
except (zipfile.BadZipfile, OSError) as e:
logger.error("Error listing files in zip archive [%s]: %s", e, self.path)
return []
@ -189,12 +88,11 @@ class ZipArchiver(Archiver):
This recompresses the zip archive, without the files in the exclude_list
"""
self._filename_list = []
try:
with ZipFile(
with zipfile.ZipFile(
tempfile.NamedTemporaryFile(dir=os.path.dirname(self.path), delete=False), "w", allowZip64=True
) as zout:
with ZipFile(self.path, mode="r") as zin:
with zipfile.ZipFile(self.path, mode="r") as zin:
for item in zin.infolist():
buffer = zin.read(item.filename)
if item.filename not in exclude_list:
@ -216,9 +114,8 @@ class ZipArchiver(Archiver):
def copy_from_archive(self, other_archive: Archiver) -> bool:
"""Replace the current zip with one copied from another archive"""
self._filename_list = []
try:
with ZipFile(self.path, mode="w", allowZip64=True) as zout:
with zipfile.ZipFile(self.path, mode="w", allowZip64=True) as zout:
for filename in other_archive.get_filename_list():
data = other_archive.read_file(filename)
if data is not None:
@ -246,4 +143,106 @@ class ZipArchiver(Archiver):
@classmethod
def is_valid(cls, path: pathlib.Path) -> bool:
return zipfile.is_zipfile(path) # only checks central directory ot the end of the archive
if not zipfile.is_zipfile(path): # only checks central directory ot the end of the archive
return False
try:
# test all the files in the zip. adds about 0.1 to execution time per zip
with zipfile.ZipFile(path) as zf:
for zipinfo in zf.filelist:
zf.open(zipinfo).close()
return True
except Exception:
return False
def _patch_zipfile(zf): # type: ignore
zf.remove = _zip_remove.__get__(zf, zipfile.ZipFile)
zf._remove_members = _zip_remove_members.__get__(zf, zipfile.ZipFile)
def _zip_remove(self, zinfo_or_arcname): # type: ignore
"""Remove a member from the archive."""
if self.mode not in ("w", "x", "a"):
raise ValueError("remove() requires mode 'w', 'x', or 'a'")
if not self.fp:
raise ValueError("Attempt to write to ZIP archive that was already closed")
if self._writing:
raise ValueError("Can't write to ZIP archive while an open writing handle exists")
# Make sure we have an existing info object
if isinstance(zinfo_or_arcname, zipfile.ZipInfo):
zinfo = zinfo_or_arcname
# make sure zinfo exists
if zinfo not in self.filelist:
raise KeyError("There is no item %r in the archive" % zinfo_or_arcname)
else:
# get the info object
zinfo = self.getinfo(zinfo_or_arcname)
return self._remove_members({zinfo})
def _zip_remove_members(self, members, *, remove_physical=True, chunk_size=2**20): # type: ignore
"""Remove members in a zip file.
All members (as zinfo) should exist in the zip; otherwise the zip file
will erroneously end in an inconsistent state.
"""
fp = self.fp
entry_offset = 0
member_seen = False
# get a sorted filelist by header offset, in case the dir order
# doesn't match the actual entry order
filelist = sorted(self.filelist, key=lambda x: x.header_offset)
for i in range(len(filelist)):
info = filelist[i]
is_member = info in members
if not (member_seen or is_member):
continue
# get the total size of the entry
try:
offset = filelist[i + 1].header_offset
except IndexError:
offset = self.start_dir
entry_size = offset - info.header_offset
if is_member:
member_seen = True
entry_offset += entry_size
# update caches
self.filelist.remove(info)
try:
del self.NameToInfo[info.filename]
except KeyError:
pass
continue
# update the header and move entry data to the new position
if remove_physical:
old_header_offset = info.header_offset
info.header_offset -= entry_offset
read_size = 0
while read_size < entry_size:
fp.seek(old_header_offset + read_size)
data = fp.read(min(entry_size - read_size, chunk_size))
fp.seek(info.header_offset + read_size)
fp.write(data)
fp.flush()
read_size += len(data)
# Avoid missing entry if entries have a duplicated name.
# Reverse the order as NameToInfo normally stores the last added one.
for info in reversed(self.filelist):
self.NameToInfo.setdefault(info.filename, info)
# update state
if remove_physical:
self.start_dir -= entry_offset
self._didModify = True
# seek to the start of the central dir
fp.seek(self.start_dir)

View File

@ -123,7 +123,7 @@ def load_tag_plugins(version: str = f"ComicAPI/{version}", local_plugins: Iterab
class ComicArchive:
logo_data = b""
pil_available: bool | None = None
pil_available = True
def __init__(
self,
@ -146,20 +146,12 @@ class ComicArchive:
self.path = pathlib.Path(path).absolute()
self.archiver = UnknownArchiver.open(self.path)
load_archive_plugins()
load_tag_plugins()
archiver_missing = True
for archiver in archivers:
if self.path.suffix in archiver.supported_extensions and archiver.is_valid(self.path):
self.archiver = archiver.open(self.path)
archiver_missing = False
break
if archiver_missing:
for archiver in archivers:
if archiver.enabled and archiver.is_valid(self.path):
self.archiver = archiver.open(self.path)
break
load_archive_plugins()
load_tag_plugins()
for archiver in archivers:
if archiver.enabled and archiver.is_valid(self.path):
self.archiver = archiver.open(self.path)
break
if not ComicArchive.logo_data and self.default_image_path:
with open(self.default_image_path, mode="rb") as fd:
@ -338,7 +330,6 @@ class ComicArchive:
def get_page_name_list(self) -> list[str]:
if not self.page_list:
self.__import_pil__() # Import pillow for list of supported extensions
self.page_list = utils.get_page_name_list(self.archiver.get_filename_list())
return self.page_list
@ -348,22 +339,6 @@ class ComicArchive:
self.page_count = len(self.get_page_name_list())
return self.page_count
def __import_pil__(self) -> bool:
if self.pil_available is not None:
return self.pil_available
try:
from PIL import Image
Image.init()
utils.KNOWN_IMAGE_EXTENSIONS.update([ext for ext, typ in Image.EXTENSION.items() if typ in Image.OPEN])
self.pil_available = True
except Exception:
self.pil_available = False
logger.exception("Failed to load Pillow")
return False
return True
def apply_archive_info_to_metadata(
self,
md: GenericMetadata,
@ -395,15 +370,30 @@ class ComicArchive:
if not calc_page_sizes:
return
for p in md.pages:
if not self.pil_available:
if p.byte_size is not None:
data = self.get_page(p.archive_index)
p.byte_size = len(data)
continue
try:
from PIL import Image
self.pil_available = True
except ImportError:
self.pil_available = False
if p.byte_size is not None:
data = self.get_page(p.archive_index)
p.byte_size = len(data)
continue
if p.byte_size is None or p.height is None or p.width is None or p.double_page is None:
try:
data = self.get_page(p.archive_index)
p.byte_size = len(data)
if not data or not self.__import_pil__():
if not data:
continue
from PIL import Image
im = Image.open(io.BytesIO(data))
w, h = im.size

View File

@ -138,16 +138,11 @@ class MetadataOrigin(NamedTuple):
class ImageHash(NamedTuple):
"""
A valid ImageHash requires at a minimum a Hash and Kind or a URL
If only a URL is given, it will be used for cover matching otherwise Hash is used
The URL is also required for the GUI to display covers
Available Kind's are "ahash" and "phash"
"""
Hash: int
Kind: str
URL: str
Kind: str # ahash, phash
def __str__(self) -> str:
return str(self.Hash) + ": " + self.Kind
class FileHash(NamedTuple):
@ -235,8 +230,8 @@ class GenericMetadata:
last_mark: str | None = None
# urls to cover image, not generally part of the metadata
_cover_image: ImageHash | None = None
_alternate_images: list[ImageHash] = dataclasses.field(default_factory=list)
_cover_image: str | ImageHash | None = None
_alternate_images: list[str | ImageHash] = dataclasses.field(default_factory=list)
def __post_init__(self) -> None:
for key, value in self.__dict__.items():

View File

@ -15,7 +15,6 @@
# limitations under the License.
from __future__ import annotations
import difflib
import hashlib
import json
import logging
@ -185,16 +184,13 @@ def _custom_key(tup: Any) -> Any:
T = TypeVar("T")
def os_sorted(lst: Iterable[T]) -> list[T]:
def os_sorted(lst: Iterable[T]) -> Iterable[T]:
import natsort
key = _custom_key
if icu_available or platform.system() == "Windows":
key = natsort.os_sort_keygen()
return sorted(sorted(lst), key=key) # type: ignore[type-var]
KNOWN_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif"}
return sorted(lst, key=key)
def parse_filename(
@ -362,7 +358,10 @@ def get_page_name_list(files: list[str]) -> list[str]:
# make a sub-list of image files
page_list = []
for name in files:
if os.path.splitext(name)[1].casefold() in KNOWN_IMAGE_EXTENSIONS and os.path.basename(name)[0] != ".":
if (
os.path.splitext(name)[1].casefold() in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif"]
and os.path.basename(name)[0] != "."
):
page_list.append(name)
return page_list
@ -518,30 +517,19 @@ def sanitize_title(text: str, basic: bool = False) -> str:
def titles_match(search_title: str, record_title: str, threshold: int = 90) -> bool:
log_msg = "search title: %s ; record title: %s ; ratio: %d ; match threshold: %d"
thresh = threshold / 100
import rapidfuzz.fuzz
sanitized_search = sanitize_title(search_title)
sanitized_record = sanitize_title(record_title)
s = difflib.SequenceMatcher(None, sanitized_search, sanitized_record)
ratio = s.real_quick_ratio()
if ratio < thresh:
logger.debug(log_msg, search_title, record_title, ratio * 100, threshold)
return False
ratio = s.quick_ratio()
if ratio < thresh:
logger.debug(log_msg, search_title, record_title, ratio * 100, threshold)
return False
ratio = s.ratio()
if ratio < thresh:
logger.debug(log_msg, search_title, record_title, ratio * 100, threshold)
return False
logger.debug(log_msg, search_title, record_title, ratio * 100, threshold)
return True
ratio = int(rapidfuzz.fuzz.ratio(sanitized_search, sanitized_record))
logger.debug(
"search title: %s ; record title: %s ; ratio: %d ; match threshold: %d",
search_title,
record_title,
ratio,
threshold,
)
return ratio >= threshold
def unique_file(file_name: pathlib.Path) -> pathlib.Path:

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import logging
import pathlib
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comictaggerlib.ui import ui_path
@ -35,7 +35,7 @@ class ApplicationLogWindow(QtWidgets.QDialog):
self.log_handler.qlog.connect(self.textEdit.append)
f = QtGui.QFont("menlo")
f.setStyleHint(QtGui.QFont.Monospace)
f.setStyleHint(QtGui.QFont.StyleHint.Monospace)
self.setFont(f)
self._button = QtWidgets.QPushButton(self)
self._button.setText("Test Me")

View File

@ -20,7 +20,7 @@ import logging
import os
from typing import Callable
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi.comicarchive import ComicArchive, tags
from comicapi.genericmetadata import GenericMetadata

View File

@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.ui import ui_path
@ -65,6 +65,7 @@ class AutoTagProgressWindow(QtWidgets.QDialog):
def set_cover_image(self, img_data: bytes, widget: CoverImageWidget) -> None:
widget.set_image_data(img_data)
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.processEvents()
def reject(self) -> None:
QtWidgets.QDialog.reject(self)

View File

@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.ui import ui_path

View File

@ -82,8 +82,6 @@ class CLI:
if not args:
log_args: tuple[Any, ...] = ("",)
elif isinstance(args[0], str):
if args[0] == "":
already_logged = True
log_args = (args[0].strip("\n"), *args[1:])
else:
log_args = args
@ -114,7 +112,6 @@ class CLI:
for f in self.config.Runtime_Options__files:
res, match_results = self.process_file_cli(self.config.Commands__command, f, match_results)
results.append(res)
self.output("")
if results[-1].status != Status.success:
return_code = 3
if self.config.Runtime_Options__json:
@ -441,6 +438,7 @@ class CLI:
ct_md = qt.id_comic(
ca,
md,
self.config.Quick_Tag__simple,
set(self.config.Quick_Tag__hash),
self.config.Quick_Tag__exact_only,
self.config.Runtime_Options__interactive,

View File

@ -1,4 +1,4 @@
"""A PyQt5 widget to display cover images
"""A PyQt6 widget to display cover images
Display cover images from either a local archive, or from comic source metadata.
TODO: This should be re-factored using subclasses!
@ -23,7 +23,7 @@ from __future__ import annotations
import logging
import pathlib
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi.comicarchive import ComicArchive
from comictaggerlib.imagefetcher import ImageFetcher

View File

@ -20,7 +20,7 @@ import logging
import operator
import natsort
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comicapi import utils
from comicapi.genericmetadata import Credit

View File

@ -52,17 +52,15 @@ def validate_types(config: settngs.Config[settngs.Values]) -> settngs.Config[set
for setting in group.v.values():
# Get the value and if it is the default
value, default = settngs.get_option(config.values, setting)
if not default and setting.type is not None:
# If it is not the default and the type attribute is not None
# use it to convert the loaded string into the expected value
if (
isinstance(value, str)
or isinstance(default, Enum)
or (isinstance(setting.type, type) and issubclass(setting.type, Enum))
):
if isinstance(setting.type, type) and issubclass(setting.type, Enum) and isinstance(value, list):
config.values[setting.group][setting.dest] = [setting.type(x) for x in value]
else:
if not default:
if setting.type is not None:
# If it is not the default and the type attribute is not None
# use it to convert the loaded string into the expected value
if (
isinstance(value, str)
or isinstance(default, Enum)
or (isinstance(setting.type, type) and issubclass(setting.type, Enum))
):
config.values[setting.group][setting.dest] = setting.type(value)
return config

View File

@ -351,9 +351,7 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
parser.exit(message="Please specify the tags to copy to with --tags-write\n", status=1)
if config[0].Runtime_Options__recursive:
config[0].Runtime_Options__files = utils.os_sorted(
set(utils.get_recursive_filelist(config[0].Runtime_Options__files))
)
config[0].Runtime_Options__files = utils.get_recursive_filelist(config[0].Runtime_Options__files)
if not config[0].Runtime_Options__enable_embedding_hashes:
config[0].Runtime_Options__preferred_hash = ""
@ -362,7 +360,7 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
if not utils.which("rar"):
if platform.system() == "Windows":
letters = ["C"]
letters.extend({f"{d}" for d in "ABDEFGHIJKLMNOPQRSTUVWXYZ" if os.path.exists(f"{d}:\\")})
letters.extend({f"{d}" for d in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if os.path.exists(f"{d}:\\")} - {"C"})
for letter in letters:
# look in some likely places for Windows machines
utils.add_to_path(rf"{letter}:\Program Files\WinRAR")

View File

@ -43,6 +43,7 @@ class SettngsNS(settngs.TypedNS):
Quick_Tag__url: urllib3.util.url.Url
Quick_Tag__max: int
Quick_Tag__simple: bool
Quick_Tag__aggressive_filtering: bool
Quick_Tag__hash: list[comictaggerlib.quick_tag.HashType]
Quick_Tag__exact_only: bool
@ -169,6 +170,7 @@ class Runtime_Options(typing.TypedDict):
class Quick_Tag(typing.TypedDict):
url: urllib3.util.url.Url
max: int
simple: bool
aggressive_filtering: bool
hash: list[comictaggerlib.quick_tag.HashType]
exact_only: bool

View File

@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comictaggerlib.ui import ui_path

View File

@ -1,4 +1,4 @@
"""A PyQt5 widget for managing list of comic archive files"""
"""A PyQt6 widget for managing list of comic archive files"""
#
# Copyright 2012-2014 ComicTagger Authors
@ -18,11 +18,10 @@ from __future__ import annotations
import logging
import os
import pathlib
import platform
from typing import Callable, cast
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi import utils
from comicapi.comicarchive import ComicArchive
@ -64,9 +63,9 @@ class FileSelectionList(QtWidgets.QWidget):
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.ActionsContextMenu)
self.dirty_flag = False
select_all_action = QtWidgets.QAction("Select All", self)
remove_action = QtWidgets.QAction("Remove Selected Items", self)
self.separator = QtWidgets.QAction("", self)
select_all_action = QtGui.QAction("Select All", self)
remove_action = QtGui.QAction("Remove Selected Items", self)
self.separator = QtGui.QAction("", self)
self.separator.setSeparator(True)
select_all_action.setShortcut("Ctrl+A")
@ -79,21 +78,19 @@ class FileSelectionList(QtWidgets.QWidget):
self.addAction(remove_action)
self.addAction(self.separator)
self.loaded_paths: set[pathlib.Path] = set()
self.dirty_flag_verification = dirty_flag_verification
self.rar_ro_shown = False
def get_sorting(self) -> tuple[int, int]:
col = self.twList.horizontalHeader().sortIndicatorSection()
order = self.twList.horizontalHeader().sortIndicatorOrder()
order = self.twList.horizontalHeader().sortIndicatorOrder().value
return int(col), int(order)
def set_sorting(self, col: int, order: QtCore.Qt.SortOrder) -> None:
self.twList.horizontalHeader().setSortIndicator(col, order)
def add_app_action(self, action: QtWidgets.QAction) -> None:
self.insertAction(QtWidgets.QAction(), action)
def add_app_action(self, action: QtGui.QAction) -> None:
self.insertAction(QtGui.QAction(), action)
def set_modified_flag(self, modified: bool) -> None:
self.dirty_flag = modified
@ -118,7 +115,6 @@ class FileSelectionList(QtWidgets.QWidget):
if row == self.twList.currentRow():
current_removed = True
self.twList.removeRow(row)
self.loaded_paths -= {ca.path}
break
self.twList.setSortingEnabled(True)
@ -162,7 +158,6 @@ class FileSelectionList(QtWidgets.QWidget):
self.twList.setSortingEnabled(False)
for i in row_list:
self.loaded_paths -= {self.get_archive_by_row(i).path} # type: ignore[union-attr]
self.twList.removeRow(i)
self.twList.setSortingEnabled(True)
@ -193,20 +188,21 @@ class FileSelectionList(QtWidgets.QWidget):
progdialog.show()
center_window_on_parent(progdialog)
QtCore.QCoreApplication.processEvents()
first_added = None
rar_added_ro = False
self.twList.setSortingEnabled(False)
for idx, f in enumerate(filelist):
if idx % 10 == 0:
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.processEvents()
if progdialog is not None:
if progdialog.wasCanceled():
break
progdialog.setValue(idx + 1)
progdialog.setLabelText(f)
row, ca = self.add_path_item(f)
QtCore.QCoreApplication.processEvents()
row = self.add_path_item(f)
if row is not None:
ca = self.get_archive_by_row(row)
rar_added_ro = bool(ca and ca.archiver.name() == "RAR" and not ca.archiver.is_writable())
if first_added is None and row != -1:
first_added = row
@ -260,32 +256,29 @@ class FileSelectionList(QtWidgets.QWidget):
)
self.rar_ro_shown = True
def get_current_list_row(self, path: str) -> tuple[int, ComicArchive]:
pl = pathlib.Path(path)
if pl not in self.loaded_paths:
return -1, None # type: ignore[return-value]
def is_list_dupe(self, path: str) -> bool:
return self.get_current_list_row(path) >= 0
def get_current_list_row(self, path: str) -> int:
for r in range(self.twList.rowCount()):
ca = cast(ComicArchive, self.get_archive_by_row(r))
if ca.path == pl:
return r, ca
if str(ca.path) == path:
return r
return -1, None # type: ignore[return-value]
return -1
def add_path_item(self, path: str) -> tuple[int, ComicArchive]:
def add_path_item(self, path: str) -> int:
path = str(path)
path = os.path.abspath(path)
current_row, ca = self.get_current_list_row(path)
if current_row >= 0:
return current_row, ca
if self.is_list_dupe(path):
return self.get_current_list_row(path)
ca = ComicArchive(
path, str(graphics_path / "nocover.png"), hash_archive=self.config.Runtime_Options__preferred_hash
)
if ca.seems_to_be_a_comic_archive():
self.loaded_paths.add(ca.path)
row: int = self.twList.rowCount()
self.twList.insertRow(row)
@ -295,44 +288,28 @@ class FileSelectionList(QtWidgets.QWidget):
readonly_item = QtWidgets.QTableWidgetItem()
type_item = QtWidgets.QTableWidgetItem()
item_text = os.path.split(ca.path)[1]
filename_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
filename_item.setData(QtCore.Qt.ItemDataRole.UserRole, ca)
filename_item.setText(item_text)
filename_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
self.twList.setItem(row, FileSelectionList.fileColNum, filename_item)
item_text = os.path.split(ca.path)[0]
folder_item.setText(item_text)
folder_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
folder_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, FileSelectionList.folderColNum, folder_item)
type_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, FileSelectionList.typeColNum, type_item)
md_item.setText(", ".join(x for x in ca.get_supported_tags() if ca.has_tags(x)))
md_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
md_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
self.twList.setItem(row, FileSelectionList.MDFlagColNum, md_item)
if not ca.is_writable():
readonly_item.setCheckState(QtCore.Qt.CheckState.Checked)
readonly_item.setData(QtCore.Qt.ItemDataRole.UserRole, True)
readonly_item.setText(" ")
else:
readonly_item.setData(QtCore.Qt.ItemDataRole.UserRole, False)
readonly_item.setCheckState(QtCore.Qt.CheckState.Unchecked)
# This is a nbsp it sorts after a space ' '
readonly_item.setText("\xa0")
readonly_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
readonly_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
self.twList.setItem(row, FileSelectionList.readonlyColNum, readonly_item)
return row, ca
return -1, None # type: ignore[return-value]
self.update_row(row)
return row
return -1
def update_row(self, row: int) -> None:
if row >= 0:
@ -344,14 +321,14 @@ class FileSelectionList(QtWidgets.QWidget):
type_item = self.twList.item(row, FileSelectionList.typeColNum)
readonly_item = self.twList.item(row, FileSelectionList.readonlyColNum)
item_text = os.path.split(ca.path)[1]
filename_item.setText(item_text)
filename_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item_text = os.path.split(ca.path)[0]
folder_item.setText(item_text)
folder_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item_text = os.path.split(ca.path)[1]
filename_item.setText(item_text)
filename_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item_text = ca.archiver.name()
type_item.setText(item_text)
type_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)

View File

@ -2,11 +2,11 @@
# Resource object code
#
# Created by: The Resource Compiler for PyQt5 (Qt v5.15.11)
# Created by: The Resource Compiler for PyQt6 (Qt v5.15.11)
#
# WARNING! All changes made in this file will be lost!
from PyQt5 import QtCore
from PyQt6 import QtCore
qt_resource_data = b"\
\x00\x00\x16\x0b\

View File

@ -16,7 +16,7 @@ from comictalker.comictalker import ComicTalker
logger = logging.getLogger("comictagger")
try:
qt_available = True
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt6 import QtCore, QtGui, QtWidgets
def show_exception_box(log_msg: str) -> None:
"""Checks if a QApplication instance is available and shows a messagebox with the exception message.
@ -70,7 +70,7 @@ try:
try:
# needed here to initialize QWebEngine
from PyQt5.QtWebEngineWidgets import QWebEngineView # noqa: F401
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: F401
qt_webengine_available = True
except ImportError:
@ -81,7 +81,7 @@ try:
# Handles "Open With" from Finder on macOS
def event(self, event: QtCore.QEvent) -> bool:
if event.type() == QtCore.QEvent.FileOpen:
if event.type() == QtCore.QEvent.Type.FileOpen:
logger.info("file open recieved: %s", event.url().toLocalFile())
self.openFileRequest.emit(event.url())
return True

View File

@ -33,7 +33,7 @@ except ImportError:
from comictaggerlib import ctversion
if TYPE_CHECKING:
from PyQt5 import QtCore, QtNetwork
from PyQt6 import QtCore, QtNetwork
logger = logging.getLogger(__name__)
@ -57,7 +57,7 @@ class ImageFetcher:
if self.qt_available:
try:
from PyQt5 import QtNetwork
from PyQt6 import QtNetwork
self.qt_available = True
except ImportError:
@ -99,7 +99,7 @@ class ImageFetcher:
return image_data
if self.qt_available:
from PyQt5 import QtCore, QtNetwork
from PyQt6 import QtCore, QtNetwork
# if we found it, just emit the signal asap
if image_data:

View File

@ -20,7 +20,6 @@ import io
import itertools
import logging
import math
import statistics
from collections.abc import Sequence
from statistics import median
from typing import TypeVar
@ -71,14 +70,13 @@ class ImageHasher:
return 0
pixels = list(image.getdata())
avg = statistics.mean(pixels)
avg = sum(pixels) / len(pixels)
h = 0
for i, p in enumerate(pixels):
if p > avg:
h |= 1 << len(pixels) - 1 - i
diff = "".join(str(int(p > avg)) for p in pixels)
return h
result = int(diff, 2)
return result
def difference_hash(self) -> int:
try:
@ -88,25 +86,24 @@ class ImageHasher:
return 0
pixels = list(image.getdata())
h = 0
z = (self.width * self.height) - 1
diff = ""
for y in range(self.height):
for x in range(self.width):
idx = x + ((self.width + 1) * y)
if pixels[idx] < pixels[idx + 1]:
h |= 1 << z
z -= 1
idx = x + (self.width + 1 * y)
diff += str(int(pixels[idx] < pixels[idx + 1]))
return h
result = int(diff, 2)
def perception_hash(self) -> int:
return result
def p_hash(self) -> int:
"""
Pure python version of Perceptual Hash computation of https://github.com/JohannesBuchner/imagehash/tree/master
Implementation follows http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html
"""
def generate_dct2(block: Sequence[Sequence[float | int]], axis: int = 0) -> list[list[float | int]]:
def dct1(block: Sequence[float | int]) -> list[float | int]:
def generate_dct2(block: Sequence[Sequence[float]], axis: int = 0) -> list[list[float]]:
def dct1(block: Sequence[float]) -> list[float]:
"""Perform 1D Discrete Cosine Transform (DCT) on a given block."""
N = len(block)
dct_block = [0.0] * N
@ -123,7 +120,7 @@ class ImageHasher:
"""Perform 2D Discrete Cosine Transform (DCT) on a given block along the specified axis."""
rows = len(block)
cols = len(block[0])
dct_block: list[list[float | int]] = [[0.0] * cols for _ in range(rows)]
dct_block = [[0.0] * cols for _ in range(rows)]
if axis == 0:
# Apply 1D DCT on each row
@ -141,12 +138,18 @@ class ImageHasher:
return dct_block
def convert_to_array(data: list[float | int]) -> list[list[float | int]]:
def convert_image_to_ndarray(image: Image.Image) -> Sequence[Sequence[float]]:
width, height = image.size
pixels2 = []
for row in range(32):
x = row * 32
pixels2.append(data[x : x + 32])
for y in range(height):
row = []
for x in range(width):
pixel = image.getpixel((x, y))
assert isinstance(pixel, float)
row.append(pixel)
pixels2.append(row)
return pixels2
highfreq_factor = 4
@ -158,18 +161,16 @@ class ImageHasher:
logger.exception("p_hash error converting to greyscale and resizing")
return 0
pixels = convert_to_array(list(image.getdata()))
pixels = convert_image_to_ndarray(image)
dct = generate_dct2(generate_dct2(pixels, axis=0), axis=1)
dctlowfreq = list(itertools.chain.from_iterable(row[:8] for row in dct[:8]))
med = median(dctlowfreq)
# Convert to a bit string
diff = "".join(str(int(item > med)) for item in dctlowfreq)
h = 0
for i, p in enumerate(dctlowfreq):
if p > med:
h |= 1 << len(dctlowfreq) - 1 - i
result = int(diff, 2)
return h
return result
# accepts 2 hashes (longs or hex strings) and returns the hamming distance
@ -190,4 +191,5 @@ class ImageHasher:
# xor the two numbers
n = n1 ^ n2
return bin(n).count("1")
# count up the 1's in the binary string
return sum(b == "1" for b in bin(n)[2:])

View File

@ -19,7 +19,7 @@ from __future__ import annotations
import logging
import platform
from PyQt5 import QtCore, QtGui, QtWidgets, sip, uic
from PyQt6 import QtCore, QtGui, QtWidgets, sip, uic
from comictaggerlib.ui import ui_path
@ -82,7 +82,7 @@ class ImagePopup(QtWidgets.QDialog):
screen_size.width(),
screen_size.height(),
QtCore.Qt.AspectRatioMode.IgnoreAspectRatio,
QtCore.Qt.SmoothTransformation,
QtCore.Qt.TransformationMode.SmoothTransformation,
)
self.setMask(self.clientBgPixmap.mask())
@ -104,7 +104,10 @@ class ImagePopup(QtWidgets.QDialog):
if self.imagePixmap.width() > win_w or self.imagePixmap.height() > win_h:
# scale the pixmap to fit in the frame
display_pixmap = self.imagePixmap.scaled(
win_w, win_h, QtCore.Qt.AspectRatioMode.KeepAspectRatio, QtCore.Qt.SmoothTransformation
win_w,
win_h,
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
QtCore.Qt.TransformationMode.SmoothTransformation,
)
self.lblImage.setPixmap(display_pixmap)
else:

View File

@ -16,7 +16,6 @@
# limitations under the License.
from __future__ import annotations
import copy
import io
import logging
from operator import attrgetter
@ -135,7 +134,7 @@ class IssueIdentifier:
def calculate_hash(self, image_data: bytes = b"", image: Image.Image | None = None) -> int:
if self.image_hasher == 3:
return ImageHasher(data=image_data, image=image).perception_hash()
return ImageHasher(data=image_data, image=image).p_hash()
if self.image_hasher == 2:
return -1 # ImageHasher(data=image_data, image=image).average_hash2()
@ -186,7 +185,7 @@ class IssueIdentifier:
self.log_msg(f"Found {len(issues)} series that have an issue #{terms['issue_number']}")
final_cover_matching, full = self._cover_matching(terms, images, extra_images, issues)
final_cover_matching = self._cover_matching(terms, images, extra_images, issues)
# One more test for the case choosing limited series first issue vs a trade with the same cover:
# if we have a given issue count > 1 and the series from CV has count==1, remove it from match list
@ -198,9 +197,10 @@ class IssueIdentifier:
)
final_cover_matching.remove(match)
best_score = 0
if final_cover_matching:
best_score = final_cover_matching[0].distance
else:
best_score = 0
if best_score >= self.min_score_thresh:
if len(final_cover_matching) == 1:
self.log_msg("No matching pages in the issue.")
@ -220,7 +220,7 @@ class IssueIdentifier:
self.log_msg("--------------------------------------------------------------------------")
search_result = self.result_one_good_match
elif len(final_cover_matching) == 0:
elif len(self.match_list) == 0:
self.log_msg("--------------------------------------------------------------------------")
self.log_msg("No matches found :(")
self.log_msg("--------------------------------------------------------------------------")
@ -229,7 +229,6 @@ class IssueIdentifier:
# we've got multiple good matches:
self.log_msg("More than one likely candidate.")
search_result = self.result_multiple_good_matches
final_cover_matching = full # display more options for the user to pick
self.log_msg("--------------------------------------------------------------------------")
for match_item in final_cover_matching:
self._print_match(match_item)
@ -307,42 +306,35 @@ class IssueIdentifier:
def _get_issue_cover_match_score(
self,
primary_img_url: ImageHash | None,
alt_urls: list[ImageHash],
primary_img_url: str | ImageHash,
alt_urls: list[str | ImageHash],
local_hashes: list[tuple[str, int]],
use_alt_urls: bool = False,
) -> Score:
# local_hashes is a list of pre-calculated hashes.
# use_alt_urls - indicates to use alternate covers
# use_alt_urls - indicates to use alternate covers from CV
# If there is no ImageHash or no URL and Kind, return 100 for a bad match
if primary_img_url is None or (not primary_img_url.Kind and not primary_img_url.URL and not use_alt_urls):
# If there is no URL return 100
if not primary_img_url:
return Score(score=100, url="", remote_hash=0, local_hash=0, local_hash_name="0")
self._user_canceled()
remote_hashes = []
# If the cover is ImageHash and the alternate covers are URLs, the alts will not be hashed/checked currently
if isinstance(primary_img_url, ImageHash):
# ImageHash doesn't have a url so we just give it an empty string
remote_hashes.append(("", primary_img_url.Hash))
if use_alt_urls and alt_urls:
remote_hashes.extend(("", alt_hash.Hash) for alt_hash in alt_urls if isinstance(alt_hash, ImageHash))
else:
urls = [primary_img_url]
if use_alt_urls:
only_urls = [url for url in alt_urls if isinstance(url, str)]
urls.extend(only_urls)
self.log_msg(f"[{len(only_urls)} alt. covers]")
if primary_img_url.Kind:
remote_hashes.append((primary_img_url.URL, primary_img_url.Hash))
self.log_msg(
f"Using provided hash for cover matching. Hash: {primary_img_url.Hash}, Kind: {primary_img_url.Kind}"
)
elif primary_img_url.URL:
remote_hashes = self._get_remote_hashes([primary_img_url.URL])
self.log_msg(f"Downloading image for cover matching: {primary_img_url.URL}")
if use_alt_urls and alt_urls:
only_urls = []
for alt_url in alt_urls:
if alt_url.Kind:
remote_hashes.append((alt_url.URL, alt_url.Hash))
elif alt_url.URL:
only_urls.append(alt_url.URL)
if only_urls:
remote_hashes.extend(self._get_remote_hashes(only_urls))
self.log_msg(f"[{len(remote_hashes) - 1} alt. covers]")
remote_hashes = self._get_remote_hashes(urls)
score_list = []
done = False
@ -533,12 +525,13 @@ class IssueIdentifier:
)
try:
image_url = issue._cover_image if isinstance(issue._cover_image, str) else ""
# We only include urls in the IssueResult so we don't have to deal with it down the line
# TODO: display the hash to the user so they know a direct hash was used instead of downloading an image
alt_urls: list[str] = [img.URL for img in issue._alternate_images]
alt_urls: list[str] = [url for url in issue._alternate_images if isinstance(url, str)]
score_item = self._get_issue_cover_match_score(
issue._cover_image, issue._alternate_images, hashes, use_alt_urls=use_alternates
image_url, issue._alternate_images, hashes, use_alt_urls=use_alternates
)
except Exception:
logger.exception(f"Scoring series{alternate} covers failed")
@ -556,7 +549,7 @@ class IssueIdentifier:
month=issue.month,
year=issue.year,
publisher=None,
image_url=issue._cover_image.URL if issue._cover_image else "",
image_url=image_url,
alt_image_urls=alt_urls,
description=issue.description or "",
)
@ -639,7 +632,7 @@ class IssueIdentifier:
images: list[tuple[str, Image.Image]],
extra_images: list[tuple[str, Image.Image]],
issues: list[tuple[ComicSeries, GenericMetadata]],
) -> tuple[list[IssueResult], list[IssueResult]]:
) -> list[IssueResult]:
# Set hashing kind, will presume all hashes are of the same kind
for series, issue in issues:
if isinstance(issue._cover_image, ImageHash):
@ -654,7 +647,7 @@ class IssueIdentifier:
if len(cover_matching_1) == 0:
self.log_msg(":-( no matches!")
return cover_matching_1, cover_matching_1
return cover_matching_1
# sort list by image match scores
cover_matching_1.sort(key=attrgetter("distance"))
@ -688,14 +681,8 @@ class IssueIdentifier:
# now drop down into the rest of the processing
best_score = final_cover_matching[0].distance
full = copy.copy(final_cover_matching)
# now pare down list, remove any item more than specified distant from the top scores
for match_item in reversed(final_cover_matching):
if match_item.distance > (best_score + self.min_score_distance):
final_cover_matching.remove(match_item)
# If we have 5 or less results we don't trim as the user can pick
if len(final_cover_matching) > 5:
full = final_cover_matching
return final_cover_matching, full
return final_cover_matching

View File

@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi.genericmetadata import GenericMetadata
from comicapi.issuestring import IssueString
@ -223,9 +223,8 @@ class IssueSelectionWindow(QtWidgets.QDialog):
self.issue_number = issue.issue or ""
# We don't currently have a way to display hashes to the user
# TODO: display the hash to the user so they know it will be used for cover matching
alt_images = [url.URL for url in issue._alternate_images]
cover = issue._cover_image.URL if issue._cover_image else ""
self.coverWidget.set_issue_details(self.issue_id, [cover, *alt_images])
alt_images = [url for url in issue._alternate_images if isinstance(url, str)]
self.coverWidget.set_issue_details(self.issue_id, [str(issue._cover_image) or "", *alt_images])
if issue.description is None:
self.set_description(self.teDescription, "")
else:

View File

@ -46,8 +46,7 @@ def setup_logging(verbose: int, log_dir: pathlib.Path) -> None:
logging.basicConfig(
handlers=[stream_handler, file_handler],
level=logging.WARNING,
style="{",
format="{asctime} | {name:<30} | {levelname:<7} | {message}",
format="%(asctime)s | %(name)s | %(levelname)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)

View File

@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comictaggerlib.ui import qtutils, ui_path

View File

@ -88,12 +88,7 @@ def configure_locale() -> None:
if code != "":
os.environ["LANG"] = f"{code}.utf-8"
# Get locale settings from OS, fall back to en_US or C in case of error for minimalist or misconfigured systems
try:
locale.setlocale(locale.LC_ALL, "")
except locale.Error:
locale.setlocale(locale.LC_ALL, "C")
logger.error("Couldn't set the locale: unsupported locale setting; falling back to 'C' locale")
locale.setlocale(locale.LC_ALL, "")
sys.stdout.reconfigure(encoding=sys.getdefaultencoding()) # type: ignore[union-attr]
sys.stderr.reconfigure(encoding=sys.getdefaultencoding()) # type: ignore[union-attr]
sys.stdin.reconfigure(encoding=sys.getdefaultencoding()) # type: ignore[union-attr]
@ -301,7 +296,7 @@ class App:
return gui.open_tagger_window(self.talkers, self.config, error)
except ImportError:
self.config[0].Runtime_Options__no_gui = True
logger.warning("PyQt5 is not available. ComicTagger is limited to command-line mode.")
logger.warning("PyQt6 is not available. ComicTagger is limited to command-line mode.")
# GUI mode is not available or CLI mode was requested
if error and error[1]:

View File

@ -19,7 +19,7 @@ from __future__ import annotations
import logging
import os
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comicapi.comicarchive import ComicArchive
from comictaggerlib.coverimagewidget import CoverImageWidget

View File

@ -1,4 +1,4 @@
"""A PyQt5 dialog to show a message and let the user check a box
"""A PyQt6 dialog to show a message and let the user check a box
Example usage:
@ -29,7 +29,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtWidgets
from PyQt6 import QtCore, QtWidgets
logger = logging.getLogger(__name__)

View File

@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata

View File

@ -1,4 +1,4 @@
"""A PyQt5 widget for editing the page list info"""
"""A PyQt6 widget for editing the page list info"""
#
# Copyright 2012-2014 ComicTagger Authors
@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from comicapi.comicarchive import ComicArchive, tags
from comicapi.genericmetadata import GenericMetadata, PageMetadata, PageType
@ -145,7 +145,7 @@ class PageListEditor(QtWidgets.QWidget):
if show_shortcut:
text = text + " (" + shortcut + ")"
self.cbPageType.addItem(text, user_data)
action_item = QtWidgets.QAction(shortcut, self)
action_item = QtGui.QAction(shortcut, self)
action_item.triggered.connect(lambda: self.select_page_type_item(self.cbPageType.findData(user_data)))
action_item.setShortcut(shortcut)
self.addAction(action_item)

View File

@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore
from PyQt6 import QtCore
from comicapi.comicarchive import ComicArchive

View File

@ -1,4 +1,4 @@
"""A PyQt5 dialog to show ID log and progress"""
"""A PyQt6 dialog to show ID log and progress"""
#
# Copyright 2012-2014 ComicTagger Authors
@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comictaggerlib.ui import ui_path

View File

@ -1,18 +1,11 @@
from __future__ import annotations
import argparse
import contextlib
import itertools
import logging
import pathlib
import sqlite3
import statistics
import threading
from collections.abc import Iterable
from enum import auto
from functools import cached_property
from io import BytesIO
from typing import TYPE_CHECKING, Callable, NamedTuple, TypedDict, overload
from typing import Callable, TypedDict, cast
from urllib.parse import urljoin
import requests
@ -25,217 +18,47 @@ from comictaggerlib.ctsettings.settngs_namespace import SettngsNS
from comictaggerlib.imagehasher import ImageHasher
from comictalker import ComicTalker
if TYPE_CHECKING:
from _typeshed import SupportsRichComparison
logger = logging.getLogger(__name__)
__version__ = "0.1"
class HashType(utils.StrEnum):
# Unknown = 'Unknown'
PHASH = auto()
DHASH = auto()
AHASH = auto()
DHASH = auto()
PHASH = auto()
def __repr__(self) -> str:
return str(self)
class SimpleResult(TypedDict):
Distance: int
# Mapping of domains (eg comicvine.gamespot.com) to IDs
IDList: dict[str, list[str]]
class Hash(TypedDict):
Hash: int
Kind: HashType
class ID_dict(TypedDict):
Domain: str
ID: str
class ID(NamedTuple):
Domain: str
ID: str
Kind: str
class Result(TypedDict):
Hash: Hash
ID: ID_dict
# Mapping of domains (eg comicvine.gamespot.com) to IDs
IDs: dict[str, list[str]]
Distance: int
EquivalentIDs: list[ID_dict]
Hash: Hash
class ResultList(NamedTuple):
distance: int
results: list[Result]
def ihash(types: str) -> list[HashType]:
result: list[HashType] = []
types = types.casefold()
choices = ", ".join(HashType)
for typ in utils.split(types, ","):
if typ not in list(HashType):
raise argparse.ArgumentTypeError(f"invalid choice: {typ} (choose from {choices.upper()})")
result.append(HashType[typ.upper()])
class Distance(NamedTuple):
hash: HashType
distance: int
def __repr__(self) -> str:
return f"{self.hash}={self.distance}"
class Hashes:
hashes: tuple[Result, ...]
id: ID
def __init__(
self,
*,
hashes: Iterable[Result],
id: ID | None = None, # noqa: A002
) -> None:
self.hashes = tuple(
sorted(hashes, key=lambda x: list(HashType.__members__.values()).index(HashType(x["Hash"]["Kind"])))
)
self.count = len(self.hashes)
if id is None:
self.id = ID(**self.hash()["ID"])
else:
self.id = id
@overload
def hash(self) -> Result: ...
@overload
def hash(self, hash_type: HashType) -> Result | None: ...
def hash(self, hash_type: HashType | None = None) -> Result | None:
if hash_type:
for _hash in self.hashes:
if _hash["Hash"]["Kind"] == hash_type:
return _hash
return None
return self.hashes[0]
@cached_property
def distance(self) -> int:
return int(statistics.mean(x["Distance"] for x in self.hashes))
@cached_property
def score(self) -> int:
# Get the distances as a value between 0 and 1. Lowest value is 55/64 ~ 0.85
hashes: list[float] = [(64 - x["Distance"]) / 64 for x in self.hashes]
hashes.extend((64 - 9) // 64 for x in range(len(HashType) - len(hashes)))
mod = {
3: 64 / 64,
2: 60 / 64,
1: 58 / 64,
}[len(self.hashes)]
# Add an extra mod value to bring the score up if there are more hashes
hashes.append(mod)
return int(statistics.mean(int(x * 100) for x in hashes))
@cached_property
def kinds(self) -> set[HashType]:
return {HashType(x["Hash"]["Kind"]) for x in self.hashes}
@cached_property
def distances(self) -> tuple[Distance, ...]:
return tuple(Distance(HashType(x["Hash"]["Kind"]), x["Distance"]) for x in self.hashes)
@cached_property
def exact(self) -> bool:
return self.score >= 95 and len(self.hashes) > 1
@cached_property
def key(self) -> tuple[SupportsRichComparison, ...]:
return (-self.count, tuple(x["Distance"] for x in self.hashes))
def should_break(self, previous: Hashes) -> bool:
group_limit = 3
if (previous.count - self.count) == 1:
group_limit = 2
if (previous.count - self.count) == 2:
group_limit = 0
if (self.distance - previous.distance) > group_limit:
return True
if len(self.hashes) == 1 and self.hashes[0]["Hash"]["Kind"] == HashType.AHASH:
if previous.count > 1:
return True
return False
def __repr__(self) -> str:
return f"Hashes(id={self.id!r}, count={self.count!r}, distance={self.distance!r}, score={self.score!r}, 'exact'={self.exact!r})"
class NameMatches(NamedTuple):
confident_match: tuple[tuple[Hashes, GenericMetadata], ...]
probable_match: tuple[tuple[Hashes, GenericMetadata], ...]
other_match: tuple[tuple[Hashes, GenericMetadata], ...]
class IDCache:
def __init__(self, cache_folder: pathlib.Path, version: str) -> None:
self.cache_folder = cache_folder
self.db_file = cache_folder / "bad_ids.db"
self.version = version
self.local: threading.Thread | None = None
self.db: sqlite3.Connection | None = None
self.create_cache_db()
def clear_cache(self) -> None:
try:
self.close()
except Exception:
pass
try:
self.db_file.unlink(missing_ok=True)
except Exception:
pass
def connect(self) -> sqlite3.Connection:
if self.local != threading.current_thread():
self.db = None
if self.db is None:
self.local = threading.current_thread()
self.db = sqlite3.connect(self.db_file)
self.db.row_factory = sqlite3.Row
self.db.text_factory = str
return self.db
def close(self) -> None:
if self.db is not None:
self.db.close()
self.db = None
def create_cache_db(self) -> None:
# create tables
with self.connect() as con, contextlib.closing(con.cursor()) as cur:
cur.execute(
"""CREATE TABLE IF NOT EXISTS bad_ids(
domain TEXT NOT NULL,
id TEXT NOT NULL,
PRIMARY KEY (id, domain))"""
)
def add_ids(self, bad_ids: set[ID]) -> None:
with self.connect() as con, contextlib.closing(con.cursor()) as cur:
for bad_id in bad_ids:
cur.execute(
"""INSERT into bad_ids (domain, ID) VALUES (?, ?) ON CONFLICT DO NOTHING""",
(bad_id.Domain, bad_id.ID),
)
def get_ids(self) -> dict[str, set[ID]]:
# purge stale series info
ids: dict[str, set[ID]] = utils.DefaultDict(default=lambda x: set())
with self.connect() as con, contextlib.closing(con.cursor()) as cur:
cur.execute(
"""SELECT * FROM bad_ids""",
)
for record in cur.fetchall():
ids[record["domain"]] |= {ID(Domain=record["domain"], ID=record["id"])}
return ids
if not result:
raise argparse.ArgumentTypeError(f"invalid choice: {types} (choose from {choices.upper()})")
return result
def settings(manager: settngs.Manager) -> None:
@ -244,7 +67,7 @@ def settings(manager: settngs.Manager) -> None:
"-u",
default="https://comic-hasher.narnian.us",
type=utils.parse_url,
help="Server to use for searching cover hashes",
help="Website to use for searching cover hashes",
)
manager.add_setting(
"--max",
@ -252,70 +75,47 @@ def settings(manager: settngs.Manager) -> None:
type=int,
help="Maximum score to allow. Lower score means more accurate",
)
manager.add_setting(
"--simple",
default=False,
action=argparse.BooleanOptionalAction,
help="Whether to retrieve simple results or full results",
)
manager.add_setting(
"--aggressive-filtering",
default=False,
action=argparse.BooleanOptionalAction,
help="Will filter out matches more aggressively",
help="Will filter out worse matches if better matches are found",
)
manager.add_setting(
"--hash",
default=list(HashType),
type=HashType,
nargs="+",
default="ahash, dhash, phash",
type=ihash,
help="Pick what hashes you want to use to search (default: %(default)s)",
)
manager.add_setting(
"--exact-only",
default=True,
action=argparse.BooleanOptionalAction,
help="Skip non-exact matches if exact matches are found",
help="Skip non-exact matches if we have exact matches",
)
KNOWN_BAD_IDS: dict[str, set[ID]] = utils.DefaultDict(
{
"comicvine.gamespot.com": {
ID("comicvine.gamespot.com", "737049"),
ID("comicvine.gamespot.com", "753078"),
ID("comicvine.gamespot.com", "390219"),
}
},
default=lambda x: set(),
)
def limit(results: Iterable[Hashes], limit: int) -> list[list[Hashes]]:
hashes: list[list[Hashes]] = []
r = list(results)
for _, result_list in itertools.groupby(r, key=lambda r: r.count):
result_l = list(result_list)
hashes.append(sorted(result_l[:limit], key=lambda r: r.key))
limit -= len(result_l)
if limit <= 0:
break
return hashes
class QuickTag:
def __init__(
self, url: utils.Url, domain: str, talker: ComicTalker, config: SettngsNS, output: Callable[..., None]
self, url: utils.Url, domain: str, talker: ComicTalker, config: SettngsNS, output: Callable[[str], None]
):
self.output = output
self.url = url
self.talker = talker
self.domain = domain
self.config = config
self.bad_ids = IDCache(config.Runtime_Options__config.user_cache_dir, __version__)
self.known_bad_ids = self.bad_ids.get_ids()
for domain, bad_ids in KNOWN_BAD_IDS.items():
self.known_bad_ids[domain] |= bad_ids
def id_comic(
self,
ca: comicarchive.ComicArchive,
tags: GenericMetadata,
simple: bool,
hashes: set[HashType],
exact_only: bool,
interactive: bool,
@ -328,10 +128,6 @@ class QuickTag:
cover_index = tags.get_cover_page_index_list()[0]
cover_image = Image.open(BytesIO(ca.get_page(cover_index)))
cover_image.load()
self.limit = 30
if aggressive_filtering:
self.limit = 15
self.output(f"Tagging: {ca.path}")
@ -343,47 +139,35 @@ class QuickTag:
if HashType.DHASH in hashes:
dhash = hex(hasher.difference_hash())[2:]
if HashType.PHASH in hashes:
phash = hex(hasher.perception_hash())[2:]
phash = hex(hasher.p_hash())[2:]
logger.info(f"Searching with {ahash=}, {dhash=}, {phash=}")
self.output("Searching hashes")
logger.info(
"Searching with ahash=%s, dhash=%s, phash=%s",
ahash,
dhash,
phash,
)
results = self.SearchHashes(max_hamming_distance, ahash, dhash, phash, exact_only)
logger.debug("results=%s", results)
if not results:
self.output("No results found for QuickTag")
return None
results = self.SearchHashes(simple, max_hamming_distance, ahash, dhash, phash, exact_only)
logger.debug(f"{results=}")
IDs = [
Hashes(hashes=(g[1] for g in group), id=i)
for i, group in itertools.groupby(
sorted(((ID(**r["ID"]), (r)) for r in results), key=lambda r: (r[0], r[1]["Hash"]["Kind"])),
key=lambda r: r[0],
if simple:
filtered_simple_results = self.filter_simple_results(
cast(list[SimpleResult], results), interactive, aggressive_filtering
)
]
IDs = sorted(IDs, key=lambda r: r.key)
self.output(f"Total number of IDs found: {len(IDs)}")
logger.debug("IDs=%s", IDs)
metadata_simple_results = self.get_simple_results(filtered_simple_results)
chosen_result = self.display_simple_results(metadata_simple_results, tags, interactive)
else:
filtered_results = self.filter_results(cast(list[Result], results), interactive, aggressive_filtering)
metadata_results = self.get_results(filtered_results)
chosen_result = self.display_results(metadata_results, tags, interactive)
aggressive_results, display_results = self.match_results(IDs, aggressive_filtering)
chosen_result = self.display_results(
aggressive_results, display_results, ca, tags, interactive, aggressive_filtering
)
if chosen_result:
return self.talker.fetch_comic_data(issue_id=chosen_result.ID)
return None
return self.talker.fetch_comic_data(issue_id=chosen_result.issue_id)
def SearchHashes(
self, max_hamming_distance: int, ahash: str, dhash: str, phash: str, exact_only: bool
) -> list[Result]:
self, simple: bool, max_hamming_distance: int, ahash: str, dhash: str, phash: str, exact_only: bool
) -> list[SimpleResult] | list[Result]:
resp = requests.get(
urljoin(self.url.url, "/match_cover_hash"),
params={
"simple": str(simple),
"max": str(max_hamming_distance),
"ahash": ahash,
"dhash": dhash,
@ -402,205 +186,206 @@ class QuickTag:
raise Exception(f"Failed to retrieve results from the server: {text}")
return resp.json()["results"]
def get_mds(self, ids: Iterable[ID]) -> list[GenericMetadata]:
def get_mds(self, results: list[SimpleResult] | list[Result]) -> list[GenericMetadata]:
md_results: list[GenericMetadata] = []
ids = {md_id for md_id in ids if md_id.Domain == self.domain}
all_ids = {md_id.ID for md_id in ids if md_id.Domain == self.domain}
results.sort(key=lambda r: r["Distance"])
all_ids = set()
for res in results:
all_ids.update(res.get("IDList", res.get("IDs", {})).get(self.domain, [])) # type: ignore[attr-defined]
self.output(f"Retrieving basic {self.talker.name} data")
# Try to do a bulk fetch of basic issue data, if we have more than 1 id
if hasattr(self.talker, "fetch_comics") and len(all_ids) > 1:
# Try to do a bulk feth of basic issue data
if hasattr(self.talker, "fetch_comics"):
md_results = self.talker.fetch_comics(issue_ids=list(all_ids))
else:
for md_id in all_ids:
md_results.append(self.talker.fetch_comic_data(issue_id=md_id))
retrieved_ids = {ID(self.domain, md.issue_id) for md in md_results} # type: ignore[arg-type]
bad_ids = ids - retrieved_ids
if bad_ids:
logger.debug("Adding bad IDs to known list: %s", bad_ids)
self.known_bad_ids[self.domain] |= bad_ids
self.bad_ids.add_ids(bad_ids)
return md_results
def _filter_hash_results(self, results: Iterable[Hashes]) -> list[Hashes]:
groups: list[Hashes] = []
previous: dict[HashType, None | int] = dict.fromkeys(HashType)
skipped: list[Hashes] = []
for hash_group in sorted(results, key=lambda r: r.key):
b = []
if skipped:
skipped.append(hash_group)
for _hash in hash_group.hashes:
prev = previous[_hash["Hash"]["Kind"]]
b.append(prev is not None and (_hash["Distance"] - prev) > 3)
previous[_hash["Hash"]["Kind"]] = _hash["Distance"]
if b and all(b):
skipped.append(hash_group)
def get_simple_results(self, results: list[SimpleResult]) -> list[tuple[int, GenericMetadata]]:
md_results = []
mds = self.get_mds(results)
groups.append(hash_group)
if skipped:
logger.debug(
"Filtering bottom %d of %s results as they seem to all be substantially worse",
len(skipped),
len(skipped) + len(groups),
)
return groups
# Re-associate the md to the distance
for res in results:
for md in mds:
if md.issue_id in res["IDList"].get(self.domain, []):
md_results.append((res["Distance"], md))
return md_results
def _filter_hashes(self, hashes: Iterable[Hashes], aggressive_filtering: bool) -> tuple[list[Hashes], list[Hashes]]:
hashes = list(hashes)
if not hashes:
return [], []
def get_results(self, results: list[Result]) -> list[tuple[int, Hash, GenericMetadata]]:
md_results = []
mds = self.get_mds(results)
aggressive_skip = False
skipped: list[Hashes] = []
hashes = sorted(hashes, key=lambda r: r.key)
# Re-associate the md to the distance
for res in results:
for md in mds:
if md.issue_id in res["IDs"].get(self.domain, []):
md_results.append((res["Distance"], res["Hash"], md))
return md_results
groups: list[Hashes] = [hashes[0]]
aggressive_groups = [hashes[0]]
previous = hashes[0]
for group in hashes[1:]:
group_limit = 3
if (group.distance - previous.distance) > group_limit or skipped:
skipped.append(group)
elif aggressive_filtering:
if group.should_break(previous):
aggressive_skip = True
def filter_simple_results(
self, results: list[SimpleResult], interactive: bool, aggressive_filtering: bool
) -> list[SimpleResult]:
# If there is a single exact match return it
exact = [r for r in results if r["Distance"] == 0]
if len(exact) == 1:
logger.info("Exact result found. Ignoring any others")
return exact
if not aggressive_skip:
aggressive_groups.append(group)
# If ther are more than 4 results and any are better than 6 return the first group of results
if len(results) > 4:
dist: list[tuple[int, list[SimpleResult]]] = []
filtered_results: list[SimpleResult] = []
for distance, group in itertools.groupby(results, key=lambda r: r["Distance"]):
dist.append((distance, list(group)))
if aggressive_filtering and dist[0][0] < 6:
logger.info(f"Aggressive filtering is enabled. Dropping matches above {dist[0]}")
for _, res in dist[:1]:
filtered_results.extend(res)
logger.debug(f"{filtered_results=}")
return filtered_results
return results
groups.append(group)
previous = group
if skipped or len(groups) - len(aggressive_groups) > 0:
logger.debug("skipping (%d|%d)/%d results", len(skipped), len(groups) - len(aggressive_groups), len(hashes))
return aggressive_groups, groups
def filter_results(self, results: list[Result], interactive: bool, aggressive_filtering: bool) -> list[Result]:
ahash_results = sorted([r for r in results if r["Hash"]["Kind"] == "ahash"], key=lambda r: r["Distance"])
dhash_results = sorted([r for r in results if r["Hash"]["Kind"] == "dhash"], key=lambda r: r["Distance"])
phash_results = sorted([r for r in results if r["Hash"]["Kind"] == "phash"], key=lambda r: r["Distance"])
hash_results = [phash_results, dhash_results, ahash_results]
def match_results(self, results: list[Hashes], aggressive_filtering: bool) -> tuple[list[Hashes], list[Hashes]]:
exact = [r for r in results if r.exact]
# If any of the hash types have a single exact match return it. Prefer phash for no particular reason
for hashed_result in hash_results:
exact = [r for r in hashed_result if r["Distance"] == 0]
if len(exact) == 1:
logger.info(f"Exact {exact[0]['Hash']['Kind']} result found. Ignoring any others")
return exact
limited = limit(results, self.limit)
logger.debug("Only looking at the top %d out of %d hash scores", min(len(results), self.limit), len(results))
results_filtered = False
# If any of the hash types have more than 4 results and they have results better than 6 return the first group of results for each hash type
for i, hashed_results in enumerate(hash_results):
filtered_results: list[Result] = []
if len(hashed_results) > 4:
dist: list[tuple[int, list[Result]]] = []
for distance, group in itertools.groupby(hashed_results, key=lambda r: r["Distance"]):
dist.append((distance, list(group)))
if aggressive_filtering and dist[0][0] < 6:
logger.info(
f"Aggressive filtering is enabled. Dropping {dist[0][1][0]['Hash']['Kind']} matches above {dist[0][0]}"
)
for _, res in dist[:1]:
filtered_results.extend(res)
# Filter out results if there is a gap > 3 in distance
for i, hashed_results in enumerate(limited):
limited[i] = self._filter_hash_results(hashed_results)
if filtered_results:
hash_results[i] = filtered_results
results_filtered = True
if results_filtered:
logger.debug(f"filtered_results={list(itertools.chain(*hash_results))}")
return list(itertools.chain(*hash_results))
aggressive, normal = self._filter_hashes(itertools.chain.from_iterable(limited), aggressive_filtering)
if exact:
self.output(f"{len(exact)} exact result found. Ignoring any others: {exact}")
aggressive = exact # I've never seen more than 2 "exact" matches
return aggressive, normal
def match_names(self, tags: GenericMetadata, results: list[tuple[Hashes, GenericMetadata]]) -> NameMatches:
confident_match: list[tuple[Hashes, GenericMetadata]] = []
probable_match: list[tuple[Hashes, GenericMetadata]] = []
other_match: list[tuple[Hashes, GenericMetadata]] = []
for result, md in results:
assert md.issue_id
assert md.series
assert md.issue
titles_match = tags.series and utils.titles_match(tags.series, md.series, threshold=70)
issues_match = tags.issue and IssueString(tags.issue).as_string() == IssueString(md.issue).as_string()
if titles_match and issues_match:
confident_match.append((result, md))
elif (titles_match or issues_match) and result.distance < 6:
probable_match.append((result, md))
else:
other_match.append((result, md))
return NameMatches(tuple(confident_match), tuple(probable_match), tuple(other_match))
def display_results(
self,
results: list[Hashes],
display_results: list[Hashes],
ca: comicarchive.ComicArchive,
tags: GenericMetadata,
interactive: bool,
aggressive_filtering: bool,
) -> ID | None:
if len(results) < 1:
return None
# we only return early if we don't have a series name or issue as get_mds will pull the full info if there is only one result
if (
not (tags.series or tags.issue)
and not interactive
and aggressive_filtering
and len(results) == 1
and (results[0].distance < 4 or results[0].score >= 95)
):
self.output("Found a single match < 4. Assuming it's correct")
return results[0].id
limited = limit((r for r in results if r.id not in KNOWN_BAD_IDS.get(self.domain, set())), self.limit)
ids = {r.id: r for r in itertools.chain.from_iterable(limited)}
mds = [(ids[ID(self.domain, md.issue_id)], md) for md in self.get_mds(ids)] # type: ignore[arg-type]
matches = self.match_names(tags, mds)
if len(matches.confident_match) == 1:
result, md = matches.confident_match[0]
self.output(f"Found confident {result.distances} match with series name {md.series!r}")
return result.id
elif len(matches.probable_match) == 1:
result, md = matches.probable_match[0]
self.output(f"Found probable {result.distances} match with series name {md.series!r}")
return result.id
elif len(matches.other_match) == 1 and matches.other_match[0][0].distance < 4:
result, md = matches.other_match[0]
self.output(f"Found a {result.distances} match with series name {md.series!r}")
return result.id
def display_simple_results(
self, md_results: list[tuple[int, GenericMetadata]], tags: GenericMetadata, interactive: bool
) -> GenericMetadata:
if len(md_results) < 1:
return GenericMetadata()
if len(md_results) == 1 and md_results[0][0] <= 4:
self.output("Found a single match <=4. Assuming it's correct")
return md_results[0][1]
series_match: list[GenericMetadata] = []
for score, md in md_results:
if (
score < 10
and tags.series
and md.series
and utils.titles_match(tags.series, md.series)
and IssueString(tags.issue).as_string() == IssueString(md.issue).as_string()
):
series_match.append(md)
if len(series_match) == 1:
self.output(f"Found match with series name {series_match[0].series!r}")
return series_match[0]
if not interactive:
return None
return GenericMetadata()
limited_interactive = limit(
(r for r in display_results if r.id not in KNOWN_BAD_IDS.get(self.domain, set())), self.limit
)
ids_interactive = {r.id: r for r in itertools.chain.from_iterable(limited_interactive)}
mds_interactive = [(ids_interactive[ID(self.domain, md.issue_id)], md) for md in self.get_mds(ids_interactive)] # type: ignore[arg-type]
interactive_only_ids = set(ids_interactive).difference(ids)
items = sorted(mds_interactive, key=lambda r: r[0].key)
self.output(
f"\nSelect result for {ca.path.name}, page count: {ca.get_number_of_pages()} :\n", force_output=True
)
for counter, r in enumerate(items, 1):
hashes, md = r
md_results.sort(key=lambda r: (r[0], len(r[1].publisher or "")))
for counter, r in enumerate(md_results, 1):
self.output(
"{}{:2}. {:6} {!s} distance: {}({}) - {} #{} [{}] ({}/{}) - {}".format(
" " if hashes.id in interactive_only_ids else "*",
" {:2}. score: {} [{:15}] ({:02}/{:04}) - {} #{} - {}".format(
counter,
hashes.id.ID,
hashes.distances,
hashes.distance,
hashes.score,
md.series or "",
md.issue or "",
md.publisher or "",
md.month or "",
md.year or "",
md.title or "",
r[0],
r[1].publisher,
r[1].month or 0,
r[1].year or 0,
r[1].series,
r[1].issue,
r[1].title,
),
force_output=True,
)
while True:
i = input(
f'Please select a result to tag the comic with or "q" to quit: [1-{len(results)}] ',
f'Please select a result to tag the comic with or "q" to quit: [1-{len(md_results)}] ',
).casefold()
if i.isdigit() and int(i) in range(1, len(results) + 1):
if i.isdigit() and int(i) in range(1, len(md_results) + 1):
break
if i.startswith("q"):
self.output("User quit without saving metadata")
return None
self.output("")
if i == "q":
logger.warning("User quit without saving metadata")
return GenericMetadata()
return items[int(i) - 1][0].id
return md_results[int(i) - 1][1]
def display_results(
self,
md_results: list[tuple[int, Hash, GenericMetadata]],
tags: GenericMetadata,
interactive: bool,
) -> GenericMetadata:
if len(md_results) < 1:
return GenericMetadata()
if len(md_results) == 1 and md_results[0][0] <= 4:
self.output("Found a single match <=4. Assuming it's correct")
return md_results[0][2]
series_match: dict[str, tuple[int, Hash, GenericMetadata]] = {}
for score, cover_hash, md in md_results:
if (
score < 10
and tags.series
and md.series
and utils.titles_match(tags.series, md.series)
and IssueString(tags.issue).as_string() == IssueString(md.issue).as_string()
):
assert md.issue_id
series_match[md.issue_id] = (score, cover_hash, md)
if len(series_match) == 1:
score, cover_hash, md = list(series_match.values())[0]
self.output(f"Found {cover_hash['Kind']} {score=} match with series name {md.series!r}")
return md
if not interactive:
return GenericMetadata()
md_results.sort(key=lambda r: (r[0], len(r[2].publisher or ""), r[1]["Kind"]))
for counter, r in enumerate(md_results, 1):
self.output(
" {:2}. score: {} {}: {:064b} [{:15}] ({:02}/{:04}) - {} #{} - {}".format(
counter,
r[0],
r[1]["Kind"],
r[1]["Hash"],
r[2].publisher or "",
r[2].month or 0,
r[2].year or 0,
r[2].series or "",
r[2].issue or "",
r[2].title or "",
),
)
while True:
i = input(
f'Please select a result to tag the comic with or "q" to quit: [1-{len(md_results)}] ',
).casefold()
if i.isdigit() and int(i) in range(1, len(md_results) + 1):
break
if i == "q":
self.output("User quit without saving metadata")
return GenericMetadata()
return md_results[int(i) - 1][2]

View File

@ -19,7 +19,7 @@ from __future__ import annotations
import logging
import settngs
from PyQt5 import QtCore, QtWidgets, uic
from PyQt6 import QtCore, QtWidgets, uic
from comicapi import utils
from comicapi.comicarchive import ComicArchive, tags
@ -191,13 +191,13 @@ class RenameWindow(QtWidgets.QDialog):
try:
for idx, comic in enumerate(zip(self.comic_archive_list, self.rename_list), 1):
QtCore.QCoreApplication.processEvents()
if prog_dialog.wasCanceled():
break
prog_dialog.setValue(idx)
prog_dialog.setLabelText(comic[1])
if idx % 5 == 0:
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.processEvents()
folder = get_rename_dir(
comic[0],

View File

@ -21,8 +21,8 @@ import logging
from collections import deque
import natsort
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtCore import QUrl, pyqtSignal
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from PyQt6.QtCore import QUrl, pyqtSignal
from comicapi import utils
from comicapi.comicarchive import ComicArchive
@ -254,6 +254,8 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
self.iddialog.textEdit.append(text.rstrip())
self.iddialog.textEdit.ensureCursorVisible()
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.processEvents()
def identify_progress(self, cur: int, total: int) -> None:
if self.iddialog is not None:
@ -487,13 +489,14 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
def showEvent(self, event: QtGui.QShowEvent) -> None:
self.perform_query()
QtCore.QCoreApplication.processEvents()
if not self.series_list:
QtCore.QCoreApplication.processEvents()
QtWidgets.QMessageBox.information(self, "Search Result", "No matches found!")
QtCore.QTimer.singleShot(200, self.close_me)
elif self.immediate_autoselect:
# defer the immediate autoselect so this dialog has time to pop up
QtCore.QCoreApplication.processEvents()
QtCore.QTimer.singleShot(10, self.do_immediate_autoselect)
def do_immediate_autoselect(self) -> None:

View File

@ -26,7 +26,7 @@ import urllib.parse
from typing import Any, cast
import settngs
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtWidgets, uic
import comictaggerlib.ui.talkeruigenerator
from comicapi import merge, utils

View File

@ -31,7 +31,7 @@ from typing import Any, Callable, cast
import natsort
import settngs
from PyQt5 import QtCore, QtGui, QtNetwork, QtWidgets, uic
from PyQt6 import QtCore, QtGui, QtNetwork, QtWidgets, uic
import comicapi.merge
import comictaggerlib.graphics.resources
@ -234,8 +234,8 @@ class TaggerWindow(QtWidgets.QMainWindow):
if self.config[0].Runtime_Options__preferred_hash:
self.config[0].internal__embedded_hash_type = self.config[0].Runtime_Options__preferred_hash
self.selected_write_tags: list[str] = config[0].internal__write_tags or list(self.enabled_tags())
self.selected_read_tags: list[str] = config[0].internal__read_tags or list(self.enabled_tags())
self.selected_write_tags: list[str] = config[0].internal__write_tags
self.selected_read_tags: list[str] = config[0].internal__read_tags
self.setAcceptDrops(True)
self.view_tag_actions, self.remove_tag_actions = self.tag_actions()
@ -370,9 +370,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
def enabled_tags(self) -> Sequence[str]:
return [tag.id for tag in tags.values() if tag.enabled]
def tag_actions(self) -> tuple[dict[str, QtWidgets.QAction], dict[str, QtWidgets.QAction]]:
view_raw_tags: dict[str, QtWidgets.QAction] = {}
remove_raw_tags: dict[str, QtWidgets.QAction] = {}
def tag_actions(self) -> tuple[dict[str, QtGui.QAction], dict[str, QtGui.QAction]]:
view_raw_tags: dict[str, QtGui.QAction] = {}
remove_raw_tags: dict[str, QtGui.QAction] = {}
for tag in tags.values():
view_raw_tags[tag.id] = self.menuViewRawTags.addAction(f"View Raw {tag.name()} Tags")
view_raw_tags[tag.id].setEnabled(tag.enabled)
@ -449,7 +449,12 @@ class TaggerWindow(QtWidgets.QMainWindow):
def toggle_enable_embedding_hashes(self) -> None:
self.config[0].Runtime_Options__enable_embedding_hashes = self.actionEnableEmbeddingHashes.isChecked()
enable_widget(self.md_attributes["original_hash"], self.config[0].Runtime_Options__enable_embedding_hashes)
enabled_widgets = set()
for tag_id in self.selected_write_tags:
if not tags[tag_id].enabled:
continue
enabled_widgets.update(tags[tag_id].supported_attributes)
enable_widget(self.md_attributes["original_hash"], self.config[0].Runtime_Options__enable_embedding_hashes and 'original_hash' in enabled_widgets)
if not self.leOriginalHash.text().strip():
self.cbHashName.setCurrentText(self.config[0].internal__embedded_hash_type)
if self.config[0].Runtime_Options__enable_embedding_hashes:
@ -480,7 +485,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.actionParse_Filename_split_words.triggered.connect(self.use_filename_split)
self.actionReCalcArchiveInfo.triggered.connect(self.recalc_archive_info)
self.actionSearchOnline.triggered.connect(self.query_online)
self.actionEnableEmbeddingHashes: QtWidgets.QAction
self.actionEnableEmbeddingHashes: QtGui.QAction
self.actionEnableEmbeddingHashes.triggered.connect(self.toggle_enable_embedding_hashes)
self.actionEnableEmbeddingHashes.setChecked(self.config[0].Runtime_Options__enable_embedding_hashes)
# Window Menu
@ -574,13 +579,13 @@ class TaggerWindow(QtWidgets.QMainWindow):
for prog_idx, ca in enumerate(to_zip, 1):
logger.debug("Exporting comic %d: %s", prog_idx, ca.path)
if prog_idx % 10 == 0:
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.processEvents()
if prog_dialog is not None:
if prog_dialog.wasCanceled():
break
prog_dialog.setValue(prog_idx)
prog_dialog.setLabelText(str(ca.path))
QtCore.QCoreApplication.processEvents()
export_name = ca.path.with_suffix(".cbz")
export = True
@ -610,6 +615,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
if prog_dialog is not None:
prog_dialog.hide()
QtCore.QCoreApplication.processEvents()
self.fileSelectionList.remove_archive_list(archives_to_remove)
summary = f"Successfully created {success_count} Zip archive(s)."
@ -1059,7 +1065,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
if dialog.exec():
file_list = dialog.selectedFiles()
if file_list:
self.fileSelectionList.twList.selectRow(self.fileSelectionList.add_path_item(file_list[0])[0])
self.fileSelectionList.twList.selectRow(self.fileSelectionList.add_path_item(file_list[0]))
def select_file(self, folder_mode: bool = False) -> None:
dialog = self.file_dialog(folder_mode=folder_mode)
@ -1594,16 +1600,17 @@ class TaggerWindow(QtWidgets.QMainWindow):
progdialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
progdialog.setMinimumDuration(300)
center_window_on_parent(progdialog)
QtCore.QCoreApplication.processEvents()
failed_list = []
success_count = 0
for prog_idx, ca in enumerate(ca_list, 1):
if prog_idx % 10 == 0:
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.processEvents()
if progdialog.wasCanceled():
break
progdialog.setValue(prog_idx)
progdialog.setLabelText(str(ca.path))
QtCore.QCoreApplication.processEvents()
for tag_id in tag_ids:
if ca.has_tags(tag_id) and ca.is_writable():
if ca.remove_tags(tag_id):
@ -1692,8 +1699,6 @@ class TaggerWindow(QtWidgets.QMainWindow):
failed_list = []
success_count = 0
for prog_idx, ca in enumerate(ca_list, 1):
if prog_idx % 10 == 0:
QtCore.QCoreApplication.processEvents()
ca_saved = False
md, error = self.read_selected_tags(src_tag_ids, ca)
if error is not None:
@ -1704,12 +1709,14 @@ class TaggerWindow(QtWidgets.QMainWindow):
for tag_id in dest_tag_ids:
if ca.has_tags(tag_id):
QtCore.QCoreApplication.processEvents()
if prog_dialog.wasCanceled():
break
prog_dialog.setValue(prog_idx)
prog_dialog.setLabelText(str(ca.path))
center_window_on_parent(prog_dialog)
QtCore.QCoreApplication.processEvents()
if tag_id == "cbi" and self.config[0].Metadata_Options__apply_transform_on_bulk_operation:
md = CBLTransformer(md, self.config[0]).apply()
@ -1746,6 +1753,8 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.atprogdialog.textEdit.append(text.rstrip())
self.atprogdialog.textEdit.ensureCursorVisible()
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.processEvents()
def identify_and_tag_single_archive(
self, ca: ComicArchive, match_results: OnlineMatchResults, dlg: AutoTagStartWindow
@ -1977,7 +1986,6 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.auto_tag_log("==========================================================================\n")
self.auto_tag_log(f"Auto-Tagging {prog_idx} of {len(ca_list)}\n")
self.auto_tag_log(f"{ca.path}\n")
QtCore.QCoreApplication.processEvents()
try:
cover_idx = ca.read_tags(self.selected_read_tags[0]).get_cover_page_index_list()[0]
except Exception as e:
@ -1987,11 +1995,13 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.atprogdialog.set_archive_image(image_data)
self.atprogdialog.set_test_image(b"")
QtCore.QCoreApplication.processEvents()
if self.atprogdialog.isdone:
break
self.atprogdialog.progressBar.setValue(prog_idx)
self.atprogdialog.label.setText(str(ca.path))
QtCore.QCoreApplication.processEvents()
if ca.is_writable():
success, match_results = self.identify_and_tag_single_archive(ca, match_results, atstartdlg)
@ -2302,6 +2312,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.setWindowFlags(
flags | QtCore.Qt.WindowType.WindowStaysOnTopHint | QtCore.Qt.WindowType.X11BypassWindowManagerHint
)
QtCore.QCoreApplication.processEvents()
self.setWindowFlags(flags)
self.show()

View File

@ -6,8 +6,8 @@ from enum import auto
from sys import platform
from typing import Any
from PyQt5 import QtGui, QtWidgets
from PyQt5.QtCore import QEvent, QModelIndex, QPoint, QRect, QSize, Qt, pyqtSignal
from PyQt6 import QtGui, QtWidgets
from PyQt6.QtCore import QEvent, QModelIndex, QPoint, QRect, QSize, Qt, pyqtSignal
from comicapi.utils import StrEnum
@ -30,41 +30,41 @@ class ModifyStyleItemDelegate(QtWidgets.QStyledItemDelegate):
# Draw background with the same color as other widgets
palette = self.combobox.palette()
background_color = palette.color(QtGui.QPalette.Window)
background_color = palette.color(QtGui.QPalette.ColorRole.Window)
painter.fillRect(options.rect, background_color)
style.drawPrimitive(QtWidgets.QStyle.PE_PanelItemViewItem, options, painter, self.combobox)
style.drawPrimitive(QtWidgets.QStyle.PrimitiveElement.PE_PanelItemViewItem, options, painter, self.combobox)
painter.save()
# Checkbox drawing logic
checked = index.data(Qt.CheckStateRole)
checked = index.data(Qt.ItemDataRole.CheckStateRole)
opts = QtWidgets.QStyleOptionButton()
opts.state |= QtWidgets.QStyle.State_Active
opts.state |= QtWidgets.QStyle.StateFlag.State_Active
opts.rect = self.getCheckBoxRect(options)
opts.state |= QtWidgets.QStyle.State_ReadOnly
opts.state |= QtWidgets.QStyle.StateFlag.State_ReadOnly
if checked:
opts.state |= QtWidgets.QStyle.State_On
opts.state |= QtWidgets.QStyle.StateFlag.State_On
style.drawPrimitive(
QtWidgets.QStyle.PrimitiveElement.PE_IndicatorMenuCheckMark, opts, painter, self.combobox
)
else:
opts.state |= QtWidgets.QStyle.State_Off
opts.state |= QtWidgets.QStyle.StateFlag.State_Off
if platform != "darwin":
style.drawControl(QtWidgets.QStyle.CE_CheckBox, opts, painter, self.combobox)
style.drawControl(QtWidgets.QStyle.ControlElement.CE_CheckBox, opts, painter, self.combobox)
label = index.data(Qt.DisplayRole)
label = index.data(Qt.ItemDataRole.DisplayRole)
rectangle = options.rect
rectangle.setX(opts.rect.width() + 10)
painter.drawText(rectangle, Qt.AlignVCenter, label)
# We need the restore here so that text is colored properly
painter.restore()
painter.drawText(rectangle, Qt.AlignmentFlag.AlignVCenter, label)
def getCheckBoxRect(self, option: QtWidgets.QStyleOptionViewItem) -> QRect:
# Get size of a standard checkbox.
opts = QtWidgets.QStyleOptionButton()
style = option.widget.style()
checkBoxRect = style.subElementRect(QtWidgets.QStyle.SE_CheckBoxIndicator, opts, None)
checkBoxRect = style.subElementRect(QtWidgets.QStyle.SubElement.SE_CheckBoxIndicator, opts, None)
y = option.rect.y()
h = option.rect.height()
checkBoxTopLeftCorner = QPoint(5, int(y + h / 2 - checkBoxRect.height() / 2))
@ -99,23 +99,23 @@ class CheckableComboBox(QtWidgets.QComboBox):
# https://stackoverflow.com/questions/65826378/how-do-i-use-qcombobox-setplaceholdertext/65830989#65830989
def paintEvent(self, event: QEvent) -> None:
painter = QtWidgets.QStylePainter(self)
painter.setPen(self.palette().color(QtGui.QPalette.Text))
painter.setPen(self.palette().color(QtGui.QPalette.ColorRole.Text))
# draw the combobox frame, focusrect and selected etc.
opt = QtWidgets.QStyleOptionComboBox()
self.initStyleOption(opt)
painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, opt)
painter.drawComplexControl(QtWidgets.QStyle.ComplexControl.CC_ComboBox, opt)
if self.currentIndex() < 0:
opt.palette.setBrush(
QtGui.QPalette.ButtonText,
opt.palette.brush(QtGui.QPalette.ButtonText).color(),
QtGui.QPalette.ColorRole.ButtonText,
opt.palette.brush(QtGui.QPalette.ColorRole.ButtonText).color(),
)
if self.placeholderText():
opt.currentText = self.placeholderText()
# draw the icon and text
painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, opt)
painter.drawControl(QtWidgets.QStyle.ControlElement.CE_ComboBoxLabel, opt)
def resizeEvent(self, event: Any) -> None:
# Recompute text to elide as needed
@ -126,16 +126,16 @@ class CheckableComboBox(QtWidgets.QComboBox):
# Allow events before the combobox list is shown
if obj == self.view().viewport():
# We record that the combobox list has been shown
if event.type() == QEvent.Show:
if event.type() == QEvent.Type.Show:
self.justShown = True
# We record that the combobox list has hidden,
# this will happen if the user does not make a selection
# but clicks outside of the combobox list or presses escape
if event.type() == QEvent.Hide:
if event.type() == QEvent.Type.Hide:
self._updateText()
self.justShown = False
# QEvent.MouseButtonPress is inconsistent on activation because double clicks are a thing
if event.type() == QEvent.MouseButtonRelease:
# QEvent.Type.MouseButtonPress is inconsistent on activation because double clicks are a thing
if event.type() == QEvent.Type.MouseButtonRelease:
# If self.justShown is true it means that they clicked on the combobox to change the checked items
# This is standard behavior (on macos) but I think it is surprising when it has a multiple select
if self.justShown:
@ -153,7 +153,7 @@ class CheckableComboBox(QtWidgets.QComboBox):
res = []
for i in range(self.count()):
item = self.model().item(i)
if item.checkState() == Qt.Checked:
if item.checkState() == Qt.CheckState.Checked:
res.append(self.itemData(i))
return res
@ -168,7 +168,7 @@ class CheckableComboBox(QtWidgets.QComboBox):
texts = []
for i in range(self.count()):
item = self.model().item(i)
if item.checkState() == Qt.Checked:
if item.checkState() == Qt.CheckState.Checked:
texts.append(item.text())
text = ", ".join(texts)
@ -180,22 +180,24 @@ class CheckableComboBox(QtWidgets.QComboBox):
so.initFrom(self)
# Ask the style for the size of the text field
rect = self.style().subControlRect(QtWidgets.QStyle.CC_ComboBox, so, QtWidgets.QStyle.SC_ComboBoxEditField)
rect = self.style().subControlRect(
QtWidgets.QStyle.ComplexControl.CC_ComboBox, so, QtWidgets.QStyle.SubControl.SC_ComboBoxEditField
)
# Compute the elided text
elidedText = self.fontMetrics().elidedText(text, Qt.ElideRight, rect.width())
elidedText = self.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, rect.width())
# This CheckableComboBox does not use the index, so we clear it and set the placeholder text
self.setCurrentIndex(-1)
self.setPlaceholderText(elidedText)
def setItemChecked(self, index: Any, state: bool) -> None:
qt_state = Qt.Checked if state else Qt.Unchecked
qt_state = Qt.CheckState.Checked if state else Qt.CheckState.Unchecked
item = self.model().item(index)
current = self.currentData()
# If we have at least one item checked emit itemChecked with the current check state and update text
# Require at least one item to be checked and provide a tooltip
if len(current) == 1 and not state and item.checkState() == Qt.Checked:
if len(current) == 1 and not state and item.checkState() == Qt.CheckState.Checked:
QtWidgets.QToolTip.showText(QtGui.QCursor.pos(), self.toolTip(), self, QRect(), 3000)
return
@ -205,7 +207,7 @@ class CheckableComboBox(QtWidgets.QComboBox):
self._updateText()
def toggleItem(self, index: int) -> None:
if self.model().item(index).checkState() == Qt.Checked:
if self.model().item(index).checkState() == Qt.CheckState.Checked:
self.setItemChecked(index, False)
else:
self.setItemChecked(index, True)
@ -240,44 +242,44 @@ class ReadStyleItemDelegate(QtWidgets.QStyledItemDelegate):
# Draw background with the same color as other widgets
palette = self.combobox.palette()
background_color = palette.color(QtGui.QPalette.Window)
background_color = palette.color(QtGui.QPalette.ColorRole.Window)
painter.fillRect(options.rect, background_color)
style.drawPrimitive(QtWidgets.QStyle.PE_PanelItemViewItem, options, painter, self.combobox)
style.drawPrimitive(QtWidgets.QStyle.PrimitiveElement.PE_PanelItemViewItem, options, painter, self.combobox)
painter.save()
# Checkbox drawing logic
checked = index.data(Qt.CheckStateRole)
checked = index.data(Qt.ItemDataRole.CheckStateRole)
opts = QtWidgets.QStyleOptionButton()
opts.state |= QtWidgets.QStyle.State_Active
opts.state |= QtWidgets.QStyle.StateFlag.State_Active
opts.rect = self.getCheckBoxRect(options)
opts.state |= QtWidgets.QStyle.State_ReadOnly
opts.state |= QtWidgets.QStyle.StateFlag.State_ReadOnly
if checked:
opts.state |= QtWidgets.QStyle.State_On
opts.state |= QtWidgets.QStyle.StateFlag.State_On
style.drawPrimitive(
QtWidgets.QStyle.PrimitiveElement.PE_IndicatorMenuCheckMark, opts, painter, self.combobox
)
else:
opts.state |= QtWidgets.QStyle.State_Off
opts.state |= QtWidgets.QStyle.StateFlag.State_Off
if platform != "darwin":
style.drawControl(QtWidgets.QStyle.CE_CheckBox, opts, painter, self.combobox)
style.drawControl(QtWidgets.QStyle.ControlElement.CE_CheckBox, opts, painter, self.combobox)
label = index.data(Qt.DisplayRole)
label = index.data(Qt.ItemDataRole.DisplayRole)
rectangle = options.rect
rectangle.setX(opts.rect.width() + 10)
painter.drawText(rectangle, Qt.AlignVCenter, label)
# We need the restore here so that text is colored properly
painter.restore()
painter.drawText(rectangle, Qt.AlignmentFlag.AlignVCenter, label)
# Draw buttons
if checked and (options.state & QtWidgets.QStyle.State_Selected):
if checked and (options.state & QtWidgets.QStyle.StateFlag.State_Selected):
up_rect = self._button_up_rect(options.rect)
down_rect = self._button_down_rect(options.rect)
painter.drawImage(up_rect, self.up_icon)
painter.drawImage(down_rect, self.down_icon)
painter.restore()
def _button_up_rect(self, rect: QRect) -> QRect:
return QRect(
self.combobox.view().width() - (self.button_width * 2) - (self.button_padding * 2),
@ -298,7 +300,7 @@ class ReadStyleItemDelegate(QtWidgets.QStyledItemDelegate):
# Get size of a standard checkbox.
opts = QtWidgets.QStyleOptionButton()
style = option.widget.style()
checkBoxRect = style.subElementRect(QtWidgets.QStyle.SE_CheckBoxIndicator, opts, None)
checkBoxRect = style.subElementRect(QtWidgets.QStyle.SubElement.SE_CheckBoxIndicator, opts, None)
y = option.rect.y()
h = option.rect.height()
checkBoxTopLeftCorner = QPoint(5, int(y + h / 2 - checkBoxRect.height() / 2))
@ -307,7 +309,7 @@ class ReadStyleItemDelegate(QtWidgets.QStyledItemDelegate):
def itemClicked(self, index: QModelIndex, pos: QPoint) -> None:
item_rect = self.combobox.view().visualRect(index)
checked = index.data(Qt.CheckStateRole)
checked = index.data(Qt.ItemDataRole.CheckStateRole)
button_up_rect = self._button_up_rect(item_rect)
button_down_rect = self._button_down_rect(item_rect)
@ -336,11 +338,11 @@ class ReadStyleItemDelegate(QtWidgets.QStyledItemDelegate):
item_rect = view.visualRect(index)
button_up_rect = self._button_up_rect(item_rect)
button_down_rect = self._button_down_rect(item_rect)
checked = index.data(Qt.CheckStateRole)
checked = index.data(Qt.ItemDataRole.CheckStateRole)
if checked == Qt.Checked and button_up_rect.contains(event.pos()):
if checked == Qt.CheckState.Checked and button_up_rect.contains(event.pos()):
QtWidgets.QToolTip.showText(event.globalPos(), self.up_help, self.combobox, QRect(), 3000)
elif checked == Qt.Checked and button_down_rect.contains(event.pos()):
elif checked == Qt.CheckState.Checked and button_down_rect.contains(event.pos()):
QtWidgets.QToolTip.showText(event.globalPos(), self.down_help, self.combobox, QRect(), 3000)
else:
QtWidgets.QToolTip.showText(event.globalPos(), self.item_help, self.combobox, QRect(), 3000)
@ -380,23 +382,23 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
# https://stackoverflow.com/questions/65826378/how-do-i-use-qcombobox-setplaceholdertext/65830989#65830989
def paintEvent(self, event: QEvent) -> None:
painter = QtWidgets.QStylePainter(self)
painter.setPen(self.palette().color(QtGui.QPalette.Text))
painter.setPen(self.palette().color(QtGui.QPalette.ColorRole.Text))
# draw the combobox frame, focusrect and selected etc.
opt = QtWidgets.QStyleOptionComboBox()
self.initStyleOption(opt)
painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, opt)
painter.drawComplexControl(QtWidgets.QStyle.ComplexControl.CC_ComboBox, opt)
if self.currentIndex() < 0:
opt.palette.setBrush(
QtGui.QPalette.ButtonText,
opt.palette.brush(QtGui.QPalette.ButtonText).color(),
QtGui.QPalette.ColorRole.ButtonText,
opt.palette.brush(QtGui.QPalette.ColorRole.ButtonText).color(),
)
if self.placeholderText():
opt.currentText = self.placeholderText()
# draw the icon and text
painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, opt)
painter.drawControl(QtWidgets.QStyle.ControlElement.CE_ComboBoxLabel, opt)
def buttonClicked(self, index: QModelIndex, button: ClickedButtonEnum) -> None:
if button == ClickedButtonEnum.up:
@ -415,18 +417,18 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
# Allow events before the combobox list is shown
if obj == self.view().viewport():
# We record that the combobox list has been shown
if event.type() == QEvent.Show:
if event.type() == QEvent.Type.Show:
self.justShown = True
# We record that the combobox list has hidden,
# this will happen if the user does not make a selection
# but clicks outside of the combobox list or presses escape
if event.type() == QEvent.Hide:
if event.type() == QEvent.Type.Hide:
self._updateText()
self.justShown = False
# Reverse as the display order is in "priority" order for the user whereas overlay requires reversed
self.dropdownClosed.emit(self.currentData())
# QEvent.MouseButtonPress is inconsistent on activation because double clicks are a thing
if event.type() == QEvent.MouseButtonRelease:
# QEvent.Type.MouseButtonPress is inconsistent on activation because double clicks are a thing
if event.type() == QEvent.Type.MouseButtonRelease:
# If self.justShown is true it means that they clicked on the combobox to change the checked items
# This is standard behavior (on macos) but I think it is surprising when it has a multiple select
if self.justShown:
@ -446,7 +448,7 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
res = []
for i in range(self.count()):
item = self.model().item(i)
if item.checkState() == Qt.Checked:
if item.checkState() == Qt.CheckState.Checked:
res.append(self.itemData(i))
return res
@ -458,7 +460,7 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
self.model().item(0).setCheckState(Qt.CheckState.Checked)
# Add room for "move" arrows
text_width = self.fontMetrics().width(text)
text_width = self.fontMetrics().boundingRect(text).width()
checkbox_width = 40
total_width = text_width + checkbox_width + (self.itemDelegate().button_width * 2)
if total_width > self.view().minimumWidth():
@ -477,19 +479,19 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
return
# Grab values for the rows to swap
cur_data = self.model().item(index).data(Qt.UserRole)
cur_title = self.model().item(index).data(Qt.DisplayRole)
cur_state = self.model().item(index).data(Qt.CheckStateRole)
cur_data = self.model().item(index).data(Qt.ItemDataRole.UserRole)
cur_title = self.model().item(index).data(Qt.ItemDataRole.DisplayRole)
cur_state = self.model().item(index).checkState()
swap_data = self.model().item(row).data(Qt.UserRole)
swap_title = self.model().item(row).data(Qt.DisplayRole)
swap_data = self.model().item(row).data(Qt.ItemDataRole.UserRole)
swap_title = self.model().item(row).data(Qt.ItemDataRole.DisplayRole)
swap_state = self.model().item(row).checkState()
self.model().item(row).setData(cur_data, Qt.UserRole)
self.model().item(row).setData(cur_data, Qt.ItemDataRole.UserRole)
self.model().item(row).setCheckState(cur_state)
self.model().item(row).setText(cur_title)
self.model().item(index).setData(swap_data, Qt.UserRole)
self.model().item(index).setData(swap_data, Qt.ItemDataRole.UserRole)
self.model().item(index).setCheckState(swap_state)
self.model().item(index).setText(swap_title)
@ -497,7 +499,7 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
texts = []
for i in range(self.count()):
item = self.model().item(i)
if item.checkState() == Qt.Checked:
if item.checkState() == Qt.CheckState.Checked:
texts.append(item.text())
text = ", ".join(texts)
@ -509,22 +511,24 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
so.initFrom(self)
# Ask the style for the size of the text field
rect = self.style().subControlRect(QtWidgets.QStyle.CC_ComboBox, so, QtWidgets.QStyle.SC_ComboBoxEditField)
rect = self.style().subControlRect(
QtWidgets.QStyle.ComplexControl.CC_ComboBox, so, QtWidgets.QStyle.SubControl.SC_ComboBoxEditField
)
# Compute the elided text
elidedText = self.fontMetrics().elidedText(text, Qt.ElideRight, rect.width())
elidedText = self.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, rect.width())
# This CheckableComboBox does not use the index, so we clear it and set the placeholder text
self.setCurrentIndex(-1)
self.setPlaceholderText(elidedText)
def setItemChecked(self, index: Any, state: bool) -> None:
qt_state = Qt.Checked if state else Qt.Unchecked
qt_state = Qt.CheckState.Checked if state else Qt.CheckState.Unchecked
item = self.model().item(index)
current = self.currentData()
# If we have at least one item checked emit itemChecked with the current check state and update text
# Require at least one item to be checked and provide a tooltip
if len(current) == 1 and not state and item.checkState() == Qt.Checked:
if len(current) == 1 and not state and item.checkState() == Qt.CheckState.Checked:
QtWidgets.QToolTip.showText(QtGui.QCursor.pos(), self.toolTip(), self, QRect(), 3000)
return
@ -533,7 +537,7 @@ class CheckableOrderComboBox(QtWidgets.QComboBox):
self._updateText()
def toggleItem(self, index: int) -> None:
if self.model().item(index).checkState() == Qt.Checked:
if self.model().item(index).checkState() == Qt.CheckState.Checked:
self.setItemChecked(index, False)
else:
self.setItemChecked(index, True)

View File

@ -8,15 +8,14 @@ import traceback
import webbrowser
from collections.abc import Collection, Sequence
from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QPalette
from PyQt5.QtWidgets import QWidget
from PyQt6.QtCore import QUrl
from PyQt6.QtWidgets import QWidget
logger = logging.getLogger(__name__)
try:
from PyQt5 import QtGui, QtWidgets
from PyQt5.QtCore import Qt
from PyQt6 import QtGui, QtWidgets
from PyQt6.QtCore import Qt
qt_available = True
except ImportError:
@ -29,9 +28,10 @@ if qt_available:
pil_available = True
except ImportError:
pil_available = False
active_palette: QPalette | None = None
try:
from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
from PyQt6.QtWebEngineCore import QWebEnginePage
from PyQt6.QtWebEngineWidgets import QWebEngineView
class WebPage(QWebEnginePage):
def acceptNavigationRequest(
@ -54,6 +54,7 @@ if qt_available:
webengine.setPage(WebPage(parent))
webengine.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
settings = webengine.settings()
assert settings is not None
settings.setAttribute(settings.WebAttribute.AutoLoadImages, True)
settings.setAttribute(settings.WebAttribute.JavascriptEnabled, False)
settings.setAttribute(settings.WebAttribute.JavascriptCanOpenWindows, False)
@ -125,12 +126,6 @@ if qt_available:
def get_qimage_from_data(image_data: bytes) -> QtGui.QImage:
img = QtGui.QImage()
if len(image_data) == 0:
logger.warning("Empty image data.")
img.load(":/graphics/nocover.png")
return img
success = img.loadFromData(image_data)
if not success:
try:
@ -140,7 +135,7 @@ if qt_available:
Image.open(io.BytesIO(image_data)).save(buffer, format="ppm")
success = img.loadFromData(buffer.getvalue())
except Exception:
logger.exception("Failed to load the image.")
logger.exception("Failed to load the image")
# if still nothing, go with default image
if not success:
img.load(":/graphics/nocover.png")
@ -153,6 +148,8 @@ if qt_available:
QtWidgets.QMessageBox.critical(QtWidgets.QMainWindow(), "Error", msg + trace)
active_palette = None
def enable_widget(widget: QtWidgets.QWidget | Collection[QtWidgets.QWidget], enable: bool) -> None:
if isinstance(widget, Sequence):
for w in widget:
@ -161,7 +158,8 @@ if qt_available:
_enable_widget(widget, enable)
def _enable_widget(widget: QtWidgets.QWidget, enable: bool) -> None:
if widget is None or active_palette is None:
global active_palette
if not (widget is not None and active_palette is not None):
return
active_color = active_palette.color(QtGui.QPalette.ColorRole.Base)
@ -194,7 +192,7 @@ if qt_available:
if isinstance(widget, (QtWidgets.QTextEdit, QtWidgets.QLineEdit, QtWidgets.QAbstractSpinBox)):
widget.setReadOnly(False)
elif isinstance(widget, QtWidgets.QListWidget):
widget.setMovement(QtWidgets.QListWidget.Free)
widget.setMovement(QtWidgets.QListWidget.Movement.Free)
else:
if isinstance(widget, QtWidgets.QTableWidgetItem):
widget.setBackground(inactive_brush)
@ -211,7 +209,7 @@ if qt_available:
elif isinstance(widget, QtWidgets.QListWidget):
inactive_palette = palettes()
widget.setPalette(inactive_palette[0])
widget.setMovement(QtWidgets.QListWidget.Static)
widget.setMovement(QtWidgets.QListWidget.Movement.Static)
def replaceWidget(
layout: QtWidgets.QLayout | QtWidgets.QSplitter, old_widget: QtWidgets.QWidget, new_widget: QtWidgets.QWidget

View File

@ -6,7 +6,7 @@ from pathlib import Path
from typing import Any, NamedTuple, cast
import settngs
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt6 import QtCore, QtGui, QtWidgets
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.ctsettings import ct_ns, group_for_plugin
@ -39,11 +39,13 @@ class PasswordEdit(QtWidgets.QLineEdit):
self.visibleIcon = QtGui.QIcon(":/graphics/eye.svg")
self.hiddenIcon = QtGui.QIcon(":/graphics/hidden.svg")
self.setEchoMode(QtWidgets.QLineEdit.Password)
self.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password)
if show_visibility:
# Add the password hide/shown toggle at the end of the edit box.
self.togglepasswordAction = self.addAction(self.visibleIcon, QtWidgets.QLineEdit.TrailingPosition)
self.togglepasswordAction = self.addAction(
self.visibleIcon, QtWidgets.QLineEdit.ActionPosition.TrailingPosition
)
self.togglepasswordAction.setToolTip("Show password")
self.togglepasswordAction.triggered.connect(self.on_toggle_password_action)
@ -51,12 +53,12 @@ class PasswordEdit(QtWidgets.QLineEdit):
def on_toggle_password_action(self) -> None:
if not self.password_shown:
self.setEchoMode(QtWidgets.QLineEdit.Normal)
self.setEchoMode(QtWidgets.QLineEdit.EchoMode.Normal)
self.password_shown = True
self.togglepasswordAction.setIcon(self.hiddenIcon)
self.togglepasswordAction.setToolTip("Hide password")
else:
self.setEchoMode(QtWidgets.QLineEdit.Password)
self.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password)
self.password_shown = False
self.togglepasswordAction.setIcon(self.visibleIcon)
self.togglepasswordAction.setToolTip("Show password")
@ -125,7 +127,7 @@ def generate_spinbox(option: settngs.Setting, layout: QtWidgets.QGridLayout) ->
widget = QtWidgets.QSpinBox()
widget.setRange(0, 9999)
widget.setToolTip(option.help)
layout.addWidget(widget, row, 1, alignment=QtCore.Qt.AlignLeft)
layout.addWidget(widget, row, 1, alignment=QtCore.Qt.AlignmentFlag.AlignLeft)
return widget
@ -138,7 +140,7 @@ def generate_doublespinbox(option: settngs.Setting, layout: QtWidgets.QGridLayou
widget = QtWidgets.QDoubleSpinBox()
widget.setRange(0, 9999.99)
widget.setToolTip(option.help)
layout.addWidget(widget, row, 1, alignment=QtCore.Qt.AlignLeft)
layout.addWidget(widget, row, 1, alignment=QtCore.Qt.AlignmentFlag.AlignLeft)
return widget
@ -223,8 +225,8 @@ def generate_talker_info(talker: ComicTalker, config: settngs.Config[ct_ns], lay
# Add horizontal divider
line = QtWidgets.QFrame()
line.setFrameShape(QtWidgets.QFrame.HLine)
line.setFrameShadow(QtWidgets.QFrame.Sunken)
line.setFrameShape(QtWidgets.QFrame.Shape.HLine)
line.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken)
layout.addWidget(line, row + 3, 0, 1, -1)
@ -352,15 +354,15 @@ def generate_source_option_tabs(
talker_layout = QtWidgets.QGridLayout()
lbl_select_talker = QtWidgets.QLabel("Metadata Source:")
line = QtWidgets.QFrame()
line.setFrameShape(QtWidgets.QFrame.HLine)
line.setFrameShadow(QtWidgets.QFrame.Sunken)
line.setFrameShape(QtWidgets.QFrame.Shape.HLine)
line.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken)
talker_tabs = QtWidgets.QTabWidget()
# Store all widgets as to allow easier access to their values vs. using findChildren etc. on the tab widget
sources: Sources = Sources(QtWidgets.QComboBox(), [])
talker_layout.addWidget(lbl_select_talker, 0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Maximum)
talker_layout.addWidget(sources[0], 0, 1, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Maximum)
talker_layout.addWidget(lbl_select_talker, 0, 0)
talker_layout.addWidget(sources[0], 0, 1)
talker_layout.addWidget(line, 1, 0, 1, -1)
talker_layout.addWidget(talker_tabs, 2, 0, 1, -1)
@ -440,7 +442,9 @@ def generate_source_option_tabs(
generate_api_widgets(talker, tab, key_option, url_option, layout_grid, definitions=config.definitions)
# Add vertical spacer
vspacer = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
vspacer = QtWidgets.QSpacerItem(
20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding
)
layout_grid.addItem(vspacer, layout_grid.rowCount() + 1, 0)
# Display the new widgets
tab.tab.setLayout(layout_grid)

View File

@ -16,13 +16,11 @@
# limitations under the License.
from __future__ import annotations
import contextlib
import datetime
import logging
import os
import pathlib
import sqlite3
import threading
from typing import Any, Generic, TypeVar
from typing_extensions import NamedTuple
@ -55,8 +53,6 @@ class ComicCacher:
self.db_file = cache_folder / "comic_cache.db"
self.version_file = cache_folder / "cache_version.txt"
self.version = version
self.local: threading.Thread | None = None
self.db: sqlite3.Connection | None = None
# verify that cache is from same version as this one
data = ""
@ -69,13 +65,10 @@ class ComicCacher:
if data != version:
self.clear_cache()
self.create_cache_db()
if not os.path.exists(self.db_file):
self.create_cache_db()
def clear_cache(self) -> None:
try:
self.close()
except Exception:
pass
try:
os.unlink(self.db_file)
except Exception:
@ -85,40 +78,32 @@ class ComicCacher:
except Exception:
pass
def connect(self) -> sqlite3.Connection:
if self.local != threading.current_thread():
self.db = None
if self.db is None:
self.local = threading.current_thread()
self.db = sqlite3.connect(self.db_file)
self.db.row_factory = sqlite3.Row
self.db.text_factory = str
return self.db
def close(self) -> None:
if self.db is not None:
self.db.close()
self.db = None
def create_cache_db(self) -> None:
# create the version file
with open(self.version_file, "w", encoding="utf-8") as f:
f.write(self.version)
# this will wipe out any existing version
open(self.db_file, "wb").close()
con = sqlite3.connect(self.db_file)
con.row_factory = sqlite3.Row
# create tables
with self.connect() as con, contextlib.closing(con.cursor()) as cur:
with con:
cur = con.cursor()
cur.execute(
"""CREATE TABLE IF NOT EXISTS SeriesSearchCache(
"""CREATE TABLE SeriesSearchCache(
timestamp DATE DEFAULT (datetime('now','localtime')),
id TEXT NOT NULL,
source TEXT NOT NULL,
search_term TEXT,
PRIMARY KEY (id, source, search_term))"""
)
cur.execute("CREATE TABLE IF NOT EXISTS Source(id TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY (id))")
cur.execute("CREATE TABLE Source(id TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY (id))")
cur.execute(
"""CREATE TABLE IF NOT EXISTS Series(
"""CREATE TABLE Series(
timestamp DATE DEFAULT (datetime('now','localtime')),
id TEXT NOT NULL,
source TEXT NOT NULL,
@ -128,7 +113,7 @@ class ComicCacher:
)
cur.execute(
"""CREATE TABLE IF NOT EXISTS Issues(
"""CREATE TABLE Issues(
timestamp DATE DEFAULT (datetime('now','localtime')),
id TEXT NOT NULL,
source TEXT NOT NULL,
@ -144,7 +129,10 @@ class ComicCacher:
cur.execute("DELETE FROM Series WHERE timestamp < ?", [str(a_week_ago)])
def add_search_results(self, source: str, search_term: str, series_list: list[Series], complete: bool) -> None:
with self.connect() as con, contextlib.closing(con.cursor()) as cur:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
con.text_factory = str
cur = con.cursor()
# remove all previous entries with this search term
cur.execute(
@ -167,7 +155,9 @@ class ComicCacher:
self.upsert(cur, "series", data)
def add_series_info(self, source: str, series: Series, complete: bool) -> None:
with self.connect() as con, contextlib.closing(con.cursor()) as cur:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
data = {
"id": series.id,
@ -178,7 +168,9 @@ class ComicCacher:
self.upsert(cur, "series", data)
def add_issues_info(self, source: str, issues: list[Issue], complete: bool) -> None:
with self.connect() as con, contextlib.closing(con.cursor()) as cur:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
for issue in issues:
data = {
@ -192,7 +184,10 @@ class ComicCacher:
def get_search_results(self, source: str, search_term: str, expire_stale: bool = True) -> list[CacheResult[Series]]:
results = []
with self.connect() as con, contextlib.closing(con.cursor()) as cur:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
con.text_factory = str
cur = con.cursor()
if expire_stale:
self.expire_stale_records(cur, "SeriesSearchCache")
@ -215,7 +210,10 @@ class ComicCacher:
return results
def get_series_info(self, series_id: str, source: str, expire_stale: bool = True) -> CacheResult[Series] | None:
with self.connect() as con, contextlib.closing(con.cursor()) as cur:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
con.text_factory = str
if expire_stale:
self.expire_stale_records(cur, "Series")
@ -235,7 +233,10 @@ class ComicCacher:
def get_series_issues_info(
self, series_id: str, source: str, expire_stale: bool = True
) -> list[CacheResult[Issue]]:
with self.connect() as con, contextlib.closing(con.cursor()) as cur:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
con.text_factory = str
if expire_stale:
self.expire_stale_records(cur, "Issues")
@ -255,7 +256,10 @@ class ComicCacher:
return results
def get_issue_info(self, issue_id: str, source: str, expire_stale: bool = True) -> CacheResult[Issue] | None:
with self.connect() as con, contextlib.closing(con.cursor()) as cur:
with sqlite3.connect(self.db_file) as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
con.text_factory = str
if expire_stale:
self.expire_stale_records(cur, "Issues")
@ -305,17 +309,3 @@ class ComicCacher:
vals.append(True) # If the cache is complete and this isn't complete we don't update it
cur.execute(sql_ins, vals)
def adapt_datetime_iso(val: datetime.datetime) -> str:
"""Adapt datetime.datetime to timezone-naive ISO 8601 date."""
return val.isoformat()
def convert_datetime(val: bytes) -> datetime.datetime:
"""Convert ISO 8601 datetime to datetime.datetime object."""
return datetime.datetime.fromisoformat(val.decode())
sqlite3.register_adapter(datetime.datetime, adapt_datetime_iso)
sqlite3.register_converter("datetime", convert_datetime)

View File

@ -22,21 +22,20 @@ import json
import logging
import pathlib
import time
from functools import cache
from typing import Any, Callable, Generic, TypeVar, cast
from urllib.parse import parse_qsl, urlencode, urljoin
from urllib.parse import parse_qsl, urljoin
import settngs
from pyrate_limiter import Limiter, RequestRate
from typing_extensions import Required, TypedDict
from comicapi import utils
from comicapi.genericmetadata import ComicSeries, GenericMetadata, ImageHash, MetadataOrigin
from comicapi.genericmetadata import ComicSeries, GenericMetadata, MetadataOrigin
from comicapi.issuestring import IssueString
from comicapi.utils import LocationParseError, StrEnum, parse_url
from comicapi.utils import LocationParseError, parse_url
from comictalker import talker_utils
from comictalker.comiccacher import ComicCacher, Issue, Series
from comictalker.comictalker import ComicTalker, TalkerDataError, TalkerError, TalkerNetworkError
from comictalker.comictalker import ComicTalker, TalkerDataError, TalkerNetworkError
try:
import niquests as requests
@ -47,7 +46,7 @@ logger = logging.getLogger(__name__)
TWITTER_TOO_MANY_REQUESTS = 420
class CVTypeID(StrEnum):
class CVTypeID:
Volume = "4050" # CV uses volume to mean series
Issue = "4000"
@ -263,10 +262,6 @@ class ComicVineTalker(ComicTalker):
self._log_total_requests()
return "Failed to connect to the URL!", False
@cache
def cacher(self) -> ComicCacher:
return ComicCacher(self.cache_folder, self.version)
def search_for_series(
self,
series_name: str,
@ -286,7 +281,7 @@ class ComicVineTalker(ComicTalker):
# Before we search online, look in our cache, since we might have done this same search recently
# For literal searches always retrieve from online
cvc = self.cacher()
cvc = ComicCacher(self.cache_folder, self.version)
if not refresh_cache and not literal:
cached_search_results = cvc.get_search_results(self.id, series_name)
@ -394,7 +389,7 @@ class ComicVineTalker(ComicTalker):
) -> list[GenericMetadata]:
logger.debug("Fetching comics by series ids: %s and number: %s", series_id_list, issue_number)
# before we search online, look in our cache, since we might already have this info
cvc = self.cacher()
cvc = ComicCacher(self.cache_folder, self.version)
cached_results: list[GenericMetadata] = []
needed_volumes: set[int] = set()
for series_id in series_id_list:
@ -481,137 +476,136 @@ class ComicVineTalker(ComicTalker):
return formatted_filtered_issues_result
def _get_id_list(self, needed_issues: list[str]) -> tuple[str, set[str]]:
used_issues = set(needed_issues[: min(len(needed_issues), 100)])
flt = "id:" + "|".join(used_issues)
return flt, used_issues
def fetch_comics(self, *, issue_ids: list[str]) -> list[GenericMetadata]:
logger.debug("Fetching comic IDs: %s", issue_ids)
# before we search online, look in our cache, since we might already have this info
cvc = self.cacher()
cvc = ComicCacher(self.cache_folder, self.version)
cached_results: list[GenericMetadata] = []
needed_issues: set[str] = set(issue_ids)
cached_issues = [x for x in (cvc.get_issue_info(issue_id, self.id) for issue_id in issue_ids) if x is not None]
needed_issues -= {i.data.id for i in cached_issues}
needed_issues: list[int] = []
for issue_id in issue_ids:
cached_issue = cvc.get_issue_info(issue_id, self.id)
for cached_issue in cached_issues:
issue: CVIssue = json.loads(cached_issue.data.data)
series: CVSeries = issue["volume"]
cached_series = cvc.get_series_info(cached_issue.data.series_id, self.id, expire_stale=False)
if cached_series is not None and cached_series.complete:
series = json.loads(cached_series.data.data)
cached_results.append(
self._map_comic_issue_to_metadata(
issue,
self._format_series(series),
),
)
if cached_issue is not None:
cached_results.append(
self._map_comic_issue_to_metadata(
json.loads(cached_issue[0].data),
self._fetch_series([int(cached_issue[0].series_id)])[0][0],
),
)
else:
needed_issues.append(int(issue_id)) # CV uses integers for it's IDs
logger.debug("Found %d issues cached need %d issues", len(cached_results), len(needed_issues))
if not needed_issues:
return cached_results
issue_filter = ""
for iid in needed_issues:
issue_filter += str(iid) + "|"
flt = "id:" + issue_filter.rstrip("|")
issue_url = urljoin(self.api_url, "issues/")
params: dict[str, Any] = {
"api_key": self.api_key,
"format": "json",
"filter": flt,
}
cv_response: CVResult[list[CVIssue]] = self._get_cv_content(issue_url, params)
issue_results: list[CVIssue] = []
issue_results = cv_response["results"]
page = 1
offset = 0
current_result_count = cv_response["number_of_page_results"]
total_result_count = cv_response["number_of_total_results"]
# see if we need to keep asking for more pages...
while needed_issues:
flt, used_issues = self._get_id_list(list(needed_issues))
params["filter"] = flt
while current_result_count < total_result_count:
page += 1
offset += cv_response["number_of_page_results"]
cv_response: CVResult[list[CVIssue]] = self._get_cv_content(issue_url, params)
params["offset"] = offset
cv_response = self._get_cv_content(issue_url, params)
issue_results.extend(cv_response["results"])
current_result_count += cv_response["number_of_page_results"]
retrieved_issues = {str(x["id"]) for x in cv_response["results"]}
used_issues.difference_update(retrieved_issues)
if used_issues:
logger.debug("%s issue ids %r do not exist anymore", self.name, used_issues)
needed_issues = needed_issues.difference(retrieved_issues, used_issues)
cache_issue: list[Issue] = []
for issue in issue_results:
cache_issue.append(
Issue(
id=str(issue["id"]),
series_id=str(issue["volume"]["id"]),
data=json.dumps(issue).encode("utf-8"),
)
)
cvc.add_issues_info(
self.id,
cache_issue,
False, # The /issues/ endpoint never provides credits
)
cvc.add_series_info(
self.id,
Series(id=str(issue["volume"]["id"]), data=json.dumps(issue["volume"]).encode("utf-8")),
False,
)
series_info = {s[0].id: s[0] for s in self._fetch_series([int(i["volume"]["id"]) for i in issue_results])}
cache_issue: list[Issue] = []
for issue in issue_results:
series = issue["volume"]
cached_series = cvc.get_series_info(str(series["id"]), self.id, expire_stale=False)
if cached_series is not None and cached_series.complete:
series = json.loads(cached_series.data.data)
cached_results.append(
self._map_comic_issue_to_metadata(issue, self._format_series(series)),
cache_issue.append(
Issue(
id=str(issue["id"]),
series_id=str(issue["volume"]["id"]),
data=json.dumps(issue).encode("utf-8"),
)
)
cached_results.append(
self._map_comic_issue_to_metadata(issue, series_info[str(issue["volume"]["id"])]),
)
from pprint import pp
pp(cache_issue, indent=2)
cvc.add_issues_info(
self.id,
cache_issue,
False, # The /issues/ endpoint never provides credits
)
return cached_results
def _fetch_series(self, series_ids: list[int]) -> list[tuple[ComicSeries, bool]]:
# before we search online, look in our cache, since we might already have this info
cvc = self.cacher()
cvc = ComicCacher(self.cache_folder, self.version)
cached_results: list[tuple[ComicSeries, bool]] = []
needed_series: set[str] = set()
needed_series: list[int] = []
for series_id in series_ids:
cached_series = cvc.get_series_info(str(series_id), self.id)
if cached_series is not None and cached_series.complete:
if cached_series is not None:
cached_results.append((self._format_series(json.loads(cached_series[0].data)), cached_series[1]))
else:
needed_series.add(str(series_id))
needed_series.append(series_id)
if not needed_series:
if needed_series == []:
return cached_results
logger.debug("Found %d series cached need %d series", len(cached_results), len(needed_series))
series_filter = ""
for vid in needed_series:
series_filter += str(vid) + "|"
flt = "id:" + series_filter.rstrip("|") # CV uses volume to mean series
series_url = urljoin(self.api_url, "volumes/") # CV uses volume to mean series
params: dict[str, Any] = {
"api_key": self.api_key,
"format": "json",
"filter": flt,
}
series_results: list[CVSeries] = []
cv_response: CVResult[list[CVSeries]] = self._get_cv_content(series_url, params)
while needed_series:
flt, used_series = self._get_id_list(list(needed_series))
params["filter"] = flt
series_results = cv_response["results"]
page = 1
offset = 0
current_result_count = cv_response["number_of_page_results"]
total_result_count = cv_response["number_of_total_results"]
cv_response: CVResult[list[CVSeries]] = self._get_cv_content(series_url, params)
# see if we need to keep asking for more pages...
while current_result_count < total_result_count:
page += 1
offset += cv_response["number_of_page_results"]
params["offset"] = offset
cv_response = self._get_cv_content(series_url, params)
series_results.extend(cv_response["results"])
current_result_count += cv_response["number_of_page_results"]
retrieved_series = {str(x["id"]) for x in series_results}
used_series.difference_update(retrieved_series)
if used_series:
logger.debug("%s series ids %r do not exist anymore", self.name, used_series)
needed_series = needed_series.difference(retrieved_series, used_series)
if series_results:
for series in series_results:
cvc.add_series_info(
self.id,
Series(id=str(series["id"]), data=json.dumps(series).encode("utf-8")),
True,
)
if series_results:
for series in series_results:
cached_results.append((self._format_series(series), True))
return cached_results
@ -620,15 +614,19 @@ class ComicVineTalker(ComicTalker):
"""
Get the content from the CV server.
"""
ratelimit_key = url
if self.api_key == self.default_api_key:
ratelimit_key = "cv"
with self.limiter.ratelimit(ratelimit_key, delay=True):
cv_response: CVResult[T] = self._get_url_content(url, params)
if cv_response["status_code"] != 1:
logger.debug(
f"{self.name} query failed with error #{cv_response['status_code']}: [{cv_response['error']}]."
)
raise TalkerNetworkError(self.name, 0, f"{cv_response['status_code']}: {cv_response['error']}")
cv_response: CVResult[T] = self._get_url_content(url, params)
if cv_response["status_code"] != 1:
logger.debug(
f"{self.name} query failed with error #{cv_response['status_code']}: [{cv_response['error']}]."
)
raise TalkerNetworkError(self.name, 0, f"{cv_response['status_code']}: {cv_response['error']}")
return cv_response
return cv_response
def _get_url_content(self, url: str, params: dict[str, Any]) -> Any:
# if there is a 500 error, try a few more times before giving up
@ -638,65 +636,47 @@ class ComicVineTalker(ComicTalker):
for tries in range(1, 5):
try:
ratelimit_key = self._get_ratelimit_key(url)
with self.limiter.ratelimit(ratelimit_key, delay=True):
logger.debug("Requesting: %s?%s", url, urlencode(final_params))
self.total_requests_made[ratelimit_key] += 1
resp = requests.get(
url, params=final_params, headers={"user-agent": "comictagger/" + self.version}, timeout=60
)
self.total_requests_made[url.removeprefix(self.api_url)] += 1
resp = requests.get(
url, params=final_params, headers={"user-agent": "comictagger/" + self.version}, timeout=10
)
if resp.status_code == 200:
return resp.json()
elif resp.status_code in (
requests.codes.SERVER_ERROR,
requests.codes.BAD_GATEWAY,
requests.codes.UNAVAILABLE,
):
logger.debug("Try #%d: %d", tries, resp.status_code)
elif resp.status_code == 500:
logger.debug(f"Try #{tries}: ")
time.sleep(1)
logger.debug(str(resp.status_code))
elif resp.status_code in (requests.codes.TOO_MANY_REQUESTS, TWITTER_TOO_MANY_REQUESTS):
logger.info("%s rate limit encountered. Waiting for 10 seconds", self.name)
elif resp.status_code in (requests.status_codes.codes.TOO_MANY_REQUESTS, TWITTER_TOO_MANY_REQUESTS):
logger.info(f"{self.name} rate limit encountered. Waiting for 10 seconds\n")
self._log_total_requests()
time.sleep(10)
limit_counter += 1
if limit_counter > 3:
# Tried 3 times, inform user to check CV website.
logger.error("%s rate limit error. Exceeded 3 retires.", self.name)
logger.error(f"{self.name} rate limit error. Exceeded 3 retires.")
raise TalkerNetworkError(
self.name,
3,
"Rate Limit Error: Check your current API usage limit at https://comicvine.gamespot.com/api/",
)
else:
logger.error("Unknown status code: %d, %s", resp.status_code, resp.content)
break
except requests.exceptions.Timeout:
logger.debug(f"Connection to {self.name} timed out.")
if tries > 3:
raise TalkerNetworkError(self.name, 4)
raise TalkerNetworkError(self.name, 4)
except requests.exceptions.RequestException as e:
logger.debug(f"Request exception: {e}")
raise TalkerNetworkError(self.name, 0, str(e)) from e
except json.JSONDecodeError as e:
logger.debug(f"JSON decode error: {e}")
raise TalkerDataError(self.name, 2, "ComicVine did not provide json")
except TalkerError as e:
raise e
except Exception as e:
raise TalkerNetworkError(self.name, 5, str(e))
raise TalkerNetworkError(self.name, 5, "Unknown error occurred")
def _get_ratelimit_key(self, url: str) -> str:
if self.api_key == self.default_api_key:
return "cv"
ratelimit_key = url.removeprefix(self.api_url)
for x in CVTypeID:
ratelimit_key = ratelimit_key.partition(f"/{x}-")[0]
return ratelimit_key
def _format_search_results(self, search_results: list[CVSeries]) -> list[ComicSeries]:
formatted_results = []
for record in search_results:
@ -736,7 +716,7 @@ class ComicVineTalker(ComicTalker):
def _fetch_issues_in_series(self, series_id: str) -> list[tuple[GenericMetadata, bool]]:
logger.debug("Fetching all issues in series: %s", series_id)
# before we search online, look in our cache, since we might already have this info
cvc = self.cacher()
cvc = ComicCacher(self.cache_folder, self.version)
cached_results = cvc.get_series_issues_info(series_id, self.id)
series = self._fetch_series_data(int(series_id))[0]
@ -793,11 +773,11 @@ class ComicVineTalker(ComicTalker):
def _fetch_series_data(self, series_id: int) -> tuple[ComicSeries, bool]:
logger.debug("Fetching series info: %s", series_id)
# before we search online, look in our cache, since we might already have this info
cvc = self.cacher()
cvc = ComicCacher(self.cache_folder, self.version)
cached_series = cvc.get_series_info(str(series_id), self.id)
logger.debug("Series cached: %s", bool(cached_series))
if cached_series is not None and cached_series.complete:
if cached_series is not None:
return (self._format_series(json.loads(cached_series[0].data)), cached_series[1])
series_url = urljoin(self.api_url, f"volume/{CVTypeID.Volume}-{series_id}") # CV uses volume to mean series
@ -841,7 +821,7 @@ class ComicVineTalker(ComicTalker):
def _fetch_issue_data_by_issue_id(self, issue_id: str) -> GenericMetadata:
logger.debug("Fetching issue by issue ID: %s", issue_id)
# before we search online, look in our cache, since we might already have this info
cvc = self.cacher()
cvc = ComicCacher(self.cache_folder, self.version)
cached_issue = cvc.get_issue_info(issue_id, self.id)
logger.debug("Issue cached: %s", bool(cached_issue and cached_issue[1]))
@ -895,11 +875,13 @@ class ComicVineTalker(ComicTalker):
md.web_links = [parse_url(url)]
except LocationParseError:
...
if issue.get("image") is not None:
md._cover_image = ImageHash(URL=issue.get("image", {}).get("super_url", ""), Hash=0, Kind="")
if issue.get("image") is None:
md._cover_image = ""
else:
md._cover_image = issue.get("image", {}).get("super_url", "")
for alt in issue.get("associated_images", []):
md._alternate_images.append(ImageHash(URL=alt["original_url"], Hash=0, Kind=""))
md._alternate_images.append(alt["original_url"])
for character in issue.get("character_credits", set()):
md.characters.add(character["name"])

View File

@ -23,4 +23,4 @@ disable = "C0330, C0326, C0115, C0116, C0103"
max-line-length=120
[tool.pylint.master]
extension-pkg-whitelist="PyQt5"
extension-pkg-whitelist="PyQt6"

View File

@ -15,6 +15,7 @@ classifiers =
Environment :: Win32 (MS Windows)
Environment :: X11 Applications :: Qt
Intended Audience :: End Users/Desktop
License :: OSI Approved :: Apache Software License
Natural Language :: English
Operating System :: OS Independent
Programming Language :: Python :: 3
@ -45,8 +46,9 @@ install_requires =
pillow>=9.1.0
pyrate-limiter>=2.6,<3
pyyaml
rapidfuzz>=2.12.0
requests==2.*
settngs==0.11.0
settngs==0.10.4
text2digits
typing-extensions>=4.3.0
wordninja
@ -75,8 +77,8 @@ pyinstaller40 =
7z =
py7zr
all =
PyQt5
PyQtWebEngine
PyQt6
PyQt6-WebEngine
comicinfoxml==0.4.*
gcd-talker>0.1.0
metron-talker>0.1.5
@ -96,7 +98,7 @@ cix =
gcd =
gcd-talker>0.1.0
gui =
PyQt5
PyQt6
icu =
pyicu;sys_platform == 'linux' or sys_platform == 'darwin'
jxl =
@ -104,8 +106,8 @@ jxl =
metron =
metron-talker>0.1.5
pyinstaller =
PyQt5
PyQtWebEngine
PyQt6
PyQt6-WebEngine
comicinfoxml==0.4.*
pillow-avif-plugin>=1.4.1
pillow-jxl-plugin>=1.2.5
@ -113,8 +115,8 @@ pyinstaller =
rarfile>=4.0
pyicu;sys_platform == 'linux' or sys_platform == 'darwin'
qtw =
PyQt5
PyQtWebEngine
PyQt6
PyQt6-WebEngine
[options.package_data]
comicapi =
@ -255,7 +257,6 @@ deps =
extras =
pyinstaller
commands =
pyrcc5 comictaggerlib/graphics/graphics.qrc -o comictaggerlib/graphics/resources.py
pyinstaller -y build-tools/comictagger.spec
python -c 'import importlib,platform; importlib.import_module("icu") if platform.system() != "Windows" else ...' # Sanity check for icu
@ -273,11 +274,11 @@ depends =
deps =
requests
allowlist_externals =
{tox_root}/build/appimagetool-x86_64.AppImage
{tox_root}/build/appimagetool-aarch64.AppImage
change_dir = {tox_root}/dist/binary
commands_pre =
-python -c 'import shutil; shutil.rmtree("{tox_root}/build/", ignore_errors=True)'
python {tox_root}/build-tools/get_appimage.py {tox_root}/build/appimagetool-x86_64.AppImage
python {tox_root}/build-tools/get_appimage.py {tox_root}/build/appimagetool.AppImage
commands =
python -c 'import shutil,pathlib; shutil.copytree("{tox_root}/dist/comictagger/", "{tox_root}/build/appimage", dirs_exist_ok=True); \
shutil.copy("{tox_root}/comictaggerlib/graphics/app.png", "{tox_root}/build/appimage/app.png"); \
@ -285,7 +286,7 @@ commands =
pathlib.Path("{tox_root}/build/appimage/AppRun.desktop").write_text( \
pathlib.Path("{tox_root}/build-tools/ComicTagger.desktop").read_text() \
.replace("/usr/local/share/comictagger/app.png", "app"))'
{tox_root}/build/appimagetool-x86_64.AppImage {tox_root}/build/appimage
{tox_root}/build/appimagetool.AppImage {tox_root}/build/appimage
[testenv:zip_artifacts]
description = Zip release artifacts
@ -327,7 +328,6 @@ per-file-ignores =
[mypy]
exclude = comictaggerlib/graphics/resources.py
check_untyped_defs = true
local_partial_types = true
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_untyped_defs = true

View File

@ -289,78 +289,34 @@ metadata_prepared = (
),
)
issueidentifier_score = ( # type: ignore[var-annotated]
issueidentifier_score = (
(
(
None,
[],
False,
),
{
"remote_hash": 0,
"score": 100,
"url": "",
"local_hash": 0,
"local_hash_name": "0",
},
),
(
(
# Test invalid ImageHash Kind value
comicapi.genericmetadata.ImageHash(
Hash=0,
Kind="",
URL="",
),
[],
False,
),
{
"remote_hash": 0,
"score": 100,
"url": "",
"local_hash": 0,
"local_hash_name": "0",
},
),
(
(
# Test URL alternative
comicapi.genericmetadata.ImageHash(
Hash=0,
Hash=0, # Force using the alternate, since the alternate is a url it will be ignored
Kind="ahash",
URL="",
),
[
comicapi.genericmetadata.ImageHash(
URL="https://comicvine.gamespot.com/a/uploads/scale_large/0/574/585444-109004_20080707014047_large.jpg",
Hash=0,
Kind="",
)
],
["https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/"],
True,
),
{
"remote_hash": 212201432349720,
"score": 0,
"url": "https://comicvine.gamespot.com/a/uploads/scale_large/0/574/585444-109004_20080707014047_large.jpg",
"remote_hash": 0,
"score": 31,
"url": "",
"local_hash": 212201432349720,
"local_hash_name": "Cover 1",
},
),
(
(
# Test hash alternative
comicapi.genericmetadata.ImageHash(
Hash=0,
Kind="ahash",
URL="",
),
[
comicapi.genericmetadata.ImageHash(
Hash=212201432349720,
Kind="ahash",
URL="",
),
],
True,
@ -378,9 +334,8 @@ issueidentifier_score = ( # type: ignore[var-annotated]
comicapi.genericmetadata.ImageHash(
Hash=212201432349720,
Kind="ahash",
URL="",
),
[],
["https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/"],
False,
),
{
@ -393,12 +348,8 @@ issueidentifier_score = ( # type: ignore[var-annotated]
),
(
(
comicapi.genericmetadata.ImageHash(
Hash=0,
Kind="",
URL="https://comicvine.gamespot.com/a/uploads/scale_large/0/574/585444-109004_20080707014047_large.jpg",
),
[],
"https://comicvine.gamespot.com/a/uploads/scale_large/0/574/585444-109004_20080707014047_large.jpg",
["https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/"],
False,
),
{

View File

@ -181,9 +181,7 @@ comic_issue_result = comicapi.genericmetadata.GenericMetadata(
issue_id=str(cv_issue_result["results"]["id"]),
series=cv_issue_result["results"]["volume"]["name"],
series_id=str(cv_issue_result["results"]["volume"]["id"]),
_cover_image=comicapi.genericmetadata.ImageHash(
URL=cv_issue_result["results"]["image"]["super_url"], Hash=0, Kind=""
),
_cover_image=cv_issue_result["results"]["image"]["super_url"],
issue=cv_issue_result["results"]["issue_number"],
volume=None,
title=cv_issue_result["results"]["name"],
@ -242,9 +240,7 @@ cv_md = comicapi.genericmetadata.GenericMetadata(
rights=None,
identifier=None,
last_mark=None,
_cover_image=comicapi.genericmetadata.ImageHash(
URL=cv_issue_result["results"]["image"]["super_url"], Hash=0, Kind=""
),
_cover_image=cv_issue_result["results"]["image"]["super_url"],
)

View File

@ -1,6 +1,5 @@
from __future__ import annotations
import os
import pathlib
import platform
import shutil
@ -82,9 +81,8 @@ def test_page_type_write(tmp_comic):
def test_invalid_zip(tmp_comic: comicapi.comicarchive.ComicArchive):
with open(tmp_comic.path, mode="b+r") as f:
# Corrupting the first file only breaks the first file. If it is never read then no exception will be raised
f.seek(-10, os.SEEK_END) # seek to a probably bad place in th Central Directory and write some bytes
f.write(b"PK\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000")
# This only corrupts the first file. If it is never read then no exception will be caused
f.write(b"PK\000\000")
result = tmp_comic.write_tags(comicapi.genericmetadata.md_test, "cr") # This is not the first file
assert result

View File

@ -215,7 +215,7 @@ def config(tmp_path):
@pytest.fixture
def plugin_config(tmp_path, comicvine_api):
def plugin_config(tmp_path):
from comictaggerlib.main import App
ns = Namespace(config=comictaggerlib.ctsettings.ComicTaggerPaths(tmp_path / "config"))

View File

@ -1,37 +0,0 @@
from __future__ import annotations
from comicapi.comicarchive import ComicArchive
from comictaggerlib.imagehasher import ImageHasher
def test_ahash(cbz: ComicArchive):
md = cbz.read_tags("cr")
covers = md.get_cover_page_index_list()
assert covers
cover = cbz.get_page(covers[0])
assert cover
ih = ImageHasher(data=cover)
assert bin(212201432349720) == bin(ih.average_hash())
def test_dhash(cbz: ComicArchive):
md = cbz.read_tags("cr")
covers = md.get_cover_page_index_list()
assert covers
cover = cbz.get_page(covers[0])
assert cover
ih = ImageHasher(data=cover)
assert bin(11278294082955047009) == bin(ih.difference_hash())
def test_phash(cbz: ComicArchive):
md = cbz.read_tags("cr")
covers = md.get_cover_page_index_list()
assert covers
cover = cbz.get_page(covers[0])
assert cover
ih = ImageHasher(data=cover)
assert bin(15307782992485167995) == bin(ih.perception_hash())

View File

@ -13,6 +13,7 @@ from comictalker.comictalker import ComicTalker
def test_save(
plugin_config: tuple[settngs.Config[ctsettings.ct_ns], dict[str, ComicTalker]],
tmp_comic,
comicvine_api,
md_saved,
mock_now,
) -> None:
@ -69,6 +70,7 @@ def test_save(
def test_delete(
plugin_config: tuple[settngs.Config[ctsettings.ct_ns], dict[str, ComicTalker]],
tmp_comic,
comicvine_api,
md_saved,
mock_now,
) -> None:
@ -107,6 +109,7 @@ def test_delete(
def test_rename(
plugin_config: tuple[settngs.Config[ctsettings.ct_ns], dict[str, ComicTalker]],
tmp_comic,
comicvine_api,
md_saved,
mock_now,
) -> None:

View File

@ -42,7 +42,7 @@ def test_get_issue_cover_match_score(
cbz,
config,
comicvine_api,
data: tuple[ImageHash, list[ImageHash], bool],
data: tuple[str | ImageHash, list[str | ImageHash], bool],
expected: comictaggerlib.issueidentifier.Score,
):
config, definitions = config