Compare commits

..

61 Commits

Author SHA1 Message Date
Timmy Welch
c9de8370c2 Merge branch 'mizaki/gmd_lang_iso' into develop 2025-01-10 16:59:54 -08:00
Timmy Welch
8de35bdfa1 Fix default dict creating unnecessary keys 2025-01-10 16:25:10 -08:00
Mizaki
5f8a6b25c1 Fix -1 not being false for credit language combobox 2025-01-10 23:46:45 +00:00
Mizaki
01d7612a58 Pass credit language ISO using the widget.data to respect the metadata credit requiring an ISO string. If the string fails to match an ISO, use the raw text. 2025-01-10 00:19:35 +00:00
Timmy Welch
e8e21eb1b6 Fix tests not being excluded in wheel 2025-01-05 18:41:15 -08:00
Timmy Welch
8fbb40bb76 Fix language and countries getting modified 2024-12-16 19:13:56 -08:00
Timmy Welch
04075cc20e Fix credit handling in GUI 2024-12-16 19:12:25 -08:00
Timmy Welch
92ce2987ea Regenerate settngs 2024-12-07 15:30:44 -08:00
Timmy Welch
c282ebf845 Switch ubuntu runner to 22.04 and macos to 13 2024-12-07 14:41:22 -08:00
Timmy Welch
38932f0782 Add language to ComicTagger 2024-12-06 23:18:45 -08:00
Timmy Welch
bf0a46055a Fix parsing ' in filenames
Fixes #672
2024-12-06 23:18:45 -08:00
Timmy Welch
0fa329ca75 Add language to Credit in ComicAPI 2024-12-06 23:09:25 -08:00
Timmy Welch
577e99ae39 Print CLI tags when using the print command 2024-12-06 23:02:10 -08:00
Timmy Welch
5df9359151 Merge branch 'mizaki/write_md_merge' into develop 2024-10-19 14:00:46 -07:00
Timmy Welch
119a0881e0 Merge branch 'mizaki/fix_readtags' into develop 2024-10-19 13:59:59 -07:00
Timmy Welch
f4f732b742 Fix accidental re-ordering of pages when pages.image_index is disabled on a metadata type 2024-10-19 13:58:19 -07:00
Timmy Welch
a8f269aefa Fix export to CBZ 2024-10-19 10:37:27 -07:00
Mizaki
6930f0cb74 Fix switching unclean read tags 2024-10-17 21:44:09 +01:00
Mizaki
170476a705 Preserve hidden metadata values when reading from GUI form 2024-10-15 13:06:19 +01:00
Timmy Welch
7448e9828b Sort pages in archive order before writing CR metadata 2024-10-14 16:54:13 -07:00
Timmy Welch
6d20fe348f Update pre-commit 2024-10-11 21:07:17 -07:00
Timmy Welch
5b02358bf1 Fix all inputs being disabled when an invalid tag is loaded from settings 2024-10-11 21:04:48 -07:00
Timmy Welch
78df903de7 pre-commit 2024-09-27 15:08:00 -07:00
Timmy Welch
4cd70670cc Allow custom paramaters in comicvine url 2024-09-27 15:06:26 -07:00
Timmy Welch
dcb532d7c9 Add Image Comics to publishers.json 2024-09-27 14:45:33 -07:00
Timmy Welch
5820c36ea5 Fix CV error handling 2024-09-27 14:39:25 -07:00
Timmy Welch
c0db1e52ae Make cleanup_html produce text that is more compliant with markdown 2024-09-22 16:26:15 -07:00
Timmy Welch
e46656323c Fix clearing invalid tags 2024-09-22 16:24:28 -07:00
Timmy Welch
e96de650bf Fix label names for standard location links 2024-09-21 17:06:17 -07:00
Timmy Welch
b421a0edaa Add links to standard locations 2024-09-21 15:57:09 -07:00
Timmy Welch
a9fdafdb93 Format message better 2024-09-21 15:39:37 -07:00
Timmy Welch
a4a6d54d7e Merge branch 'cv-cache' into develop 2024-09-20 15:02:20 -07:00
Timmy Welch
9358431146 Add a notice about Metron/GCD changes on PyInstaller builds 2024-09-20 14:45:06 -07:00
Timmy Welch
a60eda1602 Typo 2024-09-20 13:57:14 -07:00
Timmy Welch
c796ad7c7a Enable debug logging for pyrate-limiter 2024-09-20 13:52:25 -07:00
Timmy Welch
63718882a5 Update pyinstaller package to not include metron or gcd by default
This makes it so that users using pyinstaller can update metron and gcd without waiting for a new ComicTagger release
2024-09-19 19:23:41 -07:00
Timmy Welch
89dfec2363 ComicVine improvements
Add more logging
Add a 10 second timeout to all requests
Log unhandled exceptions
2024-09-19 19:13:03 -07:00
Timmy Welch
39a4a37d7c Add tests 2024-09-19 19:03:30 -07:00
Timmy Welch
25e5134577 Cache more ComicVine lookups 2024-09-19 17:31:06 -07:00
Timmy Welch
a7f1d566ab Merge branch 'plugin-isolation' into develop 2024-09-19 16:26:35 -07:00
Timmy Welch
234d9e49fe Fix test 2024-09-17 15:32:01 -07:00
Timmy Welch
6ea9230382 Allow .whl files 2024-09-17 14:27:01 -07:00
Timmy Welch
1803a37591 Handle None values when doing conversions and catch indexing errors 2024-09-17 09:20:11 -07:00
Timmy Welch
c50de9bed7 Fix plugin folder 2024-09-16 16:52:51 -07:00
Timmy Welch
6a97ace933 Only support zip local plugins 2024-09-16 16:46:42 -07:00
Timmy Welch
f56d58bf45 Fix reading plugin files 2024-09-16 16:13:11 -07:00
Timmy Welch
4c9096a11b Implement the most basic local plugin isolation possible
Remove modules belonging to local plugins after loading
Remove sys.path entry after loading

This means that multiple local plugins can be installed with the same import path and should work correctly
This does not allow loading a local plugin that has the same import path as an installed plugin
2024-09-15 17:09:33 -07:00
Timmy Welch
c9c0c99a2a Increase rate limits on CV to cover the 200 requests/Hr restriction
Add twitter's alternative to HTTP code 429
2024-09-12 13:56:57 -07:00
Timmy Welch
58f71cf6d9 Remove archived tags from tests 2024-09-12 13:17:06 -07:00
Timmy Welch
befffc98b1 Catch all exceptions when parsing metadata from the CLI 2024-09-12 13:11:30 -07:00
Timmy Welch
006f3cbd1f Remove comet and cbl tags 2024-09-12 12:09:07 -07:00
Timmy Welch
582224abec Fixes for quick-tag 2024-09-12 11:51:38 -07:00
Timmy Welch
acb59f9e83 Fix saving settings 2024-08-24 12:19:11 -07:00
Timmy Welch
fab30f3f29 Add experimental quick-tag 2024-08-18 19:16:55 -07:00
Timmy Welch
2cb6caea8d Ignore update with incomplete data when complete data is already cached 2024-08-16 17:05:28 -07:00
Timmy Welch
ffdf7d71e1 Fix tests 2024-08-16 12:50:14 -07:00
Timmy Welch
db3d5d6a01 Merge branch 'jxl' into develop 2024-08-09 16:34:25 -07:00
Timmy Welch
8709ef301d Fix failing test 2024-08-03 23:11:31 -07:00
Timmy Welch
b8728c5eed Improve performance when re-tagging file based tags in zip archives 2024-08-03 14:41:04 -07:00
pre-commit-ci[bot]
0ba81f9f86 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-08-03 21:27:25 +00:00
Timmy Welch
8c85a60f67 Add pillow-jxl-plugin as an optional dependency 2024-08-03 14:15:00 -07:00
50 changed files with 1841 additions and 1277 deletions

View File

@ -47,7 +47,7 @@ jobs:
strategy:
matrix:
python-version: [3.9]
os: [ubuntu-latest, macos-12, windows-latest]
os: [ubuntu-22.04, macos-13, windows-latest]
steps:
- uses: actions/checkout@v4
@ -70,7 +70,7 @@ jobs:
- name: Install linux dependencies
run: |
sudo apt-get update && sudo apt-get upgrade && sudo apt-get install pkg-config libicu-dev libqt5gui5 libfuse2
sudo apt-get update && sudo apt-get upgrade && sudo apt-get install pkg-config libicu-dev libqt5gui5 libfuse2 desktop-file-utils
if: runner.os == 'Linux'
- name: Build and install PyPi packages

View File

@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
python-version: [3.9]
os: [ubuntu-latest, macos-12, windows-latest]
os: [ubuntu-22.04, macos-13, windows-latest]
steps:
- uses: actions/checkout@v4
@ -39,7 +39,7 @@ jobs:
- name: Install linux dependencies
run: |
sudo apt-get update && sudo apt-get upgrade && sudo apt-get install pkg-config libicu-dev libqt5gui5 libfuse2
sudo apt-get update && sudo apt-get upgrade && sudo apt-get install pkg-config libicu-dev libqt5gui5 libfuse2 desktop-file-utils
if: runner.os == 'Linux'
- name: Build, Install and Test PyPi packages

View File

@ -1,7 +1,7 @@
exclude: ^(scripts|comictaggerlib/graphics/resources.py)
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
@ -10,11 +10,11 @@ repos:
- id: name-tests-test
- id: requirements-txt-fixer
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v2.5.0
rev: v2.7.0
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/asottile/pyupgrade
rev: v3.17.0
rev: v3.18.0
hooks:
- id: pyupgrade
args: [--py39-plus]
@ -33,12 +33,12 @@ repos:
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 7.1.0
rev: 7.1.1
hooks:
- id: flake8
additional_dependencies: [flake8-encodings, flake8-builtins, flake8-print, flake8-no-nested-comprehensions]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.0
rev: v1.11.2
hooks:
- id: mypy
additional_dependencies: [types-setuptools, types-requests, settngs>=0.10.4]

View File

@ -10,7 +10,7 @@ import comictaggerlib.main
def generate() -> str:
app = comictaggerlib.main.App()
app.load_plugins(app.initial_arg_parser.parse_known_args()[0])
app.register_settings()
app.register_settings(True)
imports, types = settngs.generate_dict(app.manager.definitions)
imports2, types2 = settngs.generate_ns(app.manager.definitions)
i = imports.splitlines()

View File

@ -27,7 +27,7 @@ if opts.APPIMAGETOOL.exists():
raise SystemExit(0)
urlretrieve(
"https://github.com/AppImage/AppImageKit/releases/latest/download/appimagetool-x86_64.AppImage", opts.APPIMAGETOOL
"https://github.com/AppImage/appimagetool/releases/latest/download/appimagetool-x86_64.AppImage", opts.APPIMAGETOOL
)
os.chmod(opts.APPIMAGETOOL, 0o0700)

View File

@ -4,7 +4,6 @@ import logging
import os
import pathlib
import shutil
import struct
import tempfile
import zipfile
from typing import cast
@ -47,7 +46,7 @@ class ZipArchiver(Archiver):
try:
data = zf.read(archive_file)
except (zipfile.BadZipfile, OSError) as e:
logger.error("Error reading zip archive [%s]: %s :: %s", e, self.path, archive_file)
logger.exception("Error reading zip archive [%s]: %s :: %s", e, self.path, archive_file)
raise
return data
@ -59,13 +58,13 @@ class ZipArchiver(Archiver):
# zip archive w/o the indicated file. Very sucky, but maybe
# another solution can be found
files = self.get_filename_list()
if archive_file in files:
if not self.rebuild([archive_file]):
return False
try:
# now just add the archive file as a new one
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) # type: ignore
zf.writestr(archive_file, data)
return True
except (zipfile.BadZipfile, OSError) as e:
@ -125,7 +124,7 @@ class ZipArchiver(Archiver):
# preserve the old comment
comment = other_archive.get_comment()
if comment is not None:
if not self.write_zip_comment(self.path, comment):
if not self.set_comment(comment):
return False
except Exception as e:
logger.error("Error while copying to zip archive [%s]: from %s to %s", e, other_archive.path, self.path)
@ -144,61 +143,106 @@ class ZipArchiver(Archiver):
@classmethod
def is_valid(cls, path: pathlib.Path) -> bool:
return zipfile.is_zipfile(path)
def write_zip_comment(self, filename: pathlib.Path | str, comment: str) -> bool:
"""
This is a custom function for writing a comment to a zip file,
since the built-in one doesn't seem to work on Windows and Mac OS/X
Fortunately, the zip comment is at the end of the file, and it's
easy to manipulate. See this website for more info:
see: http://en.wikipedia.org/wiki/Zip_(file_format)#Structure
"""
# get file size
statinfo = os.stat(filename)
file_length = statinfo.st_size
try:
with open(filename, mode="r+b") as file:
# the starting position, relative to EOF
pos = -4
found = False
# walk backwards to find the "End of Central Directory" record
while (not found) and (-pos != file_length):
# seek, relative to EOF
file.seek(pos, 2)
value = file.read(4)
# look for the end of central directory signature
if bytearray(value) == bytearray([0x50, 0x4B, 0x05, 0x06]):
found = True
else:
# not found, step back another byte
pos = pos - 1
if found:
# now skip forward 20 bytes to the comment length word
pos += 20
file.seek(pos, 2)
# Pack the length of the comment string
fmt = "H" # one 2-byte integer
comment_length = struct.pack(fmt, len(comment)) # pack integer in a binary string
# write out the length
file.write(comment_length)
file.seek(pos + 2, 2)
# write out the comment itself
file.write(comment.encode("utf-8"))
file.truncate()
else:
raise Exception("Could not find the End of Central Directory record!")
except Exception as e:
logger.error("Error writing comment to zip archive [%s]: %s", e, self.path)
if not zipfile.is_zipfile(path): # only checks central directory ot the end of the archive
return False
else:
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

@ -23,9 +23,7 @@ import os
import pathlib
import shutil
import sys
import traceback
from collections.abc import Iterable
from typing import TYPE_CHECKING
from comicapi import utils
from comicapi.archivers import Archiver, UnknownArchiver, ZipArchiver
@ -33,16 +31,13 @@ from comicapi.genericmetadata import GenericMetadata
from comicapi.tags import Tag
from comictaggerlib.ctversion import version
if TYPE_CHECKING:
from importlib.metadata import EntryPoint
logger = logging.getLogger(__name__)
archivers: list[type[Archiver]] = []
tags: dict[str, Tag] = {}
def load_archive_plugins(local_plugins: Iterable[EntryPoint] = tuple()) -> None:
def load_archive_plugins(local_plugins: Iterable[type[Archiver]] = tuple()) -> None:
if archivers:
return
if sys.version_info < (3, 10):
@ -53,7 +48,7 @@ def load_archive_plugins(local_plugins: Iterable[EntryPoint] = tuple()) -> None:
archive_plugins: list[type[Archiver]] = []
# A list is used first matching plugin wins
for ep in itertools.chain(local_plugins, entry_points(group="comicapi.archiver")):
for ep in itertools.chain(entry_points(group="comicapi.archiver")):
try:
spec = importlib.util.find_spec(ep.module)
except ValueError:
@ -71,11 +66,12 @@ def load_archive_plugins(local_plugins: Iterable[EntryPoint] = tuple()) -> None:
else:
logger.exception("Failed to load archive plugin: %s", ep.name)
archivers.clear()
archivers.extend(local_plugins)
archivers.extend(archive_plugins)
archivers.extend(builtin)
def load_tag_plugins(version: str = f"ComicAPI/{version}", local_plugins: Iterable[EntryPoint] = tuple()) -> None:
def load_tag_plugins(version: str = f"ComicAPI/{version}", local_plugins: Iterable[type[Tag]] = tuple()) -> None:
if tags:
return
if sys.version_info < (3, 10):
@ -85,7 +81,7 @@ def load_tag_plugins(version: str = f"ComicAPI/{version}", local_plugins: Iterab
builtin: dict[str, Tag] = {}
tag_plugins: dict[str, tuple[Tag, str]] = {}
# A dict is used, last plugin wins
for ep in itertools.chain(entry_points(group="comicapi.tags"), local_plugins):
for ep in entry_points(group="comicapi.tags"):
location = "Unknown"
try:
_spec = importlib.util.find_spec(ep.module)
@ -110,6 +106,9 @@ def load_tag_plugins(version: str = f"ComicAPI/{version}", local_plugins: Iterab
tag_plugins[tag.id] = (tag(version), location)
except Exception:
logger.exception("Failed to load tag plugin: %s from %s", ep.name, location)
# A dict is used, last plugin wins
for tag in local_plugins:
tag_plugins[tag.id] = (tag(version), "Local")
for tag_id in set(builtin.keys()).intersection(tag_plugins):
location = tag_plugins[tag_id][1]
@ -198,7 +197,11 @@ class ComicArchive:
return self.archiver.name() == "ZIP"
def seems_to_be_a_comic_archive(self) -> bool:
if not (isinstance(self.archiver, UnknownArchiver)) and self.get_number_of_pages() > 0:
if (
not (isinstance(self.archiver, UnknownArchiver))
and self.get_number_of_pages() > 0
and self.archiver.is_valid(self.path)
):
return True
return False
@ -252,11 +255,8 @@ class ComicArchive:
if filename:
try:
image_data = self.archiver.read_file(filename) or b""
except Exception as e:
tb = traceback.extract_tb(e.__traceback__)
logger.error(
"%s:%s: Error reading in page %d. Substituting logo page.", tb[1].filename, tb[1].lineno, index
)
except Exception:
logger.exception("Error reading in page %d. Substituting logo page.", index)
image_data = ComicArchive.logo_data
return image_data
@ -337,37 +337,42 @@ class ComicArchive:
) -> None:
md.page_count = self.get_number_of_pages()
md.apply_default_page_list(self.get_page_name_list())
if calc_page_sizes:
for index, p in enumerate(md.pages):
idx = p.display_index
if self.pil_available:
try:
from PIL import Image
if not calc_page_sizes or not self.seems_to_be_a_comic_archive():
return
for p in md.pages:
self.pil_available = True
except ImportError:
self.pil_available = False
if p.byte_size is None or p.height is None or p.width is None or p.double_page is None:
data = self.get_page(idx)
p.byte_size = len(data)
if data:
try:
if isinstance(data, bytes):
im = Image.open(io.BytesIO(data))
else:
im = Image.open(io.StringIO(data))
w, h = im.size
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
p.height = h
p.width = w
if detect_double_page:
p.double_page = p.is_double_page()
except Exception as e:
logger.warning("Error decoding image [%s] %s :: image %s", e, self.path, index)
else:
if p.byte_size is not None:
data = self.get_page(idx)
p.byte_size = len(data)
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:
continue
im = Image.open(io.BytesIO(data))
w, h = im.size
p.height = h
p.width = w
if detect_double_page:
p.double_page = p.is_double_page()
except Exception as e:
logger.exception("Error decoding image [%s] %s :: image %s", e, self.path, p.archive_index)
def metadata_from_filename(
self,

View File

@ -126,5 +126,18 @@
"radio comics": "Mighty Comics Group",
"red circle Comics": "Dark Circle Comics",
"red circle": "Dark Circle Comics"
},
"Image Comics": {
"Image": "",
"avalon studios": "Avalon Studios",
"desperado publishing": "Desperado Publishing",
"extreme studios": "Extreme Studios",
"gorilla comics": "Gorilla Comics",
"highbrow entertainment": "Highbrow Entertainment",
"shadowline": "Shadowline",
"skybound entertainment": "Skybound Entertainment",
"todd mcfarlane productions": "Todd McFarlane Productions",
"top cow productions": "Top Cow Productions"
}
}

View File

@ -213,8 +213,11 @@ def lex_filename(lex: Lexer) -> LexerFunc | None:
r = lex.peek()
if r.isdigit():
return lex_number
lex.accept_run(is_symbol)
lex.emit(ItemType.Symbol)
if is_symbol(r):
lex.accept_run(is_symbol)
lex.emit(ItemType.Symbol)
else:
return lex_text
elif r.isnumeric():
lex.backup()
return lex_number
@ -305,7 +308,7 @@ def lex_space(lex: Lexer) -> LexerFunc:
def lex_text(lex: Lexer) -> LexerFunc:
while True:
r = lex.get()
if is_alpha_numeric(r):
if is_alpha_numeric(r) or r in "'":
if r.isnumeric(): # E.g. v1
word = lex.input[lex.start : lex.pos]
if key.get(word.casefold(), None) == ItemType.InfoSpecifier:
@ -313,10 +316,7 @@ def lex_text(lex: Lexer) -> LexerFunc:
lex.emit(key[word.casefold()])
return lex_filename
else:
if r == "'" and lex.peek().casefold() == "s":
lex.get()
else:
lex.backup()
lex.backup()
word = lex.input[lex.start : lex.pos + 1]
if word.casefold() in key:

View File

@ -43,6 +43,9 @@ logger = logging.getLogger(__name__)
REMOVE = object()
Credit = merge.Credit
class PageType(merge.StrEnum):
"""
These page info classes are exactly the same as the CIX scheme, since
@ -94,8 +97,18 @@ class PageMetadata:
return False
return self.archive_index == other.archive_index
Credit = merge.Credit
def _get_clean_metadata(self, *attributes: str) -> PageMetadata:
return PageMetadata(
filename=self.filename if "filename" in attributes else "",
type=self.type if "type" in attributes else "",
bookmark=self.bookmark if "bookmark" in attributes else "",
display_index=self.display_index if "display_index" in attributes else 0,
archive_index=self.archive_index if "archive_index" in attributes else 0,
double_page=self.double_page if "double_page" in attributes else None,
byte_size=self.byte_size if "byte_size" in attributes else None,
height=self.height if "height" in attributes else None,
width=self.width if "width" in attributes else None,
)
@dataclasses.dataclass
@ -179,7 +192,7 @@ class GenericMetadata:
characters: set[str] = dataclasses.field(default_factory=set)
teams: set[str] = dataclasses.field(default_factory=set)
locations: set[str] = dataclasses.field(default_factory=set)
credits: list[merge.Credit] = dataclasses.field(default_factory=list)
credits: list[Credit] = dataclasses.field(default_factory=list)
# Some CoMet-only items
price: float | None = None
@ -206,14 +219,23 @@ class GenericMetadata:
tmp.__dict__.update(kwargs)
return tmp
def get_clean_metadata(self, *attributes: str) -> GenericMetadata:
def _get_clean_metadata(self, *attributes: str) -> GenericMetadata:
new_md = GenericMetadata()
list_handled = []
for attr in sorted(attributes):
if "." in attr:
lst, _, name = attr.partition(".")
if lst in list_handled:
continue
old_value = getattr(self, lst)
new_value = getattr(new_md, lst)
if old_value:
if hasattr(old_value[0], "_get_clean_metadata"):
list_attributes = [x.removeprefix(lst + ".") for x in attributes if x.startswith(lst)]
for x in old_value:
new_value.append(x._get_clean_metadata(*list_attributes))
list_handled.append(lst)
continue
if not new_value:
for x in old_value:
new_value.append(x.__class__())
@ -369,19 +391,21 @@ class GenericMetadata:
return coverlist
@overload
def add_credit(self, person: merge.Credit) -> None: ...
def add_credit(self, person: Credit) -> None: ...
@overload
def add_credit(self, person: str, role: str, primary: bool = False) -> None: ...
def add_credit(self, person: str, role: str, primary: bool = False, language: str = "") -> None: ...
def add_credit(self, person: str | merge.Credit, role: str | None = None, primary: bool = False) -> None:
def add_credit(
self, person: str | Credit, role: str | None = None, primary: bool = False, language: str = ""
) -> None:
credit: merge.Credit
if isinstance(person, merge.Credit):
credit: Credit
if isinstance(person, Credit):
credit = person
else:
assert role is not None
credit = merge.Credit(person=person, role=role, primary=primary)
credit = Credit(person=person, role=role, primary=primary, language=language)
if credit.role is None:
raise TypeError("GenericMetadata.add_credit takes either a Credit object or a person name and role")
@ -552,12 +576,12 @@ md_test: GenericMetadata = GenericMetadata(
teams={"Fahrenheit"},
locations=set(utils.split("lonely cottage ", ",")),
credits=[
merge.Credit(primary=False, person="Dara Naraghi", role="Writer"),
merge.Credit(primary=False, person="Esteve Polls", role="Penciller"),
merge.Credit(primary=False, person="Esteve Polls", role="Inker"),
merge.Credit(primary=False, person="Neil Uyetake", role="Letterer"),
merge.Credit(primary=False, person="Sam Kieth", role="Cover"),
merge.Credit(primary=False, person="Ted Adams", role="Editor"),
Credit(primary=False, person="Dara Naraghi", role="Writer"),
Credit(primary=False, person="Esteve Polls", role="Penciller"),
Credit(primary=False, person="Esteve Polls", role="Inker"),
Credit(primary=False, person="Neil Uyetake", role="Letterer"),
Credit(primary=False, person="Sam Kieth", role="Cover"),
Credit(primary=False, person="Ted Adams", role="Editor"),
],
tags=set(),
pages=[

View File

@ -1,12 +1,11 @@
from __future__ import annotations
import dataclasses
from collections import defaultdict
from collections.abc import Collection
from enum import auto
from typing import Any
from comicapi.utils import StrEnum, norm_fold
from comicapi.utils import DefaultDict, StrEnum, norm_fold
@dataclasses.dataclass
@ -14,9 +13,13 @@ class Credit:
person: str = ""
role: str = ""
primary: bool = False
language: str = "" # Should be ISO 639 language code
def __str__(self) -> str:
return f"{self.role}: {self.person}"
lang = ""
if self.language:
lang = f" [{self.language}]"
return f"{self.role}: {self.person}{lang}"
class Mode(StrEnum):
@ -51,19 +54,19 @@ def overlay(old: Any, new: Any) -> Any:
return new
attribute = defaultdict(
lambda: overlay,
attribute = DefaultDict(
{
Mode.OVERLAY: overlay,
Mode.ADD_MISSING: lambda old, new: overlay(new, old),
},
default=lambda x: overlay,
)
lists = defaultdict(
lambda: overlay,
lists = DefaultDict(
{
Mode.OVERLAY: merge_lists,
Mode.ADD_MISSING: lambda old, new: merge_lists(new, old),
},
default=lambda x: overlay,
)

View File

@ -1,323 +0,0 @@
"""A class to encapsulate CoMet data"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import logging
import os
import xml.etree.ElementTree as ET
from typing import Any
from comicapi import utils
from comicapi.archivers import Archiver
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata, PageMetadata, PageType
from comicapi.tags import Tag
logger = logging.getLogger(__name__)
class CoMet(Tag):
enabled = True
id = "comet"
def __init__(self, version: str) -> None:
super().__init__(version)
self.comet_filename = "CoMet.xml"
self.file = "CoMet.xml"
self.supported_attributes = {
"series",
"issue",
"title",
"volume",
"genres",
"description",
"publisher",
"language",
"format",
"maturity_rating",
"month",
"year",
"page_count",
"characters",
"credits",
"credits.person",
"credits.primary",
"credits.role",
"price",
"is_version_of",
"rights",
"identifier",
"last_mark",
"pages.type", # This is required for setting the cover image none of the other types will be saved
"pages",
}
def supports_credit_role(self, role: str) -> bool:
return role.casefold() in self._get_parseable_credits()
def supports_tags(self, archive: Archiver) -> bool:
return archive.supports_files()
def has_tags(self, archive: Archiver) -> bool:
if not self.supports_tags(archive):
return False
has_tags = False
# look at all xml files in root, and search for CoMet data, get first
for n in archive.get_filename_list():
if os.path.dirname(n) == "" and os.path.splitext(n)[1].casefold() == ".xml":
# read in XML file, and validate it
data = b""
try:
data = archive.read_file(n)
except Exception as e:
logger.warning("Error reading in Comet XML for validation! from %s: %s", archive.path, e)
if self._validate_bytes(data):
# since we found it, save it!
self.file = n
has_tags = True
break
return has_tags
def remove_tags(self, archive: Archiver) -> bool:
return self.has_tags(archive) and archive.remove_file(self.file)
def read_tags(self, archive: Archiver) -> GenericMetadata:
if self.has_tags(archive):
metadata = archive.read_file(self.file) or b""
if self._validate_bytes(metadata):
return self._metadata_from_bytes(metadata, archive)
return GenericMetadata()
def read_raw_tags(self, archive: Archiver) -> str:
if self.has_tags(archive):
return ET.tostring(ET.fromstring(archive.read_file(self.file)), encoding="unicode", xml_declaration=True)
return ""
def write_tags(self, metadata: GenericMetadata, archive: Archiver) -> bool:
if self.supports_tags(archive):
success = True
xml = b""
if self.has_tags(archive):
xml = archive.read_file(self.file)
if self.file != self.comet_filename:
success = self.remove_tags(archive)
return success and archive.write_file(self.comet_filename, self._bytes_from_metadata(metadata, xml))
else:
logger.warning(f"Archive ({archive.name()}) does not support {self.name()} metadata")
return False
def name(self) -> str:
return "Comic Metadata (CoMet)"
@classmethod
def _get_parseable_credits(cls) -> list[str]:
parsable_credits: list[str] = []
parsable_credits.extend(GenericMetadata.writer_synonyms)
parsable_credits.extend(GenericMetadata.penciller_synonyms)
parsable_credits.extend(GenericMetadata.inker_synonyms)
parsable_credits.extend(GenericMetadata.colorist_synonyms)
parsable_credits.extend(GenericMetadata.letterer_synonyms)
parsable_credits.extend(GenericMetadata.cover_synonyms)
parsable_credits.extend(GenericMetadata.editor_synonyms)
return parsable_credits
def _metadata_from_bytes(self, string: bytes, archive: Archiver) -> GenericMetadata:
tree = ET.ElementTree(ET.fromstring(string))
return self._convert_xml_to_metadata(tree, archive)
def _bytes_from_metadata(self, metadata: GenericMetadata, xml: bytes = b"") -> bytes:
tree = self._convert_metadata_to_xml(metadata, xml)
return ET.tostring(tree.getroot(), encoding="utf-8", xml_declaration=True)
def _convert_metadata_to_xml(self, metadata: GenericMetadata, xml: bytes = b"") -> ET.ElementTree:
# shorthand for the metadata
md = metadata
if xml:
root = ET.fromstring(xml)
else:
# build a tree structure
root = ET.Element("comet")
root.attrib["xmlns:comet"] = "http://www.denvog.com/comet/"
root.attrib["xmlns:xsi"] = "http://www.w3.org/2001/XMLSchema-instance"
root.attrib["xsi:schemaLocation"] = "http://www.denvog.com http://www.denvog.com/comet/comet.xsd"
# helper func
def assign(comet_entry: str, md_entry: Any) -> None:
if md_entry is not None:
ET.SubElement(root, comet_entry).text = str(md_entry)
# title is manditory
assign("title", md.title or "")
assign("series", md.series)
assign("issue", md.issue) # must be int??
assign("volume", md.volume)
assign("description", md.description)
assign("publisher", md.publisher)
assign("pages", md.page_count)
assign("format", md.format)
assign("language", md.language)
assign("rating", md.maturity_rating)
assign("price", md.price)
assign("isVersionOf", md.is_version_of)
assign("rights", md.rights)
assign("identifier", md.identifier)
assign("lastMark", md.last_mark)
assign("genre", ",".join(md.genres)) # TODO repeatable
for c in md.characters:
assign("character", c.strip())
if md.manga is not None and md.manga == "YesAndRightToLeft":
assign("readingDirection", "rtl")
if md.year is not None:
date_str = f"{md.year:04}"
if md.month is not None:
date_str += f"-{md.month:02}"
assign("date", date_str)
cover_index = md.get_cover_page_index_list()[0]
assign("coverImage", md.pages[cover_index].filename)
# loop thru credits, and build a list for each role that CoMet supports
for credit in metadata.credits:
if credit.role.casefold() in set(GenericMetadata.writer_synonyms):
ET.SubElement(root, "writer").text = str(credit.person)
if credit.role.casefold() in set(GenericMetadata.penciller_synonyms):
ET.SubElement(root, "penciller").text = str(credit.person)
if credit.role.casefold() in set(GenericMetadata.inker_synonyms):
ET.SubElement(root, "inker").text = str(credit.person)
if credit.role.casefold() in set(GenericMetadata.colorist_synonyms):
ET.SubElement(root, "colorist").text = str(credit.person)
if credit.role.casefold() in set(GenericMetadata.letterer_synonyms):
ET.SubElement(root, "letterer").text = str(credit.person)
if credit.role.casefold() in set(GenericMetadata.cover_synonyms):
ET.SubElement(root, "coverDesigner").text = str(credit.person)
if credit.role.casefold() in set(GenericMetadata.editor_synonyms):
ET.SubElement(root, "editor").text = str(credit.person)
ET.indent(root)
# wrap it in an ElementTree instance, and save as XML
tree = ET.ElementTree(root)
return tree
def _convert_xml_to_metadata(self, tree: ET.ElementTree, archive: Archiver) -> GenericMetadata:
root = tree.getroot()
if root.tag != "comet":
raise Exception("Not a CoMet file")
metadata = GenericMetadata()
md = metadata
# Helper function
def get(tag: str) -> Any:
node = root.find(tag)
if node is not None:
return node.text
return None
md.series = utils.xlate(get("series"))
md.title = utils.xlate(get("title"))
md.issue = utils.xlate(get("issue"))
md.volume = utils.xlate_int(get("volume"))
md.description = utils.xlate(get("description"))
md.publisher = utils.xlate(get("publisher"))
md.language = utils.xlate(get("language"))
md.format = utils.xlate(get("format"))
md.page_count = utils.xlate_int(get("pages"))
md.maturity_rating = utils.xlate(get("rating"))
md.price = utils.xlate_float(get("price"))
md.is_version_of = utils.xlate(get("isVersionOf"))
md.rights = utils.xlate(get("rights"))
md.identifier = utils.xlate(get("identifier"))
md.last_mark = utils.xlate(get("lastMark"))
_, md.month, md.year = utils.parse_date_str(utils.xlate(get("date")))
ca = ComicArchive(archive)
cover_filename = utils.xlate(get("coverImage"))
page_list = ca.get_page_name_list()
if cover_filename in page_list:
cover_index = page_list.index(cover_filename)
md.pages = [
PageMetadata(
archive_index=cover_index,
display_index=0,
filename=cover_filename,
type=PageType.FrontCover,
bookmark="",
)
]
reading_direction = utils.xlate(get("readingDirection"))
if reading_direction is not None and reading_direction == "rtl":
md.manga = "YesAndRightToLeft"
# loop for genre tags
for n in root:
if n.tag == "genre":
md.genres.add((n.text or "").strip())
# loop for character tags
for n in root:
if n.tag == "character":
md.characters.add((n.text or "").strip())
# Now extract the credit info
for n in root:
if any(
[
n.tag == "writer",
n.tag == "penciller",
n.tag == "inker",
n.tag == "colorist",
n.tag == "letterer",
n.tag == "editor",
]
):
metadata.add_credit((n.text or "").strip(), n.tag.title())
if n.tag == "coverDesigner":
metadata.add_credit((n.text or "").strip(), "Cover")
metadata.is_empty = False
return metadata
# verify that the string actually contains CoMet data in XML format
def _validate_bytes(self, string: bytes) -> bool:
try:
tree = ET.ElementTree(ET.fromstring(string))
root = tree.getroot()
if root.tag != "comet":
return False
except ET.ParseError:
return False
return True

View File

@ -1,229 +0,0 @@
"""A class to encapsulate the ComicBookInfo data"""
# Copyright 2012-2014 ComicTagger Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Any, Literal, TypedDict
from comicapi import utils
from comicapi.archivers import Archiver
from comicapi.genericmetadata import Credit, GenericMetadata
from comicapi.tags import Tag
logger = logging.getLogger(__name__)
_CBILiteralType = Literal[
"series",
"title",
"issue",
"publisher",
"publicationMonth",
"publicationYear",
"numberOfIssues",
"comments",
"genre",
"volume",
"numberOfVolumes",
"language",
"country",
"rating",
"credits",
"tags",
]
class credit(TypedDict):
person: str
role: str
primary: bool
class _ComicBookInfoJson(TypedDict, total=False):
series: str
title: str
publisher: str
publicationMonth: int
publicationYear: int
issue: int
numberOfIssues: int
volume: int
numberOfVolumes: int
rating: int
genre: str
language: str
country: str
credits: list[credit]
tags: list[str]
comments: str
_CBIContainer = TypedDict("_CBIContainer", {"appID": str, "lastModified": str, "ComicBookInfo/1.0": _ComicBookInfoJson})
class ComicBookInfo(Tag):
enabled = True
id = "cbi"
def __init__(self, version: str) -> None:
super().__init__(version)
self.supported_attributes = {
"series",
"issue",
"issue_count",
"title",
"volume",
"volume_count",
"genres",
"description",
"publisher",
"month",
"year",
"language",
"country",
"critical_rating",
"tags",
"credits",
"credits.person",
"credits.primary",
"credits.role",
}
def supports_credit_role(self, role: str) -> bool:
return True
def supports_tags(self, archive: Archiver) -> bool:
return archive.supports_comment()
def has_tags(self, archive: Archiver) -> bool:
return self.supports_tags(archive) and self._validate_string(archive.get_comment())
def remove_tags(self, archive: Archiver) -> bool:
return archive.set_comment("")
def read_tags(self, archive: Archiver) -> GenericMetadata:
if self.has_tags(archive):
comment = archive.get_comment()
if self._validate_string(comment):
return self._metadata_from_string(comment)
return GenericMetadata()
def read_raw_tags(self, archive: Archiver) -> str:
if self.has_tags(archive):
return json.dumps(json.loads(archive.get_comment()), indent=2)
return ""
def write_tags(self, metadata: GenericMetadata, archive: Archiver) -> bool:
if self.supports_tags(archive):
return archive.set_comment(self._string_from_metadata(metadata))
else:
logger.warning(f"Archive ({archive.name()}) does not support {self.name()} metadata")
return False
def name(self) -> str:
return "ComicBookInfo"
def _metadata_from_string(self, string: str) -> GenericMetadata:
cbi_container: _CBIContainer = json.loads(string)
metadata = GenericMetadata()
cbi = cbi_container["ComicBookInfo/1.0"]
metadata.series = utils.xlate(cbi.get("series"))
metadata.title = utils.xlate(cbi.get("title"))
metadata.issue = utils.xlate(cbi.get("issue"))
metadata.publisher = utils.xlate(cbi.get("publisher"))
metadata.month = utils.xlate_int(cbi.get("publicationMonth"))
metadata.year = utils.xlate_int(cbi.get("publicationYear"))
metadata.issue_count = utils.xlate_int(cbi.get("numberOfIssues"))
metadata.description = utils.xlate(cbi.get("comments"))
metadata.genres = set(utils.split(cbi.get("genre"), ","))
metadata.volume = utils.xlate_int(cbi.get("volume"))
metadata.volume_count = utils.xlate_int(cbi.get("numberOfVolumes"))
metadata.language = utils.xlate(cbi.get("language"))
metadata.country = utils.xlate(cbi.get("country"))
metadata.critical_rating = utils.xlate_int(cbi.get("rating"))
metadata.credits = [
Credit(
person=x["person"] if "person" in x else "",
role=x["role"] if "role" in x else "",
primary=x["primary"] if "primary" in x else False,
)
for x in cbi.get("credits", [])
]
metadata.tags.update(cbi.get("tags", set()))
# need the language string to be ISO
if metadata.language:
metadata.language = utils.get_language_iso(metadata.language)
metadata.is_empty = False
return metadata
def _string_from_metadata(self, metadata: GenericMetadata) -> str:
cbi_container = self._create_json_dictionary(metadata)
return json.dumps(cbi_container)
def _validate_string(self, string: bytes | str) -> bool:
"""Verify that the string actually contains CBI data in JSON format"""
try:
cbi_container = json.loads(string)
except json.JSONDecodeError:
return False
return "ComicBookInfo/1.0" in cbi_container
def _create_json_dictionary(self, metadata: GenericMetadata) -> _CBIContainer:
"""Create the dictionary that we will convert to JSON text"""
cbi_container = _CBIContainer(
{
"appID": "ComicTagger/1.0.0",
"lastModified": str(datetime.now()),
"ComicBookInfo/1.0": {},
}
) # TODO: ctversion.version,
# helper func
def assign(cbi_entry: _CBILiteralType, md_entry: Any) -> None:
if md_entry is not None or isinstance(md_entry, str) and md_entry != "":
cbi_container["ComicBookInfo/1.0"][cbi_entry] = md_entry
assign("series", utils.xlate(metadata.series))
assign("title", utils.xlate(metadata.title))
assign("issue", utils.xlate(metadata.issue))
assign("publisher", utils.xlate(metadata.publisher))
assign("publicationMonth", utils.xlate_int(metadata.month))
assign("publicationYear", utils.xlate_int(metadata.year))
assign("numberOfIssues", utils.xlate_int(metadata.issue_count))
assign("comments", utils.xlate(metadata.description))
assign("genre", utils.xlate(",".join(metadata.genres)))
assign("volume", utils.xlate_int(metadata.volume))
assign("numberOfVolumes", utils.xlate_int(metadata.volume_count))
assign("language", utils.xlate(utils.get_language_from_iso(metadata.language)))
assign("country", utils.xlate(metadata.country))
assign("rating", utils.xlate_int(metadata.critical_rating))
assign("credits", [credit(person=c.person, role=c.role, primary=c.primary) for c in metadata.credits])
assign("tags", list(metadata.tags))
return cbi_container

View File

@ -87,33 +87,47 @@ class ComicRack(Tag):
return archive.supports_files()
def has_tags(self, archive: Archiver) -> bool:
return (
self.supports_tags(archive)
and self.file in archive.get_filename_list()
and self._validate_bytes(archive.read_file(self.file))
)
try: # read_file can cause an exception
return (
self.supports_tags(archive)
and self.file in archive.get_filename_list()
and self._validate_bytes(archive.read_file(self.file))
)
except Exception:
return False
def remove_tags(self, archive: Archiver) -> bool:
return self.has_tags(archive) and archive.remove_file(self.file)
def read_tags(self, archive: Archiver) -> GenericMetadata:
if self.has_tags(archive):
metadata = archive.read_file(self.file) or b""
if self._validate_bytes(metadata):
return self._metadata_from_bytes(metadata)
try: # read_file can cause an exception
metadata = archive.read_file(self.file) or b""
if self._validate_bytes(metadata):
return self._metadata_from_bytes(metadata)
except Exception:
...
return GenericMetadata()
def read_raw_tags(self, archive: Archiver) -> str:
if self.has_tags(archive):
return ET.tostring(ET.fromstring(archive.read_file(self.file)), encoding="unicode", xml_declaration=True)
try: # read_file can cause an exception
if self.has_tags(archive):
b = archive.read_file(self.file)
# ET.fromstring is used as xml can declare the encoding
return ET.tostring(ET.fromstring(b), encoding="unicode", xml_declaration=True)
except Exception:
...
return ""
def write_tags(self, metadata: GenericMetadata, archive: Archiver) -> bool:
if self.supports_tags(archive):
xml = b""
if self.has_tags(archive):
xml = archive.read_file(self.file)
return archive.write_file(self.file, self._bytes_from_metadata(metadata, xml))
try: # read_file can cause an exception
if self.has_tags(archive):
xml = archive.read_file(self.file)
return archive.write_file(self.file, self._bytes_from_metadata(metadata, xml))
except Exception:
...
else:
logger.warning(f"Archive ({archive.name()}) does not support {self.name()} metadata")
return False
@ -252,7 +266,7 @@ class ComicRack(Tag):
else:
pages_node = ET.SubElement(root, "Pages")
for page in md.pages:
for page in sorted(md.pages, key=lambda x: x.archive_index):
page_node = ET.SubElement(pages_node, "Page")
page_node.attrib = {"Image": str(page.display_index)}
if page.bookmark:

View File

@ -61,6 +61,7 @@ class Tag:
"credits.person",
"credits.role",
"credits.primary",
"credits.language",
"price",
"is_version_of",
"rights",

View File

@ -22,11 +22,10 @@ import pathlib
import platform
import sys
import unicodedata
from collections import defaultdict
from collections.abc import Iterable, Mapping
from enum import Enum, auto
from shutil import which # noqa: F401
from typing import Any, TypeVar, cast
from typing import Any, Callable, TypeVar, cast
from comicfn2dict import comicfn2dict
@ -88,7 +87,7 @@ if sys.version_info < (3, 11):
cls._lower_members = {x.casefold(): x for x in cls} # type: ignore[attr-defined]
return cls._lower_members.get(value.casefold(), None) # type: ignore[attr-defined]
def __str__(self):
def __str__(self) -> str:
return self.value
else:
@ -107,6 +106,17 @@ else:
logger = logging.getLogger(__name__)
class DefaultDict(dict):
def __init__(self, *args, default: Callable[[Any], Any] | None = None) -> None:
super().__init__(*args)
self.default = default
def __missing__(self, key: Any) -> Any:
if self.default is None:
return key
return self.default(key)
class Parser(StrEnum):
ORIGINAL = auto()
COMPLICATED = auto()
@ -360,7 +370,9 @@ def xlate_float(data: Any) -> float | None:
if isinstance(data, (int, float)):
i = data
else:
i = str(data).translate(defaultdict(lambda: None, zip((ord(c) for c in "1234567890."), "1234567890.")))
i = str(data).translate(
DefaultDict(zip((ord(c) for c in "1234567890."), "1234567890."), default=lambda x: None)
)
if i == "":
return None
try:
@ -493,9 +505,9 @@ def parse_version(s: str) -> tuple[int, int, int]:
return (parts[0], parts[1], parts[2])
_languages: dict[str | None, str | None] = defaultdict(lambda: None)
_languages: dict[str | None, str | None] = DefaultDict(default=lambda x: None)
_countries: dict[str | None, str | None] = defaultdict(lambda: None)
_countries: dict[str | None, str | None] = DefaultDict(default=lambda x: None)
def countries() -> dict[str | None, str | None]:
@ -504,7 +516,7 @@ def countries() -> dict[str | None, str | None]:
for alpha_2, c in isocodes.countries.by_alpha_2:
_countries[alpha_2] = c["name"]
return _countries
return _countries.copy()
def languages() -> dict[str | None, str | None]:
@ -513,11 +525,13 @@ def languages() -> dict[str | None, str | None]:
for alpha_2, lng in isocodes.extendend_languages._sorted_by_index(index="alpha_2"):
_languages[alpha_2] = lng["name"]
return _languages
return _languages.copy()
def get_language_from_iso(iso: str | None) -> str | None:
return languages()[iso]
if not _languages:
return languages()[iso]
return _languages[iso]
def get_language_iso(string: str | None) -> str | None:
@ -529,10 +543,12 @@ def get_language_iso(string: str | None) -> str | None:
lang = string.casefold()
found = None
for lng in isocodes.extendend_languages.items:
for x in ("alpha_2", "alpha_3", "bibliographic", "common_name", "name"):
if x in lng and lng[x].casefold() == lang:
found = lng
# break
if found:
break
@ -542,7 +558,9 @@ def get_language_iso(string: str | None) -> str | None:
def get_country_from_iso(iso: str | None) -> str | None:
return countries()[iso]
if not _countries:
return countries()[iso]
return _countries[iso]
def get_publisher(publisher: str) -> tuple[str, str]:

View File

@ -36,6 +36,7 @@ from comictaggerlib.filerenamer import FileRenamer, get_rename_dir
from comictaggerlib.graphics import graphics_path
from comictaggerlib.issueidentifier import IssueIdentifier
from comictaggerlib.md import prepare_metadata
from comictaggerlib.quick_tag import QuickTag
from comictaggerlib.resulttypes import Action, IssueResult, MatchStatus, OnlineMatchResults, Result, Status
from comictalker.comictalker import ComicTalker, TalkerError
@ -93,6 +94,13 @@ class CLI:
def run(self) -> int:
if len(self.config.Runtime_Options__files) < 1:
if self.config.Commands__command == Action.print:
res = self.print(None)
if res.status != Status.success:
return_code = 3
if self.config.Runtime_Options__json:
print(json.dumps(dataclasses.asdict(res), cls=OutputEncoder, indent=2))
return 0
logger.error("You must specify at least one filename. Use the -h option for more info")
return 1
return_code = 0
@ -278,7 +286,14 @@ class CLI:
return (md, tags_used)
def print(self, ca: ComicArchive) -> Result:
def print(self, ca: ComicArchive | None) -> Result:
md = None
if ca is None:
if not self.config.Auto_Tag__metadata.is_empty:
if not self.config.Auto_Tag__metadata.is_empty:
self.output("--------- CLI tags ---------")
self.output(self.config.Auto_Tag__metadata)
return Result(Action.print, Status.success, None, md=md) # type: ignore
if not self.config.Runtime_Options__tags_read:
page_count = ca.get_number_of_pages()
@ -303,7 +318,6 @@ class CLI:
self.output()
md = None
for tag_id, tag in tags.items():
if not self.config.Runtime_Options__tags_read or tag_id in self.config.Runtime_Options__tags_read:
if ca.has_tags(tag_id):
@ -397,6 +411,153 @@ class CLI:
res.status = status
return res
def try_quick_tag(self, ca: ComicArchive, md: GenericMetadata) -> GenericMetadata | None:
if not self.config.Runtime_Options__enable_quick_tag:
self.output("skipping quick tag")
return None
self.output("starting quick tag")
try:
qt = QuickTag(
self.config.Quick_Tag__url,
str(utils.parse_url(self.current_talker().website).host),
self.current_talker(),
self.config,
self.output,
)
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,
self.config.Quick_Tag__aggressive_filtering,
self.config.Quick_Tag__max,
)
if ct_md is None:
ct_md = GenericMetadata()
return ct_md
except Exception:
logger.exception("Quick Tagging failed")
return None
def normal_tag(
self, ca: ComicArchive, tags_read: list[str], md: GenericMetadata, match_results: OnlineMatchResults
) -> tuple[GenericMetadata, list[IssueResult], Result | None, OnlineMatchResults]:
# ct_md, results, matches, match_results
if md is None or md.is_empty:
logger.error("No metadata given to search online with!")
res = Result(
Action.save,
status=Status.match_failure,
original_path=ca.path,
match_status=MatchStatus.no_match,
tags_written=self.config.Runtime_Options__tags_write,
tags_read=tags_read,
)
match_results.no_matches.append(res)
return GenericMetadata(), [], res, match_results
ii = IssueIdentifier(ca, self.config, self.current_talker())
ii.set_output_function(functools.partial(self.output, already_logged=True))
if not self.config.Auto_Tag__use_year_when_identifying:
md.year = None
if self.config.Auto_Tag__ignore_leading_numbers_in_filename and md.series is not None:
md.series = re.sub(r"^([\d.]+)(.*)", r"\2", md.series)
result, matches = ii.identify(ca, md)
found_match = False
choices = False
low_confidence = False
if result == IssueIdentifier.result_no_matches:
pass
elif result == IssueIdentifier.result_found_match_but_bad_cover_score:
low_confidence = True
found_match = True
elif result == IssueIdentifier.result_found_match_but_not_first_page:
found_match = True
elif result == IssueIdentifier.result_multiple_matches_with_bad_image_scores:
low_confidence = True
choices = True
elif result == IssueIdentifier.result_one_good_match:
found_match = True
elif result == IssueIdentifier.result_multiple_good_matches:
choices = True
if choices:
if low_confidence:
logger.error("Online search: Multiple low confidence matches. Save aborted")
res = Result(
Action.save,
status=Status.match_failure,
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.low_confidence_match,
tags_written=self.config.Runtime_Options__tags_write,
tags_read=tags_read,
)
match_results.low_confidence_matches.append(res)
return GenericMetadata(), matches, res, match_results
logger.error("Online search: Multiple good matches. Save aborted")
res = Result(
Action.save,
status=Status.match_failure,
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.multiple_match,
tags_written=self.config.Runtime_Options__tags_write,
tags_read=tags_read,
)
match_results.multiple_matches.append(res)
return GenericMetadata(), matches, res, match_results
if low_confidence and self.config.Runtime_Options__abort_on_low_confidence:
logger.error("Online search: Low confidence match. Save aborted")
res = Result(
Action.save,
status=Status.match_failure,
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.low_confidence_match,
tags_written=self.config.Runtime_Options__tags_write,
tags_read=tags_read,
)
match_results.low_confidence_matches.append(res)
return GenericMetadata(), matches, res, match_results
if not found_match:
logger.error("Online search: No match found. Save aborted")
res = Result(
Action.save,
status=Status.match_failure,
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.no_match,
tags_written=self.config.Runtime_Options__tags_write,
tags_read=tags_read,
)
match_results.no_matches.append(res)
return GenericMetadata(), matches, res, match_results
# we got here, so we have a single match
# now get the particular issue data
ct_md = self.fetch_metadata(matches[0].issue_id)
if ct_md.is_empty:
res = Result(
Action.save,
status=Status.fetch_data_failure,
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.good_match,
tags_written=self.config.Runtime_Options__tags_write,
tags_read=tags_read,
)
match_results.fetch_data_failures.append(res)
return GenericMetadata(), matches, res, match_results
return ct_md, matches, None, match_results
def save(self, ca: ComicArchive, match_results: OnlineMatchResults) -> tuple[Result, OnlineMatchResults]:
if self.config.Runtime_Options__skip_existing_tags:
for tag_id in self.config.Runtime_Options__tags_write:
@ -455,117 +616,34 @@ class CLI:
return res, match_results
else:
if md is None or md.is_empty:
logger.error("No metadata given to search online with!")
res = Result(
Action.save,
status=Status.match_failure,
original_path=ca.path,
match_status=MatchStatus.no_match,
tags_written=self.config.Runtime_Options__tags_write,
tags_read=tags_read,
)
match_results.no_matches.append(res)
return res, match_results
ii = IssueIdentifier(ca, self.config, self.current_talker())
ii.set_output_function(functools.partial(self.output, already_logged=True))
if not self.config.Auto_Tag__use_year_when_identifying:
md.year = None
if self.config.Auto_Tag__ignore_leading_numbers_in_filename and md.series is not None:
md.series = re.sub(r"^([\d.]+)(.*)", r"\2", md.series)
result, matches = ii.identify(ca, md)
found_match = False
choices = False
low_confidence = False
if result == IssueIdentifier.result_no_matches:
pass
elif result == IssueIdentifier.result_found_match_but_bad_cover_score:
low_confidence = True
found_match = True
elif result == IssueIdentifier.result_found_match_but_not_first_page:
found_match = True
elif result == IssueIdentifier.result_multiple_matches_with_bad_image_scores:
low_confidence = True
choices = True
elif result == IssueIdentifier.result_one_good_match:
found_match = True
elif result == IssueIdentifier.result_multiple_good_matches:
choices = True
if choices:
if low_confidence:
logger.error("Online search: Multiple low confidence matches. Save aborted")
res = Result(
Action.save,
status=Status.match_failure,
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.low_confidence_match,
tags_written=self.config.Runtime_Options__tags_write,
tags_read=tags_read,
)
match_results.low_confidence_matches.append(res)
qt_md = self.try_quick_tag(ca, md)
if qt_md is None or qt_md.is_empty:
if qt_md is not None:
self.output("Failed to find match via quick tag")
ct_md, matches, res, match_results = self.normal_tag(ca, tags_read, md, match_results) # type: ignore[assignment]
if res is not None:
return res, match_results
logger.error("Online search: Multiple good matches. Save aborted")
res = Result(
Action.save,
status=Status.match_failure,
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.multiple_match,
tags_written=self.config.Runtime_Options__tags_write,
tags_read=tags_read,
)
match_results.multiple_matches.append(res)
return res, match_results
if low_confidence and self.config.Runtime_Options__abort_on_low_confidence:
logger.error("Online search: Low confidence match. Save aborted")
res = Result(
Action.save,
status=Status.match_failure,
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.low_confidence_match,
tags_written=self.config.Runtime_Options__tags_write,
tags_read=tags_read,
)
match_results.low_confidence_matches.append(res)
return res, match_results
if not found_match:
logger.error("Online search: No match found. Save aborted")
res = Result(
Action.save,
status=Status.match_failure,
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.no_match,
tags_written=self.config.Runtime_Options__tags_write,
tags_read=tags_read,
)
match_results.no_matches.append(res)
return res, match_results
# we got here, so we have a single match
# now get the particular issue data
ct_md = self.fetch_metadata(matches[0].issue_id)
if ct_md.is_empty:
res = Result(
Action.save,
status=Status.fetch_data_failure,
original_path=ca.path,
online_results=matches,
match_status=MatchStatus.good_match,
tags_written=self.config.Runtime_Options__tags_write,
tags_read=tags_read,
)
match_results.fetch_data_failures.append(res)
return res, match_results
else:
self.output("Successfully matched via quick tag")
ct_md = qt_md
matches = [
IssueResult(
series=ct_md.series or "",
distance=-1,
issue_number=ct_md.issue or "",
issue_count=ct_md.issue_count,
url_image_hash=-1,
issue_title=ct_md.title or "",
issue_id=ct_md.issue_id or "",
series_id=ct_md.issue_id or "",
month=ct_md.month,
year=ct_md.year,
publisher=None,
image_url=ct_md._cover_image or "",
alt_image_urls=[],
description=ct_md.description or "",
)
]
res = Result(
Action.save,

View File

@ -17,10 +17,13 @@
from __future__ import annotations
import logging
from typing import Any
import operator
from PyQt5 import QtWidgets, uic
import natsort
from PyQt5 import QtCore, QtWidgets, uic
from comicapi import utils
from comicapi.genericmetadata import Credit
from comictaggerlib.ui import ui_path
logger = logging.getLogger(__name__)
@ -30,7 +33,7 @@ class CreditEditorWindow(QtWidgets.QDialog):
ModeEdit = 0
ModeNew = 1
def __init__(self, parent: QtWidgets.QWidget, mode: int, role: str, name: str, primary: bool) -> None:
def __init__(self, parent: QtWidgets.QWidget, mode: int, credit: Credit) -> None:
super().__init__(parent)
with (ui_path / "crediteditorwindow.ui").open(encoding="utf-8") as uifile:
@ -45,54 +48,51 @@ class CreditEditorWindow(QtWidgets.QDialog):
# Add the entries to the role combobox
self.cbRole.addItem("")
self.cbRole.addItem("Writer")
self.cbRole.addItem("Artist")
self.cbRole.addItem("Penciller")
self.cbRole.addItem("Inker")
self.cbRole.addItem("Colorist")
self.cbRole.addItem("Letterer")
self.cbRole.addItem("Cover Artist")
self.cbRole.addItem("Editor")
self.cbRole.addItem("Other")
self.cbRole.addItem("Inker")
self.cbRole.addItem("Letterer")
self.cbRole.addItem("Penciller")
self.cbRole.addItem("Plotter")
self.cbRole.addItem("Scripter")
self.cbRole.addItem("Translator")
self.cbRole.addItem("Writer")
self.cbRole.addItem("Other")
self.leName.setText(name)
self.cbLanguage.addItem("", "")
for f in natsort.humansorted(utils.languages().items(), operator.itemgetter(1)):
self.cbLanguage.addItem(f[1], f[0])
if role is not None and role != "":
i = self.cbRole.findText(role)
self.leName.setText(credit.person)
if credit.role is not None and credit.role != "":
i = self.cbRole.findText(credit.role)
if i == -1:
self.cbRole.setEditText(role)
self.cbRole.setEditText(credit.role)
else:
self.cbRole.setCurrentIndex(i)
self.cbPrimary.setChecked(primary)
if credit.language != "":
i = (
self.cbLanguage.findData(credit.language, QtCore.Qt.ItemDataRole.UserRole)
if self.cbLanguage.findData(credit.language, QtCore.Qt.ItemDataRole.UserRole) > -1
else self.cbLanguage.findText(credit.language)
)
if i == -1:
self.cbLanguage.setEditText(credit.language)
else:
self.cbLanguage.setCurrentIndex(i)
self.cbRole.currentIndexChanged.connect(self.role_changed)
self.cbRole.editTextChanged.connect(self.role_changed)
self.cbPrimary.setChecked(credit.primary)
self.update_primary_button()
def update_primary_button(self) -> None:
enabled = self.current_role_can_be_primary()
self.cbPrimary.setEnabled(enabled)
def current_role_can_be_primary(self) -> bool:
role = self.cbRole.currentText()
if role.casefold() in ("artist", "writer"):
return True
return False
def role_changed(self, s: Any) -> None:
self.update_primary_button()
def get_credits(self) -> tuple[str, str, bool]:
primary = self.current_role_can_be_primary() and self.cbPrimary.isChecked()
return self.cbRole.currentText(), self.leName.text(), primary
def get_credit(self) -> Credit:
lang = self.cbLanguage.currentData() or self.cbLanguage.currentText()
return Credit(self.leName.text(), self.cbRole.currentText(), self.cbPrimary.isChecked(), lang)
def accept(self) -> None:
if self.cbRole.currentText() == "" or self.leName.text() == "":
QtWidgets.QMessageBox.warning(self, "Whoops", "You need to enter both role and name for a credit.")
if self.leName.text() == "":
QtWidgets.QMessageBox.warning(self, "Whoops", "You need to enter a name for a credit.")
else:
QtWidgets.QDialog.accept(self)

View File

@ -104,6 +104,9 @@ def save_file(
filename: A pathlib.Path object to save the json dictionary to
"""
file_options = settngs.clean_config(config, file=True)
if "Quick Tag" in file_options and "url" in file_options["Quick Tag"]:
file_options["Quick Tag"]["url"] = str(file_options["Quick Tag"]["url"])
try:
if not filename.exists():
filename.parent.mkdir(exist_ok=True, parents=True)

View File

@ -27,7 +27,7 @@ import settngs
from comicapi import utils
from comicapi.comicarchive import tags
from comictaggerlib import ctversion
from comictaggerlib import ctversion, quick_tag
from comictaggerlib.ctsettings.settngs_namespace import SettngsNS as ct_ns
from comictaggerlib.ctsettings.types import ComicTaggerPaths, tag
from comictaggerlib.resulttypes import Action
@ -51,6 +51,12 @@ def initial_commandline_parser() -> argparse.ArgumentParser:
default=0,
help="Be noisy when doing what it does. Use a second time to enable debug logs.\nShort option cannot be combined with other options.",
)
parser.add_argument(
"--enable-quick-tag",
action=argparse.BooleanOptionalAction,
default=False,
help='Enable the expiremental "quick tagger"',
)
return parser
@ -70,6 +76,13 @@ def register_runtime(parser: settngs.Manager) -> None:
help="Be noisy when doing what it does. Use a second time to enable debug logs.\nShort option cannot be combined with other options.",
file=False,
)
parser.add_setting(
"--enable-quick-tag",
action=argparse.BooleanOptionalAction,
default=False,
help='Enable the expiremental "quick tagger"',
file=False,
)
parser.add_setting("-q", "--quiet", action="store_true", help="Don't say much (for print mode).", file=False)
parser.add_setting(
"-j",
@ -240,9 +253,11 @@ def register_commands(parser: settngs.Manager) -> None:
)
def register_commandline_settings(parser: settngs.Manager) -> None:
def register_commandline_settings(parser: settngs.Manager, enable_quick_tag: bool) -> None:
parser.add_group("Commands", register_commands, True)
parser.add_persistent_group("Runtime Options", register_runtime)
if enable_quick_tag:
parser.add_group("Quick Tag", quick_tag.settings)
def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs.Manager) -> settngs.Config[ct_ns]:
@ -272,12 +287,11 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
if config[0].Runtime_Options__tags_read and not config[0].Runtime_Options__tags_write:
config[0].Runtime_Options__tags_write = config[0].Runtime_Options__tags_read
if (
config[0].Commands__command not in (Action.save_config, Action.list_plugins)
and config[0].Runtime_Options__no_gui
and not config[0].Runtime_Options__files
):
parser.exit(message="Command requires at least one filename!\n", status=1)
if config[0].Runtime_Options__no_gui and not config[0].Runtime_Options__files:
if config[0].Commands__command == Action.print and not config[0].Auto_Tag__metadata.is_empty:
... # allow printing the metadata provided on the commandline
elif config[0].Commands__command not in (Action.save_config, Action.list_plugins):
parser.exit(message="Command requires at least one filename!\n", status=1)
if config[0].Commands__command == Action.delete and not config[0].Runtime_Options__tags_write:
parser.exit(message="Please specify the tags to delete with --tags-write\n", status=1)

View File

@ -27,8 +27,8 @@ def general(parser: settngs.Manager) -> None:
def internal(parser: settngs.Manager) -> None:
# automatic settings
parser.add_setting("install_id", default=uuid.uuid4().hex, cmdline=False)
parser.add_setting("write_tags", default=["cbi"], cmdline=False)
parser.add_setting("read_tags", default=["cbi"], cmdline=False)
parser.add_setting("write_tags", default=["cr"], cmdline=False)
parser.add_setting("read_tags", default=["cr"], cmdline=False)
parser.add_setting("last_opened_folder", default="", cmdline=False)
parser.add_setting("window_width", default=0, cmdline=False)
parser.add_setting("window_height", default=0, cmdline=False)
@ -79,7 +79,7 @@ def identifier(parser: settngs.Manager) -> None:
def dialog(parser: settngs.Manager) -> None:
parser.add_setting("show_disclaimer", default=True, cmdline=False)
parser.add_setting("dont_notify_about_this_version", default="", cmdline=False)
parser.add_setting("ask_about_usage_stats", default=True, cmdline=False)
parser.add_setting("notify_plugin_changes", default=True, cmdline=False)
def filename(parser: settngs.Manager) -> None:
@ -356,7 +356,7 @@ def migrate_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
elif isinstance(write_Tags, str):
config[0].internal__write_tags = [write_Tags]
else:
config[0].internal__write_tags = ["cbi"]
config[0].internal__write_tags = ["cr"]
read_tags = config[0].internal__read_tags
if not isinstance(read_tags, list):
@ -365,7 +365,7 @@ def migrate_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
elif isinstance(read_tags, str):
config[0].internal__read_tags = [read_tags]
else:
config[0].internal__read_tags = ["cbi"]
config[0].internal__read_tags = ["cr"]
return config

View File

@ -4,18 +4,50 @@
from __future__ import annotations
import configparser
import importlib.metadata
import importlib.util
import logging
import pathlib
import platform
import re
from collections.abc import Generator
from typing import Any, NamedTuple
import sys
from collections.abc import Generator, Iterable
from typing import Any, NamedTuple, TypeVar
if sys.version_info < (3, 10):
import importlib_metadata
else:
import importlib.metadata as importlib_metadata
logger = logging.getLogger(__name__)
NORMALIZE_PACKAGE_NAME_RE = re.compile(r"[-_.]+")
PLUGIN_GROUPS = frozenset(("comictagger.talker", "comicapi.archiver", "comicapi.tags"))
icu_available = importlib.util.find_spec("icu") is not None
def _custom_key(tup: Any) -> Any:
import natsort
lst = []
for x in natsort.os_sort_keygen()(tup):
ret = x
if len(x) > 1 and isinstance(x[1], int) and isinstance(x[0], str) and x[0] == "":
ret = ("a", *x[1:])
lst.append(ret)
return tuple(lst)
T = TypeVar("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(lst, key=key)
class FailedToLoadPlugin(Exception):
@ -47,9 +79,12 @@ class Plugin(NamedTuple):
package: str
version: str
entry_point: importlib.metadata.EntryPoint
entry_point: importlib_metadata.EntryPoint
path: pathlib.Path
def load(self) -> LoadedPlugin:
return LoadedPlugin(self, self.entry_point.load())
class LoadedPlugin(NamedTuple):
"""Represents a plugin after being imported."""
@ -71,11 +106,11 @@ class LoadedPlugin(NamedTuple):
class Plugins(NamedTuple):
"""Classified plugins."""
archivers: list[Plugin]
tags: list[Plugin]
talkers: list[Plugin]
archivers: list[LoadedPlugin]
tags: list[LoadedPlugin]
talkers: list[LoadedPlugin]
def all_plugins(self) -> Generator[Plugin]:
def all_plugins(self) -> Generator[LoadedPlugin]:
"""Return an iterator over all :class:`LoadedPlugin`s."""
yield from self.archivers
yield from self.tags
@ -83,65 +118,62 @@ class Plugins(NamedTuple):
def versions_str(self) -> str:
"""Return a user-displayed list of plugin versions."""
return ", ".join(sorted({f"{plugin.package}: {plugin.version}" for plugin in self.all_plugins()}))
return ", ".join(sorted({f"{plugin.plugin.package}: {plugin.plugin.version}" for plugin in self.all_plugins()}))
def _find_local_plugins(plugin_path: pathlib.Path) -> Generator[Plugin]:
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(plugin_path / "setup.cfg")
for group in PLUGIN_GROUPS:
for plugin_s in cfg.get("options.entry_points", group, fallback="").splitlines():
if not plugin_s:
continue
name, _, entry_str = plugin_s.partition("=")
name, entry_str = name.strip(), entry_str.strip()
ep = importlib.metadata.EntryPoint(name, entry_str, group)
yield Plugin(plugin_path.name, cfg.get("metadata", "version", fallback="0.0.1"), ep, plugin_path)
def _check_required_plugins(plugins: list[Plugin], expected: frozenset[str]) -> None:
plugin_names = {normalize_pypi_name(plugin.package) for plugin in plugins}
expected_names = {normalize_pypi_name(name) for name in expected}
missing_plugins = expected_names - plugin_names
if missing_plugins:
raise Exception(
"required plugins were not installed!\n"
+ f"- installed: {', '.join(sorted(plugin_names))}\n"
+ f"- expected: {', '.join(sorted(expected_names))}\n"
+ f"- missing: {', '.join(sorted(missing_plugins))}"
)
logger.debug("Checking for distributions in %s", plugin_path)
for dist in importlib_metadata.distributions(path=[str(plugin_path)]):
logger.debug("found distribution %s", dist.name)
eps = dist.entry_points
for group in PLUGIN_GROUPS:
for ep in eps.select(group=group):
logger.debug("found EntryPoint group %s %s=%s", group, ep.name, ep.value)
yield Plugin(plugin_path.name, dist.version, ep, plugin_path)
def find_plugins(plugin_folder: pathlib.Path) -> Plugins:
"""Discovers all plugins (but does not load them)."""
ret: list[Plugin] = []
for plugin_path in plugin_folder.glob("*/setup.cfg"):
try:
ret.extend(_find_local_plugins(plugin_path.parent))
except Exception as err:
FailedToLoadPlugin(plugin_path.parent.name, err)
ret: list[LoadedPlugin] = []
if not plugin_folder.is_dir():
return _classify_plugins(ret)
# for determinism, sort the list
ret.sort()
zips = [x for x in plugin_folder.iterdir() if x.is_file() and x.suffix in (".zip", ".whl")]
for plugin_path in os_sorted(zips):
logger.debug("looking for plugins in %s", plugin_path)
try:
sys.path.append(str(plugin_path))
for plugin in _find_local_plugins(plugin_path):
logger.debug("Attempting to load %s from %s", plugin.entry_point.name, plugin.path)
ret.append(plugin.load())
except Exception as err:
logger.exception(FailedToLoadPlugin(plugin_path.name, err))
finally:
sys.path.remove(str(plugin_path))
for mod in list(sys.modules.values()):
if (
mod is not None
and hasattr(mod, "__spec__")
and mod.__spec__
and str(plugin_path) in (mod.__spec__.origin or "")
):
sys.modules.pop(mod.__name__)
return _classify_plugins(ret)
def _classify_plugins(plugins: list[Plugin]) -> Plugins:
def _classify_plugins(plugins: list[LoadedPlugin]) -> Plugins:
archivers = []
tags = []
talkers = []
for p in plugins:
if p.entry_point.group == "comictagger.talker":
if p.plugin.entry_point.group == "comictagger.talker":
talkers.append(p)
elif p.entry_point.group == "comicapi.tags":
elif p.plugin.entry_point.group == "comicapi.tags":
tags.append(p)
elif p.entry_point.group == "comicapi.archiver":
elif p.plugin.entry_point.group == "comicapi.archiver":
archivers.append(p)
else:
logger.warning(NotImplementedError(f"what plugin type? {p}"))

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import typing
import settngs
import urllib3.util.url
import comicapi.genericmetadata
import comicapi.merge
@ -19,6 +20,7 @@ class SettngsNS(settngs.TypedNS):
Runtime_Options__config: comictaggerlib.ctsettings.types.ComicTaggerPaths
Runtime_Options__verbose: int
Runtime_Options__enable_quick_tag: bool
Runtime_Options__quiet: bool
Runtime_Options__json: bool
Runtime_Options__raw: bool
@ -37,6 +39,13 @@ class SettngsNS(settngs.TypedNS):
Runtime_Options__skip_existing_tags: bool
Runtime_Options__files: list[str]
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
internal__install_id: str
internal__write_tags: list[str]
internal__read_tags: list[str]
@ -114,13 +123,14 @@ class SettngsNS(settngs.TypedNS):
Dialog_Flags__show_disclaimer: bool
Dialog_Flags__dont_notify_about_this_version: str
Dialog_Flags__ask_about_usage_stats: bool
Dialog_Flags__notify_plugin_changes: bool
Archive__rar: str
Source_comicvine__comicvine_key: str | None
Source_comicvine__comicvine_url: str | None
Source_comicvine__cv_use_series_start_as_volume: bool
Source_comicvine__comicvine_custom_parameters: str | None
class Commands(typing.TypedDict):
@ -132,6 +142,7 @@ class Commands(typing.TypedDict):
class Runtime_Options(typing.TypedDict):
config: comictaggerlib.ctsettings.types.ComicTaggerPaths
verbose: int
enable_quick_tag: bool
quiet: bool
json: bool
raw: bool
@ -151,6 +162,15 @@ class Runtime_Options(typing.TypedDict):
files: list[str]
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
class internal(typing.TypedDict):
install_id: str
write_tags: list[str]
@ -245,7 +265,7 @@ class General(typing.TypedDict):
class Dialog_Flags(typing.TypedDict):
show_disclaimer: bool
dont_notify_about_this_version: str
ask_about_usage_stats: bool
notify_plugin_changes: bool
class Archive(typing.TypedDict):
@ -256,6 +276,7 @@ class Source_comicvine(typing.TypedDict):
comicvine_key: str | None
comicvine_url: str | None
cv_use_series_start_as_volume: bool
comicvine_custom_parameters: str | None
SettngsDict = typing.TypedDict(
@ -263,6 +284,7 @@ SettngsDict = typing.TypedDict(
{
"Commands": Commands,
"Runtime Options": Runtime_Options,
"Quick Tag": Quick_Tag,
"internal": internal,
"Issue Identifier": Issue_Identifier,
"Filename Parsing": Filename_Parsing,

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import logging
import pathlib
import sys
import types
@ -15,6 +16,8 @@ from comicapi import utils
from comicapi.comicarchive import tags
from comicapi.genericmetadata import REMOVE, GenericMetadata
logger = logging.getLogger(__name__)
if sys.version_info < (3, 10):
@typing.no_type_check
@ -115,8 +118,7 @@ class ComicTaggerPaths(AppDirs):
@property
def user_cache_dir(self) -> pathlib.Path:
if self.path:
path = self.path / "cache"
return path
return self.path / "cache"
return pathlib.Path(super().user_cache_dir)
@property
@ -128,16 +130,14 @@ class ComicTaggerPaths(AppDirs):
@property
def user_log_dir(self) -> pathlib.Path:
if self.path:
path = self.path / "log"
return path
return self.path / "log"
return pathlib.Path(super().user_log_dir)
@property
def user_plugin_dir(self) -> pathlib.Path:
if self.path:
path = self.path / "plugins"
return path
return pathlib.Path(super().user_config_dir)
return self.path / "plugins"
return pathlib.Path(super().user_config_dir) / "plugins"
@property
def site_data_dir(self) -> pathlib.Path:
@ -198,44 +198,48 @@ def parse_metadata_from_string(mdstr: str) -> GenericMetadata:
md = GenericMetadata()
if not mdstr:
return md
if mdstr[0] == "@":
p = pathlib.Path(mdstr[1:])
if not p.is_file():
raise argparse.ArgumentTypeError("Invalid filepath")
mdstr = p.read_text()
if mdstr[0] != "{":
mdstr = "{" + mdstr + "}"
try:
if not mdstr:
return md
if mdstr[0] == "@":
p = pathlib.Path(mdstr[1:])
if not p.is_file():
raise argparse.ArgumentTypeError("Invalid filepath")
mdstr = p.read_text()
if mdstr[0] != "{":
mdstr = "{" + mdstr + "}"
md_dict = yaml.safe_load(mdstr)
md_dict = yaml.safe_load(mdstr)
empty = True
# Map the dict to the metadata object
for key, value in md_dict.items():
if hasattr(md, key):
t = get_type(key)
if value is None:
value = REMOVE
elif isinstance(t, tuple):
if value == "":
value = t[0]()
empty = True
# Map the dict to the metadata object
for key, value in md_dict.items():
if hasattr(md, key):
t = get_type(key)
if value is None:
value = REMOVE
elif isinstance(t, tuple):
if value == "":
value = t[0]()
else:
if isinstance(value, str):
value = [value]
if not isinstance(value, Collection):
raise argparse.ArgumentTypeError(f"Invalid syntax for tag '{key}'")
values = list(value)
for idx, v in enumerate(values):
if not isinstance(v, t[1]):
values[idx] = convert_value(t[1], v)
value = t[0](values)
else:
if isinstance(value, str):
value = [value]
if not isinstance(value, Collection):
raise argparse.ArgumentTypeError(f"Invalid syntax for tag '{key}'")
values = list(value)
for idx, v in enumerate(values):
if not isinstance(v, t[1]):
values[idx] = convert_value(t[1], v)
value = t[0](values)
else:
value = convert_value(t, value)
value = convert_value(t, value)
empty = False
setattr(md, key, value)
else:
raise argparse.ArgumentTypeError(f"'{key}' is not a valid tag name")
md.is_empty = empty
empty = False
setattr(md, key, value)
else:
raise argparse.ArgumentTypeError(f"'{key}' is not a valid tag name")
md.is_empty = empty
except Exception as e:
logger.exception("Unable to read metadata from the commandline '%s'", mdstr)
raise Exception("Unable to read metadata from the commandline") from e
return md

View File

@ -73,6 +73,8 @@ class MetadataFormatter(string.Formatter):
return cast(str, super().format_field(value, format_spec))
def convert_field(self, value: Any, conversion: str | None) -> str:
if value is None:
return ""
if isinstance(value, Iterable) and not isinstance(value, (str, tuple)):
if conversion == "C":
if isinstance(value, Sized):
@ -182,8 +184,11 @@ class MetadataFormatter(string.Formatter):
# given the field_name, find the object it references
# and the argument it came from
obj, arg_used = self.get_field(field_name, args, kwargs)
used_args.add(arg_used)
try:
obj, arg_used = self.get_field(field_name, args, kwargs)
used_args.add(arg_used)
except Exception:
obj = None
obj = self.none_replacement(obj, replacement, r)
# do any conversion on the resulting object

View File

@ -173,15 +173,20 @@ class FileSelectionList(QtWidgets.QWidget):
self.listCleared.emit()
def add_path_list(self, pathlist: list[str]) -> None:
if not pathlist:
return
filelist = utils.get_recursive_filelist(pathlist)
# we now have a list of files to add
# Prog dialog on Linux flakes out for small range, so scale up
progdialog = QtWidgets.QProgressDialog("", "Cancel", 0, len(filelist), parent=self)
progdialog.setWindowTitle("Adding Files")
progdialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
progdialog.setMinimumDuration(300)
center_window_on_parent(progdialog)
progdialog = None
if len(filelist) < 3:
# Prog dialog on Linux flakes out for small range, so scale up
progdialog = QtWidgets.QProgressDialog("", "Cancel", 0, len(filelist), parent=self)
progdialog.setWindowTitle("Adding Files")
progdialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
progdialog.setMinimumDuration(300)
progdialog.show()
center_window_on_parent(progdialog)
QtCore.QCoreApplication.processEvents()
first_added = None
@ -189,10 +194,11 @@ class FileSelectionList(QtWidgets.QWidget):
self.twList.setSortingEnabled(False)
for idx, f in enumerate(filelist):
QtCore.QCoreApplication.processEvents()
if progdialog.wasCanceled():
break
progdialog.setValue(idx + 1)
progdialog.setLabelText(f)
if progdialog is not None:
if progdialog.wasCanceled():
break
progdialog.setValue(idx + 1)
progdialog.setLabelText(f)
QtCore.QCoreApplication.processEvents()
row = self.add_path_item(f)
if row is not None:
@ -201,7 +207,8 @@ class FileSelectionList(QtWidgets.QWidget):
if first_added is None and row != -1:
first_added = row
progdialog.hide()
if progdialog is not None:
progdialog.hide()
QtCore.QCoreApplication.processEvents()
if first_added is not None:

View File

@ -73,24 +73,23 @@ class ImageHasher:
return result
def average_hash2(self) -> None:
"""
# Got this one from somewhere on the net. Not a clue how the 'convolve2d' works!
def difference_hash(self) -> int:
try:
image = self.image.resize((self.width + 1, self.height), Image.Resampling.LANCZOS).convert("L")
except Exception:
logger.exception("difference_hash error")
return 0
from numpy import array
from scipy.signal import convolve2d
pixels = list(image.getdata())
diff = ""
for y in range(self.height):
for x in range(self.width):
idx = x + (self.width + 1 * y)
diff += str(int(pixels[idx] < pixels[idx + 1]))
im = self.image.resize((self.width, self.height), Image.ANTIALIAS).convert('L')
result = int(diff, 2)
in_data = array((im.getdata())).reshape(self.width, self.height)
filt = array([[0,1,0],[1,-4,1],[0,1,0]])
filt_data = convolve2d(in_data,filt,mode='same',boundary='symm').flatten()
result = reduce(lambda x, (y, z): x | (z << y),
enumerate(map(lambda i: 0 if i < 0 else 1, filt_data)),
0)
return result
"""
def p_hash(self) -> int:
"""

View File

@ -28,6 +28,7 @@ def setup_logging(verbose: int, log_dir: pathlib.Path) -> None:
logging.getLogger("comicapi").setLevel(logging.DEBUG)
logging.getLogger("comictaggerlib").setLevel(logging.DEBUG)
logging.getLogger("comictalker").setLevel(logging.DEBUG)
logging.getLogger("pyrate_limiter").setLevel(logging.DEBUG)
log_file = log_dir / "ComicTagger.log"
log_dir.mkdir(parents=True, exist_ok=True)

View File

@ -44,7 +44,6 @@ if sys.version_info < (3, 10):
import importlib_metadata
else:
import importlib.metadata as importlib_metadata
logger = logging.getLogger("comictagger")
@ -89,9 +88,9 @@ def configure_locale() -> None:
os.environ["LANG"] = f"{code}.utf-8"
locale.setlocale(locale.LC_ALL, "")
sys.stdout.reconfigure(encoding=sys.getdefaultencoding()) # type: ignore[attr-defined]
sys.stderr.reconfigure(encoding=sys.getdefaultencoding()) # type: ignore[attr-defined]
sys.stdin.reconfigure(encoding=sys.getdefaultencoding()) # type: ignore[attr-defined]
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]
def update_publishers(config: settngs.Config[ct_ns]) -> None:
@ -117,26 +116,20 @@ class App:
conf = self.initialize()
self.initialize_dirs(conf.config)
self.load_plugins(conf)
self.register_settings()
self.register_settings(conf.enable_quick_tag)
self.config = self.parse_settings(conf.config)
self.main()
def load_plugins(self, opts: argparse.Namespace) -> None:
local_plugins = plugin_finder.find_plugins(opts.config.user_plugin_dir)
self._extend_plugin_paths(local_plugins)
comicapi.comicarchive.load_archive_plugins(local_plugins=[p.entry_point for p in local_plugins.archivers])
comicapi.comicarchive.load_tag_plugins(
version=version, local_plugins=[p.entry_point for p in local_plugins.tags]
)
comicapi.comicarchive.load_archive_plugins(local_plugins=[p.obj for p in local_plugins.archivers])
comicapi.comicarchive.load_tag_plugins(version=version, local_plugins=[p.obj for p in local_plugins.tags])
self.talkers = comictalker.get_talkers(
version, opts.config.user_cache_dir, local_plugins=[p.entry_point for p in local_plugins.talkers]
version, opts.config.user_cache_dir, local_plugins=[p.obj for p in local_plugins.talkers]
)
def _extend_plugin_paths(self, plugins: plugin_finder.Plugins) -> None:
sys.path.extend(str(p.path.absolute()) for p in plugins.all_plugins())
def list_plugins(
self,
talkers: Collection[comictalker.ComicTalker],
@ -215,13 +208,13 @@ class App:
setup_logging(conf.verbose, conf.config.user_log_dir)
return conf
def register_settings(self) -> None:
def register_settings(self, enable_quick_tag: bool) -> None:
self.manager = settngs.Manager(
description="A utility for reading and writing metadata to comic archives.\n\n\n"
+ "If no options are given, %(prog)s will run in windowed mode.\nPlease keep the '-v' option separated '-so -v' not '-sov'",
epilog="For more help visit the wiki at: https://github.com/comictagger/comictagger/wiki",
)
ctsettings.register_commandline_settings(self.manager)
ctsettings.register_commandline_settings(self.manager, enable_quick_tag)
ctsettings.register_file_settings(self.manager)
ctsettings.register_plugin_settings(self.manager, getattr(self, "talkers", {}))

View File

@ -411,3 +411,5 @@ class PageListEditor(QtWidgets.QWidget):
for md_field, widget in self.md_attributes.items():
enable_widget(widget, md_field in enabled_widgets)
self.listWidget.setDragEnabled(not ("pages.image_index" not in enabled_widgets and "pages" in enabled_widgets))

391
comictaggerlib/quick_tag.py Normal file
View File

@ -0,0 +1,391 @@
from __future__ import annotations
import argparse
import itertools
import logging
from enum import auto
from io import BytesIO
from typing import Callable, TypedDict, cast
from urllib.parse import urljoin
import requests
import settngs
from comicapi import comicarchive, utils
from comicapi.genericmetadata import GenericMetadata
from comicapi.issuestring import IssueString
from comictaggerlib.ctsettings.settngs_namespace import SettngsNS
from comictaggerlib.imagehasher import ImageHasher
from comictalker import ComicTalker
logger = logging.getLogger(__name__)
__version__ = "0.1"
class HashType(utils.StrEnum):
AHASH = auto()
DHASH = auto()
PHASH = auto()
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: str
class Result(TypedDict):
# Mapping of domains (eg comicvine.gamespot.com) to IDs
IDs: dict[str, list[str]]
Distance: int
Hash: Hash
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()])
if not result:
raise argparse.ArgumentTypeError(f"invalid choice: {types} (choose from {choices.upper()})")
return result
def settings(manager: settngs.Manager) -> None:
manager.add_setting(
"--url",
"-u",
default="https://comic-hasher.narnian.us",
type=utils.parse_url,
help="Website to use for searching cover hashes",
)
manager.add_setting(
"--max",
default=8,
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 worse matches if better matches are found",
)
manager.add_setting(
"--hash",
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 we have exact matches",
)
class QuickTag:
def __init__(
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
def id_comic(
self,
ca: comicarchive.ComicArchive,
tags: GenericMetadata,
simple: bool,
hashes: set[HashType],
exact_only: bool,
interactive: bool,
aggressive_filtering: bool,
max_hamming_distance: int,
) -> GenericMetadata | None:
if not ca.seems_to_be_a_comic_archive():
raise Exception(f"{ca.path} is not an archive")
from PIL import Image
cover_index = tags.get_cover_page_index_list()[0]
cover_image = Image.open(BytesIO(ca.get_page(cover_index)))
self.output(f"Tagging: {ca.path}")
self.output("hashing cover")
phash = dhash = ahash = ""
hasher = ImageHasher(image=cover_image)
if HashType.AHASH in hashes:
ahash = hex(hasher.average_hash())[2:]
if HashType.DHASH in hashes:
dhash = hex(hasher.difference_hash())[2:]
if HashType.PHASH in hashes:
phash = hex(hasher.p_hash())[2:]
logger.info(f"Searching with {ahash=}, {dhash=}, {phash=}")
self.output("Searching hashes")
results = self.SearchHashes(simple, max_hamming_distance, ahash, dhash, phash, exact_only)
logger.debug(f"{results=}")
if simple:
filtered_simple_results = self.filter_simple_results(
cast(list[SimpleResult], results), interactive, aggressive_filtering
)
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)
return self.talker.fetch_comic_data(issue_id=chosen_result.issue_id)
def SearchHashes(
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,
"phash": phash,
"exactOnly": str(exact_only),
},
)
if resp.status_code != 200:
try:
text = resp.json()["msg"]
except Exception:
text = resp.text
if text == "No hashes found":
return []
logger.error("message from server: %s", text)
raise Exception(f"Failed to retrieve results from the server: {text}")
return resp.json()["results"]
def get_mds(self, results: list[SimpleResult] | list[Result]) -> list[GenericMetadata]:
md_results: list[GenericMetadata] = []
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 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))
return md_results
def get_simple_results(self, results: list[SimpleResult]) -> list[tuple[int, GenericMetadata]]:
md_results = []
mds = self.get_mds(results)
# 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 get_results(self, results: list[Result]) -> list[tuple[int, Hash, GenericMetadata]]:
md_results = []
mds = self.get_mds(results)
# 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
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 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
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]
# 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
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)
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))
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 GenericMetadata()
md_results.sort(key=lambda r: (r[0], len(r[1].publisher or "")))
for counter, r in enumerate(md_results, 1):
self.output(
" {:2}. score: {} [{:15}] ({:02}/{:04}) - {} #{} - {}".format(
counter,
r[0],
r[1].publisher,
r[1].month or 0,
r[1].year or 0,
r[1].series,
r[1].issue,
r[1].title,
),
)
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":
logger.warning("User quit without saving metadata")
return GenericMetadata()
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

@ -22,6 +22,7 @@ import os
import pathlib
import platform
import shutil
import urllib.parse
from typing import Any, cast
import settngs
@ -104,7 +105,7 @@ Accepts the following variables:
{characters} (string)
{teams} (string)
{locations} (string)
{credits} (list of dict({'role': string, 'person': string, 'primary': boolean}))
{credits} (list of dict({'role': string, 'person': string, 'primary': boolean, 'language': str}))
{writer} (string)
{penciller} (string)
{inker} (string)
@ -205,6 +206,20 @@ class SettingsWindow(QtWidgets.QDialog):
self.filename_parser_test()
self.update_rar_path()
dirs = self.config.values.Runtime_Options__config
self.lbl_config_dir.setText(
f"Config Dir: <a href='file://{urllib.parse.quote(str(dirs.user_config_dir))}'>{dirs.user_config_dir}</a>"
)
self.lbl_cache_dir.setText(
f"Cache Dir: <a href='file://{urllib.parse.quote(str(dirs.user_cache_dir))}'>{dirs.user_cache_dir}</a>"
)
self.lbl_log_dir.setText(
f"Log Dir: <a href='file://{urllib.parse.quote(str(dirs.user_log_dir))}'>{dirs.user_log_dir}</a>"
)
self.lbl_plugin_dir.setText(
f"Plugin Dir: <a href='file://{urllib.parse.quote(str(dirs.user_plugin_dir))}'>{dirs.user_plugin_dir}</a>"
)
# Set General as start tab
self.tabWidget.setCurrentIndex(0)

View File

@ -25,6 +25,7 @@ import platform
import re
import sys
import webbrowser
from collections.abc import Sequence
from typing import Any, Callable, cast
import natsort
@ -37,7 +38,7 @@ import comictaggerlib.ui
from comicapi import utils
from comicapi.comicarchive import ComicArchive, tags
from comicapi.filenameparser import FileNameParser
from comicapi.genericmetadata import GenericMetadata
from comicapi.genericmetadata import Credit, GenericMetadata
from comicapi.issuestring import IssueString
from comictaggerlib import ctsettings, ctversion
from comictaggerlib.applicationlogwindow import ApplicationLogWindow, QTextEditLogger
@ -125,7 +126,8 @@ class TaggerWindow(QtWidgets.QMainWindow):
"teams": self.teTeams,
"locations": self.teLocations,
"credits": (self.twCredits, self.btnAddCredit, self.btnEditCredit, self.btnRemoveCredit),
"credits.person": 2,
"credits.person": 3,
"credits.language": 2,
"credits.role": 1,
"credits.primary": 0,
"tags": self.teTags,
@ -220,16 +222,16 @@ class TaggerWindow(QtWidgets.QMainWindow):
if config[0].Runtime_Options__tags_read:
config[0].internal__read_tags = config[0].Runtime_Options__tags_read
for tag_id in config[0].internal__write_tags:
if tag_id not in tags:
for tag_id in config[0].internal__write_tags.copy():
if tag_id not in self.enabled_tags():
config[0].internal__write_tags.remove(tag_id)
for tag_id in config[0].internal__read_tags:
if tag_id not in tags:
for tag_id in config[0].internal__read_tags.copy():
if tag_id not in self.enabled_tags():
config[0].internal__read_tags.remove(tag_id)
self.selected_write_tags: list[str] = config[0].internal__write_tags
self.selected_read_tags: list[str] = config[0].internal__read_tags
self.selected_write_tags: list[str] = config[0].internal__write_tags or [self.enabled_tags()[0]]
self.selected_read_tags: list[str] = config[0].internal__read_tags or [self.enabled_tags()[0]]
self.setAcceptDrops(True)
self.view_tag_actions, self.remove_tag_actions = self.tag_actions()
@ -333,10 +335,26 @@ class TaggerWindow(QtWidgets.QMainWindow):
""",
)
self.config[0].Dialog_Flags__show_disclaimer = not checked
if self.config[0].Dialog_Flags__notify_plugin_changes and getattr(sys, "frozen", False):
checked = OptionalMessageDialog.msg(
self,
"Plugins Have moved!",
f"""
Due to techinical issues the Metron and GCD plugins are no longer bundled in ComicTagger!<br/><br/>
You will need to download the .zip or .whl from the GitHub release page to:<br/><pre>{str(self.config[0].Runtime_Options__config.user_plugin_dir)}</pre><br/>
GCD: <a href="https://github.com/comictagger/gcd_talker/releases">https://github.com/comictagger/gcd_talker/releases</a><br/>
Metron: <a href="https://github.com/comictagger/metron_talker/releases">https://github.com/comictagger/metron_talker/releases</a><br/><br/>
For more information on installing plugins see the wiki page:<br/><a href="https://github.com/comictagger/comictagger/wiki/Installing-plugins">https://github.com/comictagger/comictagger/wiki/Installing-plugins</a>
""",
)
self.config[0].Dialog_Flags__notify_plugin_changes = not checked
if self.config[0].General__check_for_new_version:
self.check_latest_version_online()
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] = {}
@ -474,14 +492,14 @@ class TaggerWindow(QtWidgets.QMainWindow):
def repackage_archive(self) -> None:
ca_list = self.fileSelectionList.get_selected_archive_list()
non_zip_count = 0
zip_list = []
to_zip = []
largest_page_size = 0
for ca in ca_list:
largest_page_size = max(largest_page_size, len(ca.get_page_name_list()))
if not ca.is_zip():
non_zip_count += 1
else:
zip_list.append(ca)
to_zip.append(ca)
if non_zip_count == 0:
if not to_zip:
QtWidgets.QMessageBox.information(
self, self.tr("Export as Zip Archive"), self.tr("Only ZIP archives are selected!")
)
@ -494,11 +512,11 @@ class TaggerWindow(QtWidgets.QMainWindow):
):
return
if non_zip_count != 0:
if to_zip:
EW = ExportWindow(
self,
(
f"You have selected {non_zip_count} archive(s) to export to Zip format. "
f"You have selected {len(to_zip)} archive(s) to export to Zip format. "
""" New archives will be created in the same folder as the original.
Please choose config below, and select OK.
@ -510,11 +528,13 @@ class TaggerWindow(QtWidgets.QMainWindow):
if not EW.exec():
return
prog_dialog = QtWidgets.QProgressDialog("", "Cancel", 0, non_zip_count, self)
prog_dialog.setWindowTitle("Exporting as ZIP")
prog_dialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
prog_dialog.setMinimumDuration(300)
center_window_on_parent(prog_dialog)
prog_dialog = None
if len(to_zip) > 3 or largest_page_size > 24:
prog_dialog = QtWidgets.QProgressDialog("", "Cancel", 0, non_zip_count, self)
prog_dialog.setWindowTitle("Exporting as ZIP")
prog_dialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
prog_dialog.setMinimumDuration(300)
center_window_on_parent(prog_dialog)
QtCore.QCoreApplication.processEvents()
new_archives_to_add = []
@ -522,13 +542,16 @@ class TaggerWindow(QtWidgets.QMainWindow):
skipped_list = []
failed_list = []
success_count = 0
logger.debug("Exporting %d comics to zip", len(to_zip))
for prog_idx, ca in enumerate(zip_list, 1):
for prog_idx, ca in enumerate(to_zip, 1):
logger.debug("Exporting comic %d: %s", prog_idx, ca.path)
QtCore.QCoreApplication.processEvents()
if prog_dialog.wasCanceled():
break
prog_dialog.setValue(prog_idx)
prog_dialog.setLabelText(str(ca.path))
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")
@ -542,6 +565,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
export_name = utils.unique_file(export_name)
if export:
logger.debug("Exporting %s to %s", ca.path, export_name)
if ca.export_as_zip(export_name):
success_count += 1
if EW.addToList:
@ -556,9 +580,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
if export_name.exists():
export_name.unlink(missing_ok=True)
prog_dialog.hide()
if prog_dialog is not None:
prog_dialog.hide()
QtCore.QCoreApplication.processEvents()
self.fileSelectionList.add_path_list(new_archives_to_add)
self.fileSelectionList.remove_archive_list(archives_to_remove)
summary = f"Successfully created {success_count} Zip archive(s)."
@ -580,6 +604,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
dlg.set_text(summary)
dlg.setWindowTitle("Archive Export to Zip Summary")
dlg.exec()
self.fileSelectionList.add_path_list(new_archives_to_add)
def about_app(self) -> None:
website = "https://github.com/comictagger/comictagger"
@ -847,37 +872,48 @@ class TaggerWindow(QtWidgets.QMainWindow):
for row, credit in enumerate(md.credits):
# if the role-person pair already exists, just skip adding it to the list
if self.is_dupe_credit(credit.role.title(), credit.person):
if self.is_dupe_credit(None, credit.role.title(), credit.person):
continue
self.add_new_credit_entry(row, credit.role.title(), credit.person, credit.primary)
self.add_new_credit_entry(row, credit)
self.twCredits.setSortingEnabled(True)
self.update_credit_colors()
def add_new_credit_entry(self, row: int, role: str, name: str, primary_flag: bool = False) -> None:
def add_new_credit_entry(self, row: int, credit: Credit) -> None:
self.twCredits.insertRow(row)
item_text = role
item = QtWidgets.QTableWidgetItem(item_text)
item = QtWidgets.QTableWidgetItem(credit.role)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
self.twCredits.setItem(row, 1, item)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, credit.role)
self.twCredits.setItem(row, self.md_attributes["credits.role"], item)
item_text = name
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
language = utils.get_language_from_iso(credit.language) or credit.language
item = QtWidgets.QTableWidgetItem(language)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, credit.language)
item.setData(QtCore.Qt.ItemDataRole.UserRole, credit.language)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twCredits.setItem(row, 2, item)
self.twCredits.setItem(row, self.md_attributes["credits.language"], item)
item = QtWidgets.QTableWidgetItem(credit.person)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, credit.person)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twCredits.setItem(row, self.md_attributes["credits.person"], item)
item = QtWidgets.QTableWidgetItem("")
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twCredits.setItem(row, 0, item)
self.update_credit_primary_flag(row, primary_flag)
self.twCredits.setItem(row, self.md_attributes["credits.primary"], item)
self.update_credit_primary_flag(row, credit.primary)
def is_dupe_credit(self, role: str, name: str) -> bool:
def is_dupe_credit(self, row: int | None, role: str, name: str) -> bool:
for r in range(self.twCredits.rowCount()):
if self.twCredits.item(r, 1).text() == role and self.twCredits.item(r, 2).text() == name:
if r == row:
continue
if (
self.twCredits.item(r, self.md_attributes["credits.role"]).text() == role
and self.twCredits.item(r, self.md_attributes["credits.person"]).text() == name
):
return True
return False
@ -932,14 +968,29 @@ class TaggerWindow(QtWidgets.QMainWindow):
# get the credits from the table
md.credits = []
for row in range(self.twCredits.rowCount()):
role = self.twCredits.item(row, 1).text()
name = self.twCredits.item(row, 2).text()
primary_flag = self.twCredits.item(row, 0).text() != ""
md.add_credit(name, role, bool(primary_flag))
for row in range(self.twCredits.rowCount()):
role = self.twCredits.item(row, self.md_attributes["credits.role"]).text()
lang = (
self.twCredits.item(row, self.md_attributes["credits.language"]).data(QtCore.Qt.ItemDataRole.UserRole)
or self.twCredits.item(row, self.md_attributes["credits.language"]).text()
)
name = self.twCredits.item(row, self.md_attributes["credits.person"]).text()
primary_flag = self.twCredits.item(row, self.md_attributes["credits.primary"]).text() != ""
md.add_credit(name, role, bool(primary_flag), lang)
md.pages = self.page_list_editor.get_page_list()
# Preserve hidden md values
md.data_origin = self.metadata.data_origin
md.issue_id = self.metadata.issue_id
md.series_id = self.metadata.series_id
md.price = self.metadata.price
md.identifier = self.metadata.identifier
md.rights = self.metadata.rights
self.metadata = md
def use_filename(self) -> None:
@ -1141,9 +1192,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
if self.comic_archive is not None:
self.load_archive(self.comic_archive)
else:
self.cbSelectedReadTags.itemChanged.disconnect()
self.cbSelectedReadTags.dropdownClosed.disconnect()
self.adjust_tags_combo()
self.cbSelectedReadTags.itemChanged.connect(self.select_read_tags)
self.cbSelectedReadTags.dropdownClosed.connect(self.select_read_tags)
def select_write_tags(self) -> None:
self.selected_write_tags = self.cbSelectedWriteTags.currentData()
@ -1200,52 +1251,51 @@ class TaggerWindow(QtWidgets.QMainWindow):
def update_credit_primary_flag(self, row: int, primary: bool) -> None:
# if we're clearing a flag do it and quit
if not primary:
self.twCredits.item(row, 0).setText("")
self.twCredits.item(row, self.md_attributes["credits.primary"]).setText("")
return
# otherwise, we need to check for, and clear, other primaries with same role
role = str(self.twCredits.item(row, 1).text())
role = str(self.twCredits.item(row, self.md_attributes["credits.role"]).text())
r = 0
for r in range(self.twCredits.rowCount()):
if (
self.twCredits.item(r, 0).text() != ""
and str(self.twCredits.item(r, 1).text()).casefold() == role.casefold()
self.twCredits.item(r, self.md_attributes["credits.primary"]).text() != ""
and str(self.twCredits.item(r, self.md_attributes["credits.role"]).text()).casefold() == role.casefold()
):
self.twCredits.item(r, 0).setText("")
self.twCredits.item(r, self.md_attributes["credits.primary"]).setText("")
# Now set our new primary
self.twCredits.item(row, 0).setText("Yes")
self.twCredits.item(row, self.md_attributes["credits.primary"]).setText("Yes")
def modify_credits(self, edit: bool) -> None:
row = self.twCredits.rowCount()
old = Credit()
if edit:
row = self.twCredits.currentRow()
role = self.twCredits.item(row, 1).text()
name = self.twCredits.item(row, 2).text()
primary = self.twCredits.item(row, 0).text() != ""
else:
row = self.twCredits.rowCount()
role = ""
name = ""
primary = False
lang = str(
self.twCredits.item(row, self.md_attributes["credits.language"]).data(QtCore.Qt.ItemDataRole.UserRole)
or utils.get_language_iso(self.twCredits.item(row, self.md_attributes["credits.language"]).text())
)
old = Credit(
self.twCredits.item(row, self.md_attributes["credits.person"]).text(),
self.twCredits.item(row, self.md_attributes["credits.role"]).text(),
self.twCredits.item(row, self.md_attributes["credits.primary"]).text() != "",
lang,
)
editor = CreditEditorWindow(self, CreditEditorWindow.ModeEdit, role, name, primary)
editor = CreditEditorWindow(self, CreditEditorWindow.ModeEdit, old)
editor.setModal(True)
editor.exec()
if editor.result():
new_role, new_name, new_primary = editor.get_credits()
new = editor.get_credit()
if new_name == name and new_role == role and new_primary == primary:
if new == old:
# nothing has changed, just quit
return
# name and role is the same, but primary flag changed
if new_name == name and new_role == role:
self.update_credit_primary_flag(row, new_primary)
return
# check for dupes
ok_to_mod = True
if self.is_dupe_credit(new_role, new_name):
if self.is_dupe_credit(row, new.role, new.person):
# delete the dupe credit from list
qmsg = QtWidgets.QMessageBox()
qmsg.setText("Duplicate Credit!")
@ -1267,13 +1317,18 @@ class TaggerWindow(QtWidgets.QMainWindow):
if ok_to_mod:
# modify it
if edit:
self.twCredits.item(row, 1).setText(new_role)
self.twCredits.item(row, 2).setText(new_name)
self.update_credit_primary_flag(row, new_primary)
lang = utils.get_language_from_iso(new.language) or new.language
self.twCredits.item(row, self.md_attributes["credits.role"]).setText(new.role)
self.twCredits.item(row, self.md_attributes["credits.person"]).setText(new.person)
self.twCredits.item(row, self.md_attributes["credits.language"]).setText(lang)
self.twCredits.item(row, self.md_attributes["credits.language"]).setData(
QtCore.Qt.ItemDataRole.UserRole, new.language
)
self.update_credit_primary_flag(row, new.primary)
else:
# add new entry
row = self.twCredits.rowCount()
self.add_new_credit_entry(row, new_role, new_name, new_primary)
self.add_new_credit_entry(row, new)
self.update_credit_colors()
self.set_dirty_flag()
@ -1318,7 +1373,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
def adjust_tags_combo(self) -> None:
"""Select the enabled tags. Since tags are merged in an overlay fashion the last item in the list takes priority. We reverse the order for display to the user"""
unchecked = set(tags.keys()) - set(self.selected_read_tags)
unchecked = set(self.enabled_tags()) - set(self.selected_read_tags)
for i, tag_id in enumerate(reversed(self.selected_read_tags)):
if not tags[tag_id].enabled:
continue
@ -1328,19 +1383,15 @@ class TaggerWindow(QtWidgets.QMainWindow):
if item_idx != i:
self.cbSelectedReadTags.moveItem(item_idx, row=i)
for tag_id in unchecked:
if not tags[tag_id].enabled:
continue
self.cbSelectedReadTags.setItemChecked(self.cbSelectedReadTags.findData(tag_id), False)
# select the current tag_id
unchecked = set(tags.keys()) - set(self.selected_write_tags)
unchecked = set(self.enabled_tags()) - set(self.selected_write_tags)
for tag_id in self.selected_write_tags:
if not tags[tag_id].enabled:
continue
self.cbSelectedWriteTags.setItemChecked(self.cbSelectedWriteTags.findData(tag_id), True)
for tag_id in unchecked:
if not tags[tag_id].enabled:
continue
self.cbSelectedWriteTags.setItemChecked(self.cbSelectedWriteTags.findData(tag_id), False)
self.update_tag_tweaks()
@ -1523,7 +1574,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
# Abandon any further tag removals to prevent any greater damage to archive
break
ca.reset_cache()
ca.load_cache(set(tags))
ca.load_cache(self.enabled_tags())
progdialog.hide()
QtCore.QCoreApplication.processEvents()

View File

@ -97,7 +97,7 @@ tr:nth-child(even) {
&lt;tr&gt;&lt;td&gt;{characters}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{teams}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{locations}&lt;/td&gt;&lt;td&gt;string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{credits}&lt;/td&gt;&lt;td&gt;list of dict({&apos;role&apos;: string, &apos;person&apos;: string, &apos;primary&apos;: boolean})&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{credits}&lt;/td&gt;&lt;td&gt;list of dict({&apos;role&apos;: string, &apos;person&apos;: string, &apos;primary&apos;: boolean, &apos;language&apos;: str})&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{writer}&lt;/td&gt;&lt;td&gt;(string)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{penciller}&lt;/td&gt;&lt;td&gt;(string)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;{inker}&lt;/td&gt;&lt;td&gt;(string)&lt;/td&gt;&lt;/tr&gt;

View File

@ -2,80 +2,80 @@
<ui version="4.0">
<class>dialogCreditEditor</class>
<widget class="QDialog" name="dialogCreditEditor">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>196</height>
</rect>
</property>
<property name="windowTitle">
<string>Modify Credit</string>
</property>
<property name="sizeGripEnabled">
<bool>false</bool>
</property>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="geometry">
<rect>
<x>180</x>
<y>140</y>
<width>191</width>
<height>30</height>
</rect>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
<widget class="QWidget" name="formLayoutWidget">
<property name="geometry">
<rect>
<x>30</x>
<y>30</y>
<width>341</width>
<height>91</height>
</rect>
</property>
<layout class="QFormLayout" name="formLayout">
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Role</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="cbRole">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="leName"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QCheckBox" name="cbPrimary">
<property name="text">
<string>Primary</string>
</property>
</widget>
</item>
</layout>
</widget>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QFormLayout" name="formLayout">
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Role</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="cbRole">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="leName"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QCheckBox" name="cbPrimary">
<property name="text">
<string>Primary</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QComboBox" name="cbLanguage">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Language</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<tabstops>
<tabstop>cbRole</tabstop>
<tabstop>leName</tabstop>
<tabstop>cbLanguage</tabstop>
<tabstop>cbPrimary</tabstop>
</tabstops>
<resources/>
<connections>
<connection>

View File

@ -123,10 +123,49 @@
</widget>
</item>
<item>
<widget class="Line" name="line_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<widget class="QGroupBox" name="groupBox_9">
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QLabel" name="lbl_config_dir">
<property name="text">
<string>Config Directory:</string>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="lbl_cache_dir">
<property name="text">
<string>Cache Directory:</string>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="lbl_log_dir">
<property name="text">
<string>Log Directory:</string>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="lbl_plugin_dir">
<property name="text">
<string>Plugin Directory:</string>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>

View File

@ -919,7 +919,7 @@ Source</string>
<number>0</number>
</property>
<property name="columnCount">
<number>3</number>
<number>4</number>
</property>
<attribute name="horizontalHeaderMinimumSectionSize">
<number>2</number>
@ -937,7 +937,12 @@ Source</string>
</column>
<column>
<property name="text">
<string>Credit</string>
<string>Role</string>
</property>
</column>
<column>
<property name="text">
<string>Language</string>
</property>
</column>
<column>

View File

@ -9,9 +9,9 @@ from collections.abc import Sequence
from packaging.version import InvalidVersion, parse
if sys.version_info < (3, 10):
from importlib_metadata import EntryPoint, entry_points
from importlib_metadata import entry_points
else:
from importlib.metadata import entry_points, EntryPoint
from importlib.metadata import entry_points
from comictalker.comictalker import ComicTalker, TalkerError
@ -24,14 +24,14 @@ __all__ = [
def get_talkers(
version: str, cache: pathlib.Path, local_plugins: Sequence[EntryPoint] = tuple()
version: str, cache: pathlib.Path, local_plugins: Sequence[type[ComicTalker]] = tuple()
) -> dict[str, ComicTalker]:
"""Returns all comic talker instances"""
talkers: dict[str, ComicTalker] = {}
ct_version = parse(version)
# A dict is used, last plugin wins
for talker in itertools.chain(entry_points(group="comictagger.talker"), local_plugins):
for talker in itertools.chain(entry_points(group="comictagger.talker")):
try:
talker_cls = talker.load()
obj = talker_cls(version, cache)
@ -56,4 +56,26 @@ def get_talkers(
except Exception:
logger.exception("Failed to load talker: %s", talker.name)
# A dict is used, last plugin wins
for talker_cls in local_plugins:
try:
obj = talker_cls(version, cache)
try:
if ct_version >= parse(talker_cls.comictagger_min_ver):
talkers[talker_cls.id] = obj
else:
logger.error(
f"Minimum ComicTagger version required of {talker_cls.comictagger_min_ver} for talker {talker_cls.id} is not met, will NOT load talker"
)
except InvalidVersion:
logger.warning(
f"Invalid minimum required ComicTagger version number for talker: {talker_cls.id} - version: {talker_cls.comictagger_min_ver}, will load talker anyway"
)
# Attempt to use the talker anyway
# TODO flag this problem for later display to the user
talkers[talker_cls.id] = obj
except Exception:
logger.exception("Failed to load talker: %s", talker_cls.id)
return talkers

View File

@ -295,4 +295,9 @@ class ComicCacher:
set_slots += key + " = ?"
sql_ins = f"INSERT OR REPLACE INTO {tablename} ({keys}) VALUES ({ins_slots})"
if not data.get("complete", True):
sql_ins += f" ON CONFLICT DO UPDATE SET {set_slots} WHERE complete != ?"
vals.extend(vals)
vals.append(True) # If the cache is complete and this isn't complete we don't update it
cur.execute(sql_ins, vals)

View File

@ -21,7 +21,7 @@ from urllib.parse import urlsplit
logger = logging.getLogger(__name__)
def fix_url(url: str) -> str:
def fix_url(url: str | None) -> str:
if not url:
return ""
tmp_url = urlsplit(url)
@ -47,9 +47,10 @@ def cleanup_html(string: str | None, remove_html_tables: bool = False) -> str:
# put in our own
string = re.sub(r"<br>|</li>", "\n", string, flags=re.IGNORECASE)
string = re.sub(r"<li>", "* ", string, flags=re.IGNORECASE)
string = re.sub(r"</p>", "\n\n", string, flags=re.IGNORECASE)
string = re.sub(r"<h([1-6])>", "*", string, flags=re.IGNORECASE)
string = re.sub(r"</h[1-6]>", "*\n", string, flags=re.IGNORECASE)
string = re.sub(r"<h([1-6])>", lambda m: "#" * int(m.group(1)) + " ", string, flags=re.IGNORECASE)
string = re.sub(r"</h[1-6]>", "\n", string, flags=re.IGNORECASE)
# remove the tables
p = re.compile(r"<table[^<]*?>.*?</table>")

View File

@ -22,8 +22,8 @@ import json
import logging
import pathlib
import time
from typing import Any, Callable, Generic, TypeVar
from urllib.parse import urljoin
from typing import Any, Callable, Generic, TypeVar, cast
from urllib.parse import parse_qsl, urljoin
import settngs
from pyrate_limiter import Limiter, RequestRate
@ -43,6 +43,8 @@ except ImportError:
import requests
logger = logging.getLogger(__name__)
TWITTER_TOO_MANY_REQUESTS = 420
class CVTypeID:
Volume = "4050" # CV uses volume to mean series
@ -90,7 +92,7 @@ class CVPersonCredit(TypedDict):
role: str
class CVSeries(TypedDict):
class CVSeries(TypedDict, total=False):
api_detail_url: str
site_detail_url: str
aliases: str
@ -157,8 +159,8 @@ class CVResult(TypedDict, Generic[T]):
# https://comicvine.gamespot.com/forums/api-developers-2334/api-rate-limiting-1746419/
# "Space out your requests so AT LEAST one second passes between each and you can make requests all day."
custom_limiter = Limiter(RequestRate(10, 10))
default_limiter = Limiter(RequestRate(1, 5))
custom_limiter = Limiter(RequestRate(10, 10), RequestRate(200, 1 * 60 * 60))
default_limiter = Limiter(RequestRate(1, 10), RequestRate(100, 1 * 60 * 60))
class ComicVineTalker(ComicTalker):
@ -170,8 +172,8 @@ class ComicVineTalker(ComicTalker):
about: str = (
f"<a href='{website}'>{name}</a> has the largest collection of comic book data available through "
f"its public facing API. "
f"<p>NOTE: Using the default API key will serverly limit access times. A personal API "
f"key will allow for a <b>5 times increase</b> in online search speed. See the "
f"<p>NOTE: Using the default API key will severely limit access times. A personal API "
f"key will allow for a <b>10 times increase</b> in online search speed. See the "
"<a href='https://github.com/comictagger/comictagger/wiki/UserGuide#comic-vine'>Wiki page</a> for "
"more information.</p>"
)
@ -183,6 +185,11 @@ class ComicVineTalker(ComicTalker):
self.default_api_url = self.api_url = f"{self.website}/api/"
self.default_api_key = self.api_key = "27431e6787042105bd3e47e169a624521f89f3a4"
self.use_series_start_as_volume: bool = False
self.total_requests_made: dict[str, int] = utils.DefaultDict(default=lambda x: 0)
self.custom_url_parameters: dict[str, str] = {}
def _log_total_requests(self) -> None:
logger.debug("Total requests made to cv: %s", dict(self.total_requests_made))
def register_settings(self, parser: settngs.Manager) -> None:
parser.add_setting(
@ -205,12 +212,19 @@ class ComicVineTalker(ComicTalker):
display_name="API URL",
help=f"Use the given Comic Vine URL. (default: {self.default_api_url})",
)
parser.add_setting(
f"--{self.id}-custom-parameters",
display_name="Custom URL Parameters",
help="Custom url parameters to add to the url, must already be url encoded. (eg. refresh_cache=true)",
)
def parse_settings(self, settings: dict[str, Any]) -> dict[str, Any]:
settings = super().parse_settings(settings)
self.use_series_start_as_volume = settings["cv_use_series_start_as_volume"]
self.custom_url_parameters = dict(parse_qsl(settings[f"{self.id}_custom_parameters"]))
# Set a different limit if using the default API key
if self.api_key == self.default_api_key:
self.limiter = default_limiter
@ -224,24 +238,28 @@ class ComicVineTalker(ComicTalker):
if not url:
url = self.default_api_url
try:
test_url = urljoin(url, "issue/1/")
test_url = urljoin(url, "team/1/")
self.total_requests_made[test_url] += 1
cv_response: CVResult = requests.get( # type: ignore[type-arg]
test_url,
headers={"user-agent": "comictagger/" + self.version},
params={
"api_key": settings[f"{self.id}_key"] or self.default_api_key,
"format": "json",
"field_list": "name",
},
timeout=10,
).json()
# Bogus request, but if the key is wrong, you get error 100: "Invalid API Key"
if cv_response["status_code"] != 100:
self._log_total_requests()
return "The API key is valid", True
else:
self._log_total_requests()
return "The API key is INVALID!", False
except Exception:
self._log_total_requests()
return "Failed to connect to the URL!", False
def search_for_series(
@ -268,7 +286,9 @@ class ComicVineTalker(ComicTalker):
cached_search_results = cvc.get_search_results(self.id, series_name)
if len(cached_search_results) > 0:
logger.debug("Search for %s cached: True", repr(series_name))
return self._format_search_results([json.loads(x[0].data) for x in cached_search_results])
logger.debug("Search for %s cached: False", repr(series_name))
params = { # CV uses volume to mean series
"api_key": self.api_key,
@ -367,10 +387,46 @@ class ComicVineTalker(ComicTalker):
def fetch_issues_by_series_issue_num_and_year(
self, series_id_list: list[str], issue_number: str, year: str | int | None
) -> 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 = ComicCacher(self.cache_folder, self.version)
cached_results: list[GenericMetadata] = []
needed_volumes: set[int] = set()
for series_id in series_id_list:
series = cvc.get_series_info(series_id, self.id, expire_stale=False)
issues = []
# Explicitly mark count_of_issues at an impossible value
cvseries = CVSeries(id=int(series_id), count_of_issues=-1)
if series:
cvseries = cast(CVSeries, json.loads(series[0].data))
issues = cvc.get_series_issues_info(series_id, self.id, expire_stale=True)
issue_found = False
for issue, _ in issues:
cvissue = cast(CVIssue, json.loads(issue.data))
if cvissue.get("issue_number") == issue_number:
cached_results.append(
self._map_comic_issue_to_metadata(
cvissue,
self._fetch_series([int(cvissue["volume"]["id"])])[0][0],
),
)
issue_found = True
break
if not issues:
needed_volumes.add(int(series_id)) # we got no results from cache, we definitely need to check online
# If we didn't find the issue and we don't have all the issues we don't know if the issue exists, we have to check
if (not issue_found) and cvseries.get("count_of_issues") != len(issues):
needed_volumes.add(int(series_id))
logger.debug("Found %d issues cached need %d issues", len(cached_results), len(needed_volumes))
if not needed_volumes:
return cached_results
series_filter = ""
for vid in series_id_list:
for vid in needed_volumes:
series_filter += str(vid) + "|"
flt = f"volume:{series_filter},issue_number:{issue_number}" # CV uses volume to mean series
flt = f"volume:{series_filter[:-1]},issue_number:{issue_number}" # CV uses volume to mean series
int_year = utils.xlate_int(year)
if int_year is not None:
@ -403,20 +459,162 @@ class ComicVineTalker(ComicTalker):
filtered_issues_result.extend(cv_response["results"])
current_result_count += cv_response["number_of_page_results"]
cvc.add_issues_info(
self.id,
[
Issue(str(x["id"]), str(x["volume"]["id"]), json.dumps(x).encode("utf-8"))
for x in filtered_issues_result
],
False,
)
formatted_filtered_issues_result = [
self._map_comic_issue_to_metadata(x, self._fetch_series_data(x["volume"]["id"])[0])
for x in filtered_issues_result
]
formatted_filtered_issues_result.extend(cached_results)
return formatted_filtered_issues_result
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 = ComicCacher(self.cache_folder, self.version)
cached_results: list[GenericMetadata] = []
needed_issues: list[int] = []
for issue_id in issue_ids:
cached_issue = cvc.get_issue_info(issue_id, self.id)
if cached_issue and cached_issue[1]:
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 = 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 current_result_count < total_result_count:
page += 1
offset += cv_response["number_of_page_results"]
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"]
series_info = {s[0].id: s[0] for s in self._fetch_series([int(i["volume"]["id"]) for i in issue_results])}
for issue in issue_results:
cvc.add_issues_info(
self.id,
[
Issue(
id=str(issue["id"]),
series_id=str(issue["volume"]["id"]),
data=json.dumps(issue).encode("utf-8"),
),
],
False, # The /issues/ endpoint never provides credits
)
cached_results.append(
self._map_comic_issue_to_metadata(issue, series_info[str(issue["volume"]["id"])]),
)
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 = ComicCacher(self.cache_folder, self.version)
cached_results: list[tuple[ComicSeries, bool]] = []
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:
cached_results.append((self._format_series(json.loads(cached_series[0].data)), cached_series[1]))
else:
needed_series.append(series_id)
if needed_series == []:
return cached_results
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,
}
cv_response: CVResult[list[CVSeries]] = self._get_cv_content(series_url, params)
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"]
# 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"]
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,
)
cached_results.append((self._format_series(series), True))
return cached_results
def _get_cv_content(self, url: str, params: dict[str, Any]) -> CVResult[T]:
"""
Get the content from the CV server.
"""
with self.limiter.ratelimit("cv", delay=True):
cv_response: CVResult[T] = self._get_url_content(url, params)
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']}]."
@ -428,19 +626,25 @@ class ComicVineTalker(ComicTalker):
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
limit_counter = 0
final_params = self.custom_url_parameters.copy()
final_params.update(params)
for tries in range(1, 5):
try:
resp = requests.get(url, params=params, headers={"user-agent": "comictagger/" + self.version})
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()
if resp.status_code == 500:
elif resp.status_code == 500:
logger.debug(f"Try #{tries}: ")
time.sleep(1)
logger.debug(str(resp.status_code))
if resp.status_code == requests.status_codes.codes.TOO_MANY_REQUESTS:
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:
@ -463,8 +667,10 @@ class ComicVineTalker(ComicTalker):
except json.JSONDecodeError as e:
logger.debug(f"JSON decode error: {e}")
raise TalkerDataError(self.name, 2, "ComicVine did not provide json")
except Exception as e:
raise TalkerNetworkError(self.name, 5, str(e))
raise TalkerNetworkError(self.name, 5)
raise TalkerNetworkError(self.name, 5, "Unknown error occurred")
def _format_search_results(self, search_results: list[CVSeries]) -> list[ComicSeries]:
formatted_results = []
@ -503,17 +709,20 @@ 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 = ComicCacher(self.cache_folder, self.version)
cached_series_issues_result = cvc.get_series_issues_info(series_id, self.id)
cached_results = cvc.get_series_issues_info(series_id, self.id)
series = self._fetch_series_data(int(series_id))[0]
if len(cached_series_issues_result) == series.count_of_issues:
return [
(self._map_comic_issue_to_metadata(json.loads(x[0].data), series), x[1])
for x in cached_series_issues_result
]
logger.debug(
"Found %d issues cached need %d issues",
len(cached_results),
cast(int, series.count_of_issues) - len(cached_results),
)
if len(cached_results) == series.count_of_issues:
return [(self._map_comic_issue_to_metadata(json.loads(x[0].data), series), x[1]) for x in cached_results]
params = { # CV uses volume to mean series
"api_key": self.api_key,
@ -557,10 +766,12 @@ class ComicVineTalker(ComicTalker):
return [(x, False) for x in formatted_series_issues_result]
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 = 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:
return (self._format_series(json.loads(cached_series[0].data)), cached_series[1])
@ -582,6 +793,7 @@ class ComicVineTalker(ComicTalker):
return self._format_series(series_results), True
def _fetch_issue_data(self, series_id: int, issue_number: str) -> GenericMetadata:
logger.debug("Fetching issue by series ID: %s and issue number: %s", series_id, issue_number)
issues_list_results = self._fetch_issues_in_series(str(series_id))
# Loop through issue list to find the required issue info
@ -602,10 +814,12 @@ class ComicVineTalker(ComicTalker):
return GenericMetadata()
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 = 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]))
if cached_issue and cached_issue[1]:
return self._map_comic_issue_to_metadata(
json.loads(cached_issue[0].data), self._fetch_series_data(int(cached_issue[0].series_id))[0]

View File

@ -55,7 +55,9 @@ install_requires =
python_requires = >=3.9
[options.packages.find]
exclude = tests; testing
exclude =
tests*
testing*
[options.entry_points]
console_scripts = comictagger=comictaggerlib.main:main
@ -66,43 +68,55 @@ comicapi.archiver =
folder = comicapi.archivers.folder:FolderArchiver
comicapi.tags =
cr = comicapi.tags.comicrack:ComicRack
cbi = comicapi.tags.comicbookinfo:ComicBookInfo
comet = comicapi.tags.comet:CoMet
comictagger.talker =
comicvine = comictalker.talkers.comicvine:ComicVineTalker
pyinstaller40 =
hook-dirs = comictaggerlib.__pyinstaller:get_hook_dirs
[options.extras_require]
7Z =
7z =
py7zr
CBR =
rarfile>=4.0
GUI =
PyQt5
ICU =
pyicu;sys_platform == 'linux' or sys_platform == 'darwin'
QTW =
PyQt5
PyQtWebEngine
all =
PyQt5
PyQtWebEngine
comicinfoxml>=0.2.0
comicinfoxml==0.4.*
gcd-talker>0.1.0
metron-talker>0.1.5
pillow-avif-plugin>=1.4.1
pillow-jxl-plugin>=1.2.5
py7zr
rarfile>=4.0
pyicu;sys_platform == 'linux' or sys_platform == 'darwin'
archived_tags =
ct-archived-tags
avif =
pillow-avif-plugin>=1.4.1
cbr =
rarfile>=4.0
cix =
comicinfoxml>=0.2.0
comicinfoxml==0.4.*
gcd =
gcd-talker>=0.1.0
gcd-talker>0.1.0
gui =
PyQt5
icu =
pyicu;sys_platform == 'linux' or sys_platform == 'darwin'
jxl =
pillow-jxl-plugin>=1.2.5
metron =
metron-talker>=0.1.3
metron-talker>0.1.5
pyinstaller =
PyQt5
PyQtWebEngine
comicinfoxml==0.4.*
pillow-avif-plugin>=1.4.1
pillow-jxl-plugin>=1.2.5
py7zr
rarfile>=4.0
pyicu;sys_platform == 'linux' or sys_platform == 'darwin'
qtw =
PyQt5
PyQtWebEngine
[options.package_data]
comicapi =
@ -241,7 +255,7 @@ depends =
deps =
pyinstaller>=5.6.2,!=6.0.0
extras =
all
pyinstaller
commands =
pyrcc5 comictaggerlib/graphics/graphics.qrc -o comictaggerlib/graphics/resources.py
pyinstaller -y build-tools/comictagger.spec

View File

@ -118,7 +118,7 @@ cv_volume_result: dict[str, Any] = {
"results": {
"aliases": None,
"api_detail_url": "https://comicvine.gamespot.com/api/volume/4050-23437/",
"count_of_issues": 6,
"count_of_issues": 1,
"date_added": "2008-10-16 05:25:47",
"date_last_updated": "2012-01-18 17:21:57",
"deck": None,

View File

@ -25,6 +25,38 @@ datadir = importlib.resources.files(__package__).joinpath("data")
cbz_path = datadir.joinpath("Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz")
names: list[tuple[str, str, dict[str, str | bool], tuple[bool, bool]]] = [
(
"De Psy #6 Bonjour l'angoisse!.cbz",
"'",
{
"issue": "6",
"series": "De Psy",
"title": "Bonjour l'angoisse!",
"volume": "",
"year": "",
"remainder": "",
"issue_count": "",
"alternate": "",
"archive": "cbz",
},
(False, True),
),
(
"Airfiles #4 The 'Big Show'.cbz",
"'",
{
"issue": "4",
"series": "Airfiles",
"title": "The 'Big Show'",
"volume": "",
"year": "",
"remainder": "",
"issue_count": "",
"alternate": "",
"archive": "cbz",
},
(False, True),
),
(
"Conceptions #1 Conceptions I.cbz",
"&",
@ -1114,6 +1146,22 @@ for p in names:
)
file_renames = [
(
"{series} #{issue} - {title} ({year}) ({price!c})", # conversion on None
False,
False,
"universal",
"Cory Doctorow's Futuristic Tales of the Here and Now #001 - Anda's Game (2007).cbz",
does_not_raise(),
),
(
"{country[0]} {price} {year}", # Indexing a None value
False,
False,
"universal",
"2007.cbz",
does_not_raise(),
),
(
"{series!c} {price} {year}", # Capitalize
False,

View File

@ -8,6 +8,7 @@ import pytest
from importlib_metadata import entry_points
import comicapi.archivers.rar
import comicapi.archivers.zip
import comicapi.comicarchive
import comicapi.genericmetadata
from testing.filenames import datadir
@ -51,15 +52,6 @@ def test_write_cr(tmp_comic):
md = tmp_comic.read_tags("cr")
def test_write_cbi(tmp_comic):
md = tmp_comic.read_tags("cr")
md.apply_default_page_list(tmp_comic.get_page_name_list())
assert tmp_comic.write_tags(md, "cbi")
md = tmp_comic.read_tags("cbi")
@pytest.mark.xfail(not (comicapi.archivers.rar.rar_support and shutil.which("rar")), reason="rar support")
def test_save_cr_rar(tmp_path, md_saved):
cbr_path = datadir / "fake_cbr.cbr"
@ -77,20 +69,6 @@ def test_save_cr_rar(tmp_path, md_saved):
assert md == md_saved
@pytest.mark.xfail(not (comicapi.archivers.rar.rar_support and shutil.which("rar")), reason="rar support")
def test_save_cbi_rar(tmp_path, md_saved):
cbr_path = pathlib.Path(str(datadir)) / "fake_cbr.cbr"
shutil.copy(cbr_path, tmp_path)
tmp_comic = comicapi.comicarchive.ComicArchive(tmp_path / cbr_path.name)
assert tmp_comic.seems_to_be_a_comic_archive()
assert tmp_comic.write_tags(comicapi.genericmetadata.md_test, "cbi")
md = tmp_comic.read_tags("cbi")
supported_attributes = comicapi.comicarchive.tags["cbi"].supported_attributes
assert md.get_clean_metadata(*supported_attributes) == md_saved.get_clean_metadata(*supported_attributes)
def test_page_type_write(tmp_comic):
md = tmp_comic.read_tags("cr")
t = md.pages[0]
@ -101,12 +79,14 @@ def test_page_type_write(tmp_comic):
md = tmp_comic.read_tags("cr")
def test_invalid_zip(tmp_comic):
def test_invalid_zip(tmp_comic: comicapi.comicarchive.ComicArchive):
with open(tmp_comic.path, mode="b+r") as f:
# 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")
assert not result
result = tmp_comic.write_tags(comicapi.genericmetadata.md_test, "cr") # This is not the first file
assert result
assert not tmp_comic.seems_to_be_a_comic_archive() # Calls archiver.is_valid
archivers = []

View File

@ -28,10 +28,32 @@ def test_search_results(comic_cache):
@pytest.mark.parametrize("series_info", search_results)
def test_series_info(comic_cache, series_info):
comic_cache.add_series_info(
series=comictalker.comiccacher.Series(id=series_info["id"], data=json.dumps(series_info)),
series=comictalker.comiccacher.Series(id=series_info["id"], data=json.dumps(series_info).encode("utf-8")),
source="test",
complete=True,
)
vi = series_info.copy()
cache_result = json.loads(comic_cache.get_series_info(series_id=series_info["id"], source="test")[0].data)
assert vi == cache_result
@pytest.mark.parametrize("series_info", search_results)
def test_cache_overwrite(comic_cache, series_info):
vi = series_info.copy()
comic_cache.add_series_info(
series=comictalker.comiccacher.Series(id=series_info["id"], data=json.dumps(series_info).encode("utf-8")),
source="test",
complete=True,
) # Populate the cache
# Try to insert an incomplete series with different data
series_info["name"] = "test 3"
comic_cache.add_series_info(
series=comictalker.comiccacher.Series(id=series_info["id"], data=json.dumps(series_info).encode("utf-8")),
source="test",
complete=False,
)
cache_result = json.loads(comic_cache.get_series_info(series_id=series_info["id"], source="test")[0].data)
# Validate that the Series marked complete is still in the cache
assert vi == cache_result

View File

@ -46,13 +46,24 @@ def test_fetch_issue_data_by_issue_id(comicvine_api):
assert result == testing.comicvine.cv_md
def test_fetch_issues_in_series_issue_num_and_year(comicvine_api):
def test_fetch_issues_in_series_issue_num_and_year(comicvine_api, cv_requests_get):
results = comicvine_api.fetch_issues_by_series_issue_num_and_year([23437], "1", None)
cv_expected = testing.comicvine.comic_issue_result.copy()
for r, e in zip(results, [cv_expected]):
assert r.series == e.series
assert r == e
assert results[0].series == cv_expected.series
assert results[0] == cv_expected
assert cv_requests_get.call_count == 2
results = comicvine_api.fetch_issues_by_series_issue_num_and_year([23437], "1", None)
assert results[0].series == cv_expected.series
assert results[0] == cv_expected
assert cv_requests_get.call_count == 2 # verify caching works
results = comicvine_api.fetch_issues_by_series_issue_num_and_year([23437], "2", None)
assert not results
assert cv_requests_get.call_count == 2 # verify negative caching works
cv_issue = [

View File

@ -70,7 +70,7 @@ def no_requests(monkeypatch) -> None:
@pytest.fixture
def comicvine_api(monkeypatch, cbz, comic_cache, mock_version, config) -> comictalker.talkers.comicvine.ComicVineTalker:
def cv_requests_get(monkeypatch, cbz, comic_cache) -> unittest.mock.Mock:
# Any arguments may be passed and mock_get() will always return our
# mocked object, which only has the .json() method or None for invalid urls.
@ -88,16 +88,18 @@ def comicvine_api(monkeypatch, cbz, comic_cache, mock_version, config) -> comict
return comicvine.MockResponse(cv_result)
if args[0].startswith("https://comicvine.gamespot.com/api/issue/4000-140529"):
return comicvine.MockResponse(comicvine.cv_issue_result)
flt = kwargs.get("params", {}).get("filter", "").split(",")
if (
args[0].startswith("https://comicvine.gamespot.com/api/issues/")
and "params" in kwargs
and "filter" in kwargs["params"]
and "23437" in kwargs["params"]["filter"]
and "volume:23437" in flt
):
cv_list = make_list(comicvine.cv_issue_result)
for cv in cv_list["results"]:
comicvine.filter_field_list(cv, kwargs)
return comicvine.MockResponse(cv_list)
if "issue_number" not in kwargs["params"]["filter"] or ("issue_number:1" in flt):
cv_list = make_list(comicvine.cv_issue_result)
for cv in cv_list["results"]:
comicvine.filter_field_list(cv, kwargs)
return comicvine.MockResponse(cv_list)
if (
args[0].startswith("https://comicvine.gamespot.com/api/search")
and "params" in kwargs
@ -126,6 +128,11 @@ def comicvine_api(monkeypatch, cbz, comic_cache, mock_version, config) -> comict
# apply the monkeypatch for requests.get to mock_get
monkeypatch.setattr(requests, "get", m_get)
return m_get
@pytest.fixture
def comicvine_api(monkeypatch, cv_requests_get, mock_version, config) -> comictalker.talkers.comicvine.ComicVineTalker:
monkeypatch.setattr(comictalker.talkers.comicvine, "custom_limiter", Limiter(RequestRate(100, 1)))
monkeypatch.setattr(comictalker.talkers.comicvine, "default_limiter", Limiter(RequestRate(100, 1)))
@ -197,7 +204,7 @@ def config(tmp_path):
from comictaggerlib.main import App
app = App()
app.register_settings()
app.register_settings(False)
defaults = app.parse_settings(comictaggerlib.ctsettings.ComicTaggerPaths(tmp_path / "config"), "")
defaults[0].Runtime_Options__config.user_config_dir.mkdir(parents=True, exist_ok=True)
@ -212,15 +219,15 @@ def plugin_config(tmp_path):
from comictaggerlib.main import App
ns = Namespace(config=comictaggerlib.ctsettings.ComicTaggerPaths(tmp_path / "config"))
ns.config.user_config_dir.mkdir(parents=True, exist_ok=True)
ns.config.user_cache_dir.mkdir(parents=True, exist_ok=True)
ns.config.user_log_dir.mkdir(parents=True, exist_ok=True)
ns.config.user_plugin_dir.mkdir(parents=True, exist_ok=True)
app = App()
app.load_plugins(ns)
app.register_settings()
app.register_settings(False)
defaults = app.parse_settings(ns.config, "")
defaults[0].Runtime_Options__config.user_config_dir.mkdir(parents=True, exist_ok=True)
defaults[0].Runtime_Options__config.user_cache_dir.mkdir(parents=True, exist_ok=True)
defaults[0].Runtime_Options__config.user_log_dir.mkdir(parents=True, exist_ok=True)
defaults[0].Runtime_Options__config.user_plugin_dir.mkdir(parents=True, exist_ok=True)
yield (defaults, app.talkers)

View File

@ -52,6 +52,8 @@ def test_save(
# This is inserted here because otherwise several other tests
# unrelated to comicvine need to be re-worked
# the comicvine response is mocked to 1 for caching tests and adding the remaining 5 issues is more work
md_saved.issue_count = 1
md_saved.credits.insert(
1,
comicapi.genericmetadata.Credit(

View File

@ -9,12 +9,15 @@ from comictaggerlib.md import prepare_metadata
tags = []
for x in entry_points(group="comicapi.tag"):
for x in entry_points(group="comicapi.tags"):
tag = x.load()
supported = tag.enabled
exe_found = True
tags.append(pytest.param(tag, marks=pytest.mark.xfail(not supported, reason="tags not enabled")))
if not tags:
raise Exception("No tags found")
@pytest.mark.parametrize("tag_type", tags)
def test_metadata(mock_version, tmp_comic, md_saved, tag_type):
@ -22,20 +25,24 @@ def test_metadata(mock_version, tmp_comic, md_saved, tag_type):
supported_attributes = tag.supported_attributes
tag.write_tags(comicapi.genericmetadata.md_test, tmp_comic.archiver)
written_metadata = tag.read_tags(tmp_comic.archiver)
md = md_saved.get_clean_metadata(*supported_attributes)
md = md_saved._get_clean_metadata(*supported_attributes)
# Hack back in the pages variable because CoMet supports identifying the cover by the filename
if tag.id == "comet":
md.pages = [
comicapi.genericmetadata.ImageMetadata(
image_index=0, filename="!cover.jpg", type=comicapi.genericmetadata.PageType.FrontCover
comicapi.genericmetadata.PageMetadata(
archive_index=0,
bookmark="",
display_index=0,
filename="!cover.jpg",
type=comicapi.genericmetadata.PageType.FrontCover,
)
]
written_metadata = written_metadata.get_clean_metadata(*supported_attributes).replace(
written_metadata = written_metadata._get_clean_metadata(*supported_attributes).replace(
pages=written_metadata.pages
)
else:
written_metadata = written_metadata.get_clean_metadata(*supported_attributes)
written_metadata = written_metadata._get_clean_metadata(*supported_attributes)
assert written_metadata == md