Compare commits
16 Commits
1.6.0-alph
...
5155762711
Author | SHA1 | Date | |
---|---|---|---|
5155762711 | |||
ea43eccd78 | |||
14ce8a759f | |||
22d92e1ded | |||
d277eb332b | |||
dcad32ade0 | |||
dd0b637566 | |||
bad8b85874 | |||
938f760a37 | |||
f382c2f814 | |||
4e75731024 | |||
920a0ed1af | |||
9eb50da744 | |||
2e2d886cb2 | |||
5738433c2b | |||
4a33dbde46 |
@ -41,6 +41,6 @@ repos:
|
||||
rev: v1.8.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-setuptools, types-requests, settngs>=0.9.1]
|
||||
additional_dependencies: [types-setuptools, types-requests, settngs>=0.10.0]
|
||||
ci:
|
||||
skip: [mypy]
|
||||
|
@ -11,7 +11,11 @@ def generate() -> str:
|
||||
app = comictaggerlib.main.App()
|
||||
app.load_plugins(app.initial_arg_parser.parse_known_args()[0])
|
||||
app.register_settings()
|
||||
return settngs.generate_ns(app.manager.definitions)
|
||||
imports, types = settngs.generate_dict(app.manager.definitions)
|
||||
imports2, types2 = settngs.generate_ns(app.manager.definitions)
|
||||
i = imports.splitlines()
|
||||
i.extend(set(imports2.splitlines()) - set(i))
|
||||
return "\n\n".join(("\n".join(i), types2, types))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@ -364,7 +364,7 @@ class ComicArchive:
|
||||
|
||||
def metadata_from_filename(
|
||||
self,
|
||||
complicated_parser: bool = False,
|
||||
parser: utils.Parser = utils.Parser.ORIGINAL,
|
||||
remove_c2c: bool = False,
|
||||
remove_fcbd: bool = False,
|
||||
remove_publisher: bool = False,
|
||||
@ -376,7 +376,7 @@ class ComicArchive:
|
||||
|
||||
filename_info = utils.parse_filename(
|
||||
self.path.name,
|
||||
complicated_parser=complicated_parser,
|
||||
parser=parser,
|
||||
remove_c2c=remove_c2c,
|
||||
remove_fcbd=remove_fcbd,
|
||||
remove_publisher=remove_publisher,
|
||||
|
@ -20,12 +20,16 @@ import logging
|
||||
import os
|
||||
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 comicfn2dict import comicfn2dict
|
||||
|
||||
import comicapi.data
|
||||
from comicapi import filenamelexer, filenameparser
|
||||
|
||||
@ -37,9 +41,55 @@ try:
|
||||
except ImportError:
|
||||
icu_available = False
|
||||
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
"""
|
||||
Enum where members are also (and must be) strings
|
||||
"""
|
||||
|
||||
def __new__(cls, *values: Any) -> Any:
|
||||
"values must already be of type `str`"
|
||||
if len(values) > 3:
|
||||
raise TypeError(f"too many arguments for str(): {values!r}")
|
||||
if len(values) == 1:
|
||||
# it must be a string
|
||||
if not isinstance(values[0], str):
|
||||
raise TypeError(f"{values[0]!r} is not a string")
|
||||
if len(values) >= 2:
|
||||
# check that encoding argument is a string
|
||||
if not isinstance(values[1], str):
|
||||
raise TypeError(f"encoding must be a string, not {values[1]!r}")
|
||||
if len(values) == 3:
|
||||
# check that errors argument is a string
|
||||
if not isinstance(values[2], str):
|
||||
raise TypeError("errors must be a string, not %r" % (values[2]))
|
||||
value = str(*values)
|
||||
member = str.__new__(cls, value)
|
||||
member._value_ = value
|
||||
return member
|
||||
|
||||
@staticmethod
|
||||
def _generate_next_value_(name: str, start: int, count: int, last_values: Any) -> str:
|
||||
"""
|
||||
Return the lower-cased version of the member name.
|
||||
"""
|
||||
return name.lower()
|
||||
|
||||
else:
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Parser(StrEnum):
|
||||
ORIGINAL = auto()
|
||||
COMPLICATED = auto()
|
||||
COMICFN2DICT = auto()
|
||||
|
||||
|
||||
def _custom_key(tup: Any) -> Any:
|
||||
import natsort
|
||||
|
||||
@ -67,7 +117,7 @@ def os_sorted(lst: Iterable[T]) -> Iterable[T]:
|
||||
|
||||
def parse_filename(
|
||||
filename: str,
|
||||
complicated_parser: bool = False,
|
||||
parser: Parser = Parser.ORIGINAL,
|
||||
remove_c2c: bool = False,
|
||||
remove_fcbd: bool = False,
|
||||
remove_publisher: bool = False,
|
||||
@ -99,7 +149,25 @@ def parse_filename(
|
||||
filename, ext = os.path.splitext(filename)
|
||||
filename = " ".join(wordninja.split(filename)) + ext
|
||||
|
||||
if complicated_parser:
|
||||
fni = filenameparser.FilenameInfo(
|
||||
alternate="",
|
||||
annual=False,
|
||||
archive="",
|
||||
c2c=False,
|
||||
fcbd=False,
|
||||
format="",
|
||||
issue="",
|
||||
issue_count="",
|
||||
publisher="",
|
||||
remainder="",
|
||||
series="",
|
||||
title="",
|
||||
volume="",
|
||||
volume_count="",
|
||||
year="",
|
||||
)
|
||||
|
||||
if parser == Parser.COMPLICATED:
|
||||
lex = filenamelexer.Lex(filename, allow_issue_start_with_letter)
|
||||
p = filenameparser.Parse(
|
||||
lex.items,
|
||||
@ -108,7 +176,26 @@ def parse_filename(
|
||||
remove_publisher=remove_publisher,
|
||||
protofolius_issue_number_scheme=protofolius_issue_number_scheme,
|
||||
)
|
||||
return p.filename_info
|
||||
fni = p.filename_info
|
||||
elif parser == Parser.COMICFN2DICT:
|
||||
fn2d = comicfn2dict(filename)
|
||||
fni = filenameparser.FilenameInfo(
|
||||
alternate="",
|
||||
annual=False,
|
||||
archive=fn2d.get("ext", ""),
|
||||
c2c=False,
|
||||
fcbd=False,
|
||||
issue=fn2d.get("issue", ""),
|
||||
issue_count=fn2d.get("issue_count", ""),
|
||||
publisher=fn2d.get("publisher", ""),
|
||||
remainder=fn2d.get("scan_info", ""),
|
||||
series=fn2d.get("series", ""),
|
||||
title=fn2d.get("title", ""),
|
||||
volume=fn2d.get("volume", ""),
|
||||
volume_count=fn2d.get("volume_count", ""),
|
||||
year=fn2d.get("year", ""),
|
||||
format=fn2d.get("original_format", ""),
|
||||
)
|
||||
else:
|
||||
fnp = filenameparser.FileNameParser()
|
||||
fnp.parse_filename(filename)
|
||||
@ -129,7 +216,7 @@ def parse_filename(
|
||||
year=fnp.year,
|
||||
format="",
|
||||
)
|
||||
return fni
|
||||
return fni
|
||||
|
||||
|
||||
def combine_notes(existing_notes: str | None, new_notes: str | None, split: str) -> str:
|
||||
|
@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PyInstaller.utils.hooks import collect_data_files, collect_entry_point
|
||||
from PyInstaller.utils.hooks import collect_data_files, collect_entry_point, collect_submodules
|
||||
|
||||
datas, hiddenimports = collect_entry_point("comictagger.talker")
|
||||
hiddenimports += collect_submodules("comictaggerlib")
|
||||
datas += collect_data_files("comictaggerlib.ui")
|
||||
datas += collect_data_files("comictaggerlib.graphics")
|
||||
|
@ -233,7 +233,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
|
||||
md = ca.read_metadata(self.config.internal__load_data_style)
|
||||
if md.is_empty:
|
||||
md = ca.metadata_from_filename(
|
||||
self.config.Filename_Parsing__complicated_parser,
|
||||
self.config.Filename_Parsing__filename_parser,
|
||||
self.config.Filename_Parsing__remove_c2c,
|
||||
self.config.Filename_Parsing__remove_fcbd,
|
||||
self.config.Filename_Parsing__remove_publisher,
|
||||
|
@ -250,7 +250,7 @@ class CLI:
|
||||
# now, overlay the parsed filename info
|
||||
if self.config.Runtime_Options__parse_filename:
|
||||
f_md = ca.metadata_from_filename(
|
||||
self.config.Filename_Parsing__complicated_parser,
|
||||
self.config.Filename_Parsing__filename_parser,
|
||||
self.config.Filename_Parsing__remove_c2c,
|
||||
self.config.Filename_Parsing__remove_fcbd,
|
||||
self.config.Filename_Parsing__remove_publisher,
|
||||
@ -459,31 +459,29 @@ class CLI:
|
||||
self.output(text)
|
||||
|
||||
# use our overlaid MD struct to search
|
||||
ii.set_additional_metadata(md)
|
||||
ii.only_use_additional_meta_data = True
|
||||
# ii.set_additional_metadata(md)
|
||||
# ii.only_use_additional_meta_data = True
|
||||
ii.set_output_function(functools.partial(self.output, already_logged=True))
|
||||
ii.cover_page_index = md.get_cover_page_index_list()[0]
|
||||
matches = ii.search()
|
||||
|
||||
result = ii.search_result
|
||||
# ii.cover_page_index = md.get_cover_page_index_list()[0]
|
||||
result, matches = ii.identify(ca, md)
|
||||
|
||||
found_match = False
|
||||
choices = False
|
||||
low_confidence = False
|
||||
|
||||
if result == ii.result_no_matches:
|
||||
if result == IssueIdentifier.result_no_matches:
|
||||
pass
|
||||
elif result == ii.result_found_match_but_bad_cover_score:
|
||||
elif result == IssueIdentifier.result_found_match_but_bad_cover_score:
|
||||
low_confidence = True
|
||||
found_match = True
|
||||
elif result == ii.result_found_match_but_not_first_page:
|
||||
elif result == IssueIdentifier.result_found_match_but_not_first_page:
|
||||
found_match = True
|
||||
elif result == ii.result_multiple_matches_with_bad_image_scores:
|
||||
elif result == IssueIdentifier.result_multiple_matches_with_bad_image_scores:
|
||||
low_confidence = True
|
||||
choices = True
|
||||
elif result == ii.result_one_good_match:
|
||||
elif result == IssueIdentifier.result_one_good_match:
|
||||
found_match = True
|
||||
elif result == ii.result_multiple_good_matches:
|
||||
elif result == IssueIdentifier.result_multiple_good_matches:
|
||||
choices = True
|
||||
|
||||
if choices:
|
||||
|
@ -14,7 +14,7 @@ from comictaggerlib.ctsettings.commandline import (
|
||||
)
|
||||
from comictaggerlib.ctsettings.file import register_file_settings, validate_file_settings
|
||||
from comictaggerlib.ctsettings.plugin import group_for_plugin, register_plugin_settings, validate_plugin_settings
|
||||
from comictaggerlib.ctsettings.settngs_namespace import settngs_namespace as ct_ns
|
||||
from comictaggerlib.ctsettings.settngs_namespace import SettngsNS as ct_ns
|
||||
from comictaggerlib.ctsettings.types import ComicTaggerPaths
|
||||
from comictalker import ComicTalker
|
||||
|
||||
|
@ -29,7 +29,7 @@ from comicapi import utils
|
||||
from comicapi.comicarchive import metadata_styles
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comictaggerlib import ctversion
|
||||
from comictaggerlib.ctsettings.settngs_namespace import settngs_namespace as ct_ns
|
||||
from comictaggerlib.ctsettings.settngs_namespace import SettngsNS as ct_ns
|
||||
from comictaggerlib.ctsettings.types import (
|
||||
ComicTaggerPaths,
|
||||
metadata_type,
|
||||
@ -308,9 +308,12 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
|
||||
# take a crack at finding rar exe if it's not in the path
|
||||
if not utils.which("rar"):
|
||||
if platform.system() == "Windows":
|
||||
# look in some likely places for Windows machines
|
||||
utils.add_to_path(r"C:\Program Files\WinRAR")
|
||||
utils.add_to_path(r"C:\Program Files (x86)\WinRAR")
|
||||
letters = ["C"]
|
||||
letters.extend({f"{d}" for d in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if os.path.exists(f"{d}:\\")} - {"C"})
|
||||
for letter in letters:
|
||||
# look in some likely places for Windows machines
|
||||
utils.add_to_path(rf"{letters}:\Program Files\WinRAR")
|
||||
utils.add_to_path(rf"{letters}:\Program Files (x86)\WinRAR")
|
||||
else:
|
||||
if platform.system() == "Darwin":
|
||||
result = subprocess.run(("/usr/libexec/path_helper", "-s"), capture_output=True)
|
||||
|
@ -5,7 +5,8 @@ import uuid
|
||||
|
||||
import settngs
|
||||
|
||||
from comictaggerlib.ctsettings.settngs_namespace import settngs_namespace as ct_ns
|
||||
from comicapi import utils
|
||||
from comictaggerlib.ctsettings.settngs_namespace import SettngsNS as ct_ns
|
||||
from comictaggerlib.defaults import DEFAULT_REPLACEMENTS, Replacement, Replacements
|
||||
|
||||
|
||||
@ -19,6 +20,12 @@ def general(parser: settngs.Manager) -> None:
|
||||
help="Disable the ComicRack metadata type",
|
||||
)
|
||||
parser.add_setting("use_short_metadata_names", default=False, action=argparse.BooleanOptionalAction, cmdline=False)
|
||||
parser.add_setting(
|
||||
"--prompt-on-save",
|
||||
default=True,
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Prompts the user to confirm saving tags when using the GUI.",
|
||||
)
|
||||
|
||||
|
||||
def internal(parser: settngs.Manager) -> None:
|
||||
@ -96,10 +103,12 @@ def dialog(parser: settngs.Manager) -> None:
|
||||
def filename(parser: settngs.Manager) -> None:
|
||||
# filename parsing settings
|
||||
parser.add_setting(
|
||||
"--complicated-parser",
|
||||
default=False,
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Enables the new parser which tries to extract more information from filenames",
|
||||
"--filename-parser",
|
||||
default=utils.Parser.ORIGINAL,
|
||||
metavar=f"{{{','.join(utils.Parser)}}}",
|
||||
type=utils.Parser,
|
||||
choices=[p.value for p in utils.Parser],
|
||||
help="Select the filename parser, defaults to original",
|
||||
)
|
||||
parser.add_setting(
|
||||
"--remove-c2c",
|
||||
@ -254,12 +263,24 @@ def parse_filter(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
|
||||
return config
|
||||
|
||||
|
||||
def migrate_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
|
||||
original_types = ("cbi", "cr", "comet")
|
||||
save_style = config[0].internal__save_data_style
|
||||
if not isinstance(save_style, list):
|
||||
if isinstance(save_style, int) and save_style in (0, 1, 2):
|
||||
config[0].internal__save_data_style = [original_types[save_style]]
|
||||
elif isinstance(save_style, str):
|
||||
config[0].internal__save_data_style = [save_style]
|
||||
else:
|
||||
config[0].internal__save_data_style = ["cbi"]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def validate_file_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
|
||||
config = parse_filter(config)
|
||||
|
||||
# TODO Remove this conversion check at a later date
|
||||
if isinstance(config[0].internal__save_data_style, str):
|
||||
config[0].internal__save_data_style = [config[0].internal__save_data_style]
|
||||
config = migrate_settings(config)
|
||||
|
||||
if config[0].Filename_Parsing__protofolius_issue_number_scheme:
|
||||
config[0].Filename_Parsing__allow_issue_start_with_letter = True
|
||||
|
@ -10,16 +10,16 @@ import comicapi.comicarchive
|
||||
import comicapi.utils
|
||||
import comictaggerlib.ctsettings
|
||||
from comicapi.comicarchive import Archiver
|
||||
from comictaggerlib.ctsettings.settngs_namespace import settngs_namespace as ct_ns
|
||||
from comictaggerlib.ctsettings.settngs_namespace import SettngsNS as ct_ns
|
||||
from comictalker.comictalker import ComicTalker
|
||||
|
||||
logger = logging.getLogger("comictagger")
|
||||
|
||||
|
||||
def group_for_plugin(plugin: Archiver | ComicTalker) -> str:
|
||||
def group_for_plugin(plugin: Archiver | ComicTalker | type[Archiver]) -> str:
|
||||
if isinstance(plugin, ComicTalker):
|
||||
return f"Source {plugin.id}"
|
||||
if isinstance(plugin, Archiver):
|
||||
if isinstance(plugin, Archiver) or plugin == Archiver:
|
||||
return "Archive"
|
||||
raise NotImplementedError(f"Invalid plugin received: {plugin=}")
|
||||
|
||||
|
@ -1,14 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import settngs
|
||||
|
||||
import comicapi.genericmetadata
|
||||
import comicapi.utils
|
||||
import comictaggerlib.ctsettings.types
|
||||
import comictaggerlib.defaults
|
||||
import comictaggerlib.resulttypes
|
||||
|
||||
|
||||
class settngs_namespace(settngs.TypedNS):
|
||||
class SettngsNS(settngs.TypedNS):
|
||||
Commands__version: bool
|
||||
Commands__command: comictaggerlib.resulttypes.Action
|
||||
Commands__copy: str
|
||||
@ -59,7 +62,7 @@ class settngs_namespace(settngs.TypedNS):
|
||||
Issue_Identifier__exact_series_matches_first: bool
|
||||
Issue_Identifier__always_use_publisher_filter: bool
|
||||
|
||||
Filename_Parsing__complicated_parser: bool
|
||||
Filename_Parsing__filename_parser: comicapi.utils.Parser
|
||||
Filename_Parsing__remove_c2c: bool
|
||||
Filename_Parsing__remove_fcbd: bool
|
||||
Filename_Parsing__remove_publisher: bool
|
||||
@ -98,6 +101,7 @@ class settngs_namespace(settngs.TypedNS):
|
||||
General__check_for_new_version: bool
|
||||
General__disable_cr: bool
|
||||
General__use_short_metadata_names: bool
|
||||
General__prompt_on_save: bool
|
||||
|
||||
Dialog_Flags__show_disclaimer: bool
|
||||
Dialog_Flags__dont_notify_about_this_version: str
|
||||
@ -108,3 +112,150 @@ class settngs_namespace(settngs.TypedNS):
|
||||
Source_comicvine__comicvine_key: str
|
||||
Source_comicvine__comicvine_url: str
|
||||
Source_comicvine__cv_use_series_start_as_volume: bool
|
||||
|
||||
|
||||
class Commands(typing.TypedDict):
|
||||
version: bool
|
||||
command: comictaggerlib.resulttypes.Action
|
||||
copy: str
|
||||
|
||||
|
||||
class Runtime_Options(typing.TypedDict):
|
||||
config: comictaggerlib.ctsettings.types.ComicTaggerPaths
|
||||
verbose: int
|
||||
abort_on_conflict: bool
|
||||
delete_original: bool
|
||||
parse_filename: bool
|
||||
issue_id: str
|
||||
online: bool
|
||||
metadata: comicapi.genericmetadata.GenericMetadata
|
||||
interactive: bool
|
||||
abort_on_low_confidence: bool
|
||||
summary: bool
|
||||
raw: bool
|
||||
recursive: bool
|
||||
dryrun: bool
|
||||
darkmode: bool
|
||||
glob: bool
|
||||
quiet: bool
|
||||
json: bool
|
||||
type: list[str]
|
||||
overwrite: bool
|
||||
no_gui: bool
|
||||
files: list[str]
|
||||
|
||||
|
||||
class internal(typing.TypedDict):
|
||||
install_id: str
|
||||
save_data_style: list[str]
|
||||
load_data_style: str
|
||||
last_opened_folder: str
|
||||
window_width: int
|
||||
window_height: int
|
||||
window_x: int
|
||||
window_y: int
|
||||
form_width: int
|
||||
list_width: int
|
||||
sort_column: int
|
||||
sort_direction: int
|
||||
|
||||
|
||||
class Issue_Identifier(typing.TypedDict):
|
||||
series_match_identify_thresh: int
|
||||
border_crop_percent: int
|
||||
publisher_filter: list[str]
|
||||
series_match_search_thresh: int
|
||||
clear_metadata: bool
|
||||
auto_imprint: bool
|
||||
sort_series_by_year: bool
|
||||
exact_series_matches_first: bool
|
||||
always_use_publisher_filter: bool
|
||||
|
||||
|
||||
class Filename_Parsing(typing.TypedDict):
|
||||
filename_parser: comicapi.utils.Parser
|
||||
remove_c2c: bool
|
||||
remove_fcbd: bool
|
||||
remove_publisher: bool
|
||||
split_words: bool
|
||||
protofolius_issue_number_scheme: bool
|
||||
allow_issue_start_with_letter: bool
|
||||
|
||||
|
||||
class Sources(typing.TypedDict):
|
||||
source: str
|
||||
remove_html_tables: bool
|
||||
|
||||
|
||||
class Comic_Book_Lover(typing.TypedDict):
|
||||
assume_lone_credit_is_primary: bool
|
||||
copy_characters_to_tags: bool
|
||||
copy_teams_to_tags: bool
|
||||
copy_locations_to_tags: bool
|
||||
copy_storyarcs_to_tags: bool
|
||||
copy_notes_to_comments: bool
|
||||
copy_weblink_to_comments: bool
|
||||
apply_transform_on_import: bool
|
||||
apply_transform_on_bulk_operation: bool
|
||||
|
||||
|
||||
class File_Rename(typing.TypedDict):
|
||||
template: str
|
||||
issue_number_padding: int
|
||||
use_smart_string_cleanup: bool
|
||||
auto_extension: bool
|
||||
dir: str
|
||||
move_to_dir: bool
|
||||
strict: bool
|
||||
replacements: comictaggerlib.defaults.Replacements
|
||||
|
||||
|
||||
class Auto_Tag(typing.TypedDict):
|
||||
save_on_low_confidence: bool
|
||||
dont_use_year_when_identifying: bool
|
||||
assume_issue_one: bool
|
||||
ignore_leading_numbers_in_filename: bool
|
||||
remove_archive_after_successful_match: bool
|
||||
|
||||
|
||||
class General(typing.TypedDict):
|
||||
check_for_new_version: bool
|
||||
disable_cr: bool
|
||||
use_short_metadata_names: bool
|
||||
prompt_on_save: bool
|
||||
|
||||
|
||||
class Dialog_Flags(typing.TypedDict):
|
||||
show_disclaimer: bool
|
||||
dont_notify_about_this_version: str
|
||||
ask_about_usage_stats: bool
|
||||
|
||||
|
||||
class Archive(typing.TypedDict):
|
||||
rar: str
|
||||
|
||||
|
||||
class Source_comicvine(typing.TypedDict):
|
||||
comicvine_key: str
|
||||
comicvine_url: str
|
||||
cv_use_series_start_as_volume: bool
|
||||
|
||||
|
||||
SettngsDict = typing.TypedDict(
|
||||
"SettngsDict",
|
||||
{
|
||||
"Commands": Commands,
|
||||
"Runtime Options": Runtime_Options,
|
||||
"internal": internal,
|
||||
"Issue Identifier": Issue_Identifier,
|
||||
"Filename Parsing": Filename_Parsing,
|
||||
"Sources": Sources,
|
||||
"Comic Book Lover": Comic_Book_Lover,
|
||||
"File Rename": File_Rename,
|
||||
"Auto-Tag": Auto_Tag,
|
||||
"General": General,
|
||||
"Dialog Flags": Dialog_Flags,
|
||||
"Archive": Archive,
|
||||
"Source comicvine": Source_comicvine,
|
||||
},
|
||||
)
|
||||
|
@ -35,11 +35,6 @@ from comictaggerlib.ui.qtutils import center_window_on_parent, reduce_widget_fon
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileTableWidgetItem(QtWidgets.QTableWidgetItem):
|
||||
def __lt__(self, other: object) -> bool:
|
||||
return self.data(QtCore.Qt.ItemDataRole.UserRole) < other.data(QtCore.Qt.ItemDataRole.UserRole) # type: ignore
|
||||
|
||||
|
||||
class FileSelectionList(QtWidgets.QWidget):
|
||||
selectionChanged = QtCore.pyqtSignal(QtCore.QVariant)
|
||||
listCleared = QtCore.pyqtSignal()
|
||||
@ -63,7 +58,7 @@ class FileSelectionList(QtWidgets.QWidget):
|
||||
|
||||
reduce_widget_font_size(self.twList)
|
||||
|
||||
self.twList.setColumnCount(6)
|
||||
self.twList.horizontalHeader().setMinimumSectionSize(50)
|
||||
self.twList.currentItemChanged.connect(self.current_item_changed_cb)
|
||||
|
||||
self.currentItem = None
|
||||
@ -278,8 +273,8 @@ class FileSelectionList(QtWidgets.QWidget):
|
||||
|
||||
filename_item = QtWidgets.QTableWidgetItem()
|
||||
folder_item = QtWidgets.QTableWidgetItem()
|
||||
md_item = FileTableWidgetItem()
|
||||
readonly_item = FileTableWidgetItem()
|
||||
md_item = QtWidgets.QTableWidgetItem()
|
||||
readonly_item = QtWidgets.QTableWidgetItem()
|
||||
type_item = QtWidgets.QTableWidgetItem()
|
||||
|
||||
filename_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
@ -333,9 +328,12 @@ class FileSelectionList(QtWidgets.QWidget):
|
||||
if not ca.is_writable():
|
||||
readonly_item.setCheckState(QtCore.Qt.CheckState.Checked)
|
||||
readonly_item.setData(QtCore.Qt.ItemDataRole.UserRole, True)
|
||||
readonly_item.setText(" ")
|
||||
else:
|
||||
readonly_item.setData(QtCore.Qt.ItemDataRole.UserRole, False)
|
||||
readonly_item.setCheckState(QtCore.Qt.CheckState.Unchecked)
|
||||
# This is a nbsp it sorts after a space ' '
|
||||
readonly_item.setText("\xa0")
|
||||
|
||||
def get_selected_archive_list(self) -> list[ComicArchive]:
|
||||
ca_list: list[ComicArchive] = []
|
||||
|
@ -34,13 +34,19 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImageHasher:
|
||||
def __init__(self, path: str | None = None, data: bytes = b"", width: int = 8, height: int = 8) -> None:
|
||||
def __init__(
|
||||
self, path: str | None = None, image: Image | None = None, data: bytes = b"", width: int = 8, height: int = 8
|
||||
) -> None:
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
if path is None and not data:
|
||||
if path is None and not data and not image:
|
||||
raise OSError
|
||||
|
||||
if image is not None:
|
||||
self.image = image
|
||||
return
|
||||
|
||||
try:
|
||||
if path is not None:
|
||||
self.image = Image.open(path)
|
||||
|
@ -24,8 +24,8 @@ from typing import Any, Callable
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi.comicarchive import ComicArchive, metadata_styles
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comicapi.comicarchive import ComicArchive
|
||||
from comicapi.genericmetadata import ComicSeries, GenericMetadata
|
||||
from comicapi.issuestring import IssueString
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.imagefetcher import ImageFetcher, ImageFetcherException
|
||||
@ -44,17 +44,23 @@ except ImportError:
|
||||
|
||||
|
||||
class SearchKeys(TypedDict):
|
||||
series: str | None
|
||||
issue_number: str | None
|
||||
series: str
|
||||
issue_number: str
|
||||
alternate_number: str | None
|
||||
month: int | None
|
||||
year: int | None
|
||||
issue_count: int | None
|
||||
alternate_count: int | None
|
||||
publisher: str | None
|
||||
imprint: str | None
|
||||
|
||||
|
||||
class Score(TypedDict):
|
||||
score: NotRequired[int]
|
||||
url: str
|
||||
hash: int
|
||||
remote_hash: int
|
||||
local_hash_name: str
|
||||
local_hash: int
|
||||
|
||||
|
||||
class IssueIdentifierNetworkError(Exception): ...
|
||||
@ -71,10 +77,17 @@ class IssueIdentifier:
|
||||
result_one_good_match = 4
|
||||
result_multiple_good_matches = 5
|
||||
|
||||
def __init__(self, comic_archive: ComicArchive, config: ct_ns, talker: ComicTalker) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
comic_archive: ComicArchive,
|
||||
config: ct_ns,
|
||||
talker: ComicTalker,
|
||||
metadata: GenericMetadata = GenericMetadata(),
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.talker = talker
|
||||
self.comic_archive: ComicArchive = comic_archive
|
||||
self.md = metadata
|
||||
self.image_hasher = 1
|
||||
|
||||
self.only_use_additional_meta_data = False
|
||||
@ -139,35 +152,21 @@ class IssueIdentifier:
|
||||
|
||||
return ImageHasher(data=image_data).average_hash()
|
||||
|
||||
def get_aspect_ratio(self, image_data: bytes) -> float:
|
||||
try:
|
||||
im = Image.open(io.BytesIO(image_data))
|
||||
w, h = im.size
|
||||
return float(h) / float(w)
|
||||
except Exception:
|
||||
return 1.5
|
||||
|
||||
def crop_cover(self, image_data: bytes) -> bytes:
|
||||
im = Image.open(io.BytesIO(image_data))
|
||||
def _crop_double_page(self, im: Image.Image) -> Image.Image | None:
|
||||
w, h = im.size
|
||||
|
||||
try:
|
||||
cropped_im = im.crop((int(w / 2), 0, w, h))
|
||||
except Exception:
|
||||
logger.exception("cropCover() error")
|
||||
return b""
|
||||
return None
|
||||
|
||||
output = io.BytesIO()
|
||||
cropped_im.convert("RGB").save(output, format="PNG")
|
||||
cropped_image_data = output.getvalue()
|
||||
output.close()
|
||||
|
||||
return cropped_image_data
|
||||
return cropped_im
|
||||
|
||||
# Adapted from https://stackoverflow.com/a/10616717/20629671
|
||||
def crop_border(self, image_data: bytes, ratio: int) -> bytes | None:
|
||||
im = Image.open(io.BytesIO(image_data))
|
||||
|
||||
def _crop_border(self, im: Image.Image, ratio: int) -> Image.Image | None:
|
||||
assert Image
|
||||
assert ImageChops
|
||||
# RGBA doesn't work????
|
||||
tmp = im.convert("RGB")
|
||||
|
||||
@ -199,11 +198,7 @@ class IssueIdentifier:
|
||||
|
||||
# If there is a difference return the image otherwise return None
|
||||
if width_percent > ratio or height_percent > ratio:
|
||||
output = io.BytesIO()
|
||||
im.crop(bbox).save(output, format="PNG")
|
||||
cropped_image_data = output.getvalue()
|
||||
output.close()
|
||||
return cropped_image_data
|
||||
return im.crop(bbox)
|
||||
return None
|
||||
|
||||
def set_progress_callback(self, cb_func: Callable[[int, int], None]) -> None:
|
||||
@ -212,57 +207,6 @@ class IssueIdentifier:
|
||||
def set_cover_url_callback(self, cb_func: Callable[[bytes], None]) -> None:
|
||||
self.cover_url_callback = cb_func
|
||||
|
||||
def get_search_keys(self) -> SearchKeys:
|
||||
ca = self.comic_archive
|
||||
|
||||
search_keys: SearchKeys
|
||||
if self.only_use_additional_meta_data:
|
||||
search_keys = SearchKeys(
|
||||
series=self.additional_metadata.series,
|
||||
issue_number=self.additional_metadata.issue,
|
||||
year=self.additional_metadata.year,
|
||||
month=self.additional_metadata.month,
|
||||
issue_count=self.additional_metadata.issue_count,
|
||||
)
|
||||
return search_keys
|
||||
|
||||
# see if the archive has any useful meta data for searching with
|
||||
try:
|
||||
for style in metadata_styles:
|
||||
internal_metadata = ca.read_metadata(style)
|
||||
if not internal_metadata.is_empty:
|
||||
break
|
||||
except Exception as e:
|
||||
internal_metadata = GenericMetadata()
|
||||
logger.error("Failed to load metadata for %s: %s", ca.path, e)
|
||||
|
||||
# try to get some metadata from filename
|
||||
md_from_filename = ca.metadata_from_filename(
|
||||
self.config.Filename_Parsing__complicated_parser,
|
||||
self.config.Filename_Parsing__remove_c2c,
|
||||
self.config.Filename_Parsing__remove_fcbd,
|
||||
self.config.Filename_Parsing__remove_publisher,
|
||||
)
|
||||
|
||||
working_md = md_from_filename.copy()
|
||||
|
||||
working_md.overlay(internal_metadata)
|
||||
working_md.overlay(self.additional_metadata)
|
||||
|
||||
# preference order:
|
||||
# 1. Additional metadata
|
||||
# 1. Internal metadata
|
||||
# 1. Filename metadata
|
||||
search_keys = SearchKeys(
|
||||
series=working_md.series,
|
||||
issue_number=working_md.issue,
|
||||
year=working_md.year,
|
||||
month=working_md.month,
|
||||
issue_count=working_md.issue_count,
|
||||
)
|
||||
|
||||
return search_keys
|
||||
|
||||
def log_msg(self, msg: Any) -> None:
|
||||
msg = str(msg)
|
||||
for handler in logging.getLogger().handlers:
|
||||
@ -291,70 +235,62 @@ class IssueIdentifier:
|
||||
# default output is stdout
|
||||
self.output_function(*args, **kwargs)
|
||||
|
||||
def get_issue_cover_match_score(
|
||||
def _get_remote_hashes(self, urls: list[str]) -> list[tuple[str, int]]:
|
||||
remote_hashes: list[tuple[str, int]] = []
|
||||
for url in urls:
|
||||
try:
|
||||
alt_url_image_data = ImageFetcher(self.config.Runtime_Options__config.user_cache_dir).fetch(
|
||||
url, blocking=True
|
||||
)
|
||||
except ImageFetcherException as e:
|
||||
self.log_msg(f"Network issue while fetching alt. cover image from {self.talker.name}. Aborting...")
|
||||
raise IssueIdentifierNetworkError from e
|
||||
|
||||
self._user_canceled(self.cover_url_callback, alt_url_image_data)
|
||||
|
||||
remote_hashes.append((url, self.calculate_hash(alt_url_image_data)))
|
||||
|
||||
if self.cancel:
|
||||
raise IssueIdentifierCancelled
|
||||
return remote_hashes
|
||||
|
||||
def _get_issue_cover_match_score(
|
||||
self,
|
||||
primary_img_url: str,
|
||||
alt_urls: list[str],
|
||||
local_cover_hash_list: list[int],
|
||||
use_remote_alternates: bool = False,
|
||||
local_hashes: list[tuple[str, int]],
|
||||
use_alt_urls: bool = False,
|
||||
) -> Score:
|
||||
# local_cover_hash_list is a list of pre-calculated hashes.
|
||||
# use_remote_alternates - indicates to use alternate covers from CV
|
||||
# local_hashes is a list of pre-calculated hashes.
|
||||
# use_alt_urls - indicates to use alternate covers from CV
|
||||
|
||||
# If there is no URL return 100
|
||||
if not primary_img_url:
|
||||
return Score(score=100, url="", hash=0)
|
||||
return Score(score=100, url="", remote_hash=0)
|
||||
|
||||
try:
|
||||
url_image_data = ImageFetcher(self.config.Runtime_Options__config.user_cache_dir).fetch(
|
||||
primary_img_url, blocking=True
|
||||
)
|
||||
except ImageFetcherException as e:
|
||||
self.log_msg(f"Network issue while fetching cover image from {self.talker.name}. Aborting...")
|
||||
raise IssueIdentifierNetworkError from e
|
||||
self._user_canceled()
|
||||
|
||||
if self.cancel:
|
||||
raise IssueIdentifierCancelled
|
||||
urls = [primary_img_url]
|
||||
if use_alt_urls:
|
||||
urls.extend(alt_urls)
|
||||
self.log_msg(f"[{len(alt_urls)} alt. covers]")
|
||||
|
||||
# alert the GUI, if needed
|
||||
if self.cover_url_callback is not None:
|
||||
self.cover_url_callback(url_image_data)
|
||||
|
||||
remote_cover_list = [Score(url=primary_img_url, hash=self.calculate_hash(url_image_data))]
|
||||
|
||||
if self.cancel:
|
||||
raise IssueIdentifierCancelled
|
||||
|
||||
if use_remote_alternates:
|
||||
for alt_url in alt_urls:
|
||||
try:
|
||||
alt_url_image_data = ImageFetcher(self.config.Runtime_Options__config.user_cache_dir).fetch(
|
||||
alt_url, blocking=True
|
||||
)
|
||||
except ImageFetcherException as e:
|
||||
self.log_msg(f"Network issue while fetching alt. cover image from {self.talker.name}. Aborting...")
|
||||
raise IssueIdentifierNetworkError from e
|
||||
|
||||
if self.cancel:
|
||||
raise IssueIdentifierCancelled
|
||||
|
||||
# alert the GUI, if needed
|
||||
if self.cover_url_callback is not None:
|
||||
self.cover_url_callback(alt_url_image_data)
|
||||
|
||||
remote_cover_list.append(Score(url=alt_url, hash=self.calculate_hash(alt_url_image_data)))
|
||||
|
||||
if self.cancel:
|
||||
raise IssueIdentifierCancelled
|
||||
|
||||
self.log_msg(f"[{len(remote_cover_list) - 1} alt. covers]")
|
||||
remote_hashes = self._get_remote_hashes(urls)
|
||||
|
||||
score_list = []
|
||||
done = False
|
||||
for local_cover_hash in local_cover_hash_list:
|
||||
for remote_cover_item in remote_cover_list:
|
||||
score = ImageHasher.hamming_distance(local_cover_hash, remote_cover_item["hash"])
|
||||
score_list.append(Score(score=score, url=remote_cover_item["url"], hash=remote_cover_item["hash"]))
|
||||
for local_hash in local_hashes:
|
||||
for remote_hash in remote_hashes:
|
||||
score = ImageHasher.hamming_distance(local_hash[1], remote_hash[1])
|
||||
score_list.append(
|
||||
Score(
|
||||
score=score,
|
||||
url=remote_hash[0],
|
||||
remote_hash=remote_hash[1],
|
||||
local_hash_name=local_hash[0],
|
||||
local_hash=local_hash[1],
|
||||
)
|
||||
)
|
||||
|
||||
self.log_msg(f" - {score:03}")
|
||||
|
||||
@ -369,167 +305,181 @@ class IssueIdentifier:
|
||||
|
||||
return best_score_item
|
||||
|
||||
def search(self) -> list[IssueResult]:
|
||||
ca = self.comic_archive
|
||||
self.match_list = []
|
||||
self.cancel = False
|
||||
self.search_result = self.result_no_matches
|
||||
def _check_requirements(self, ca: ComicArchive) -> bool:
|
||||
|
||||
if not pil_available:
|
||||
self.log_msg("Python Imaging Library (PIL) is not available and is needed for issue identification.")
|
||||
return self.match_list
|
||||
return False
|
||||
|
||||
if not ca.seems_to_be_a_comic_archive():
|
||||
self.log_msg(f"Sorry, but {ca.path} is not a comic archive!")
|
||||
return self.match_list
|
||||
return False
|
||||
return True
|
||||
|
||||
cover_image_data = ca.get_page(self.cover_page_index)
|
||||
cover_hash = self.calculate_hash(cover_image_data)
|
||||
def _process_cover(self, name: str, image_data: bytes) -> list[tuple[str, Image.Image]]:
|
||||
assert Image
|
||||
cover_image = Image.open(io.BytesIO(image_data))
|
||||
images = [(name, cover_image)]
|
||||
|
||||
# check the aspect ratio
|
||||
# if it's wider than it is high, it's probably a two page spread
|
||||
# if it's wider than it is high, it's probably a two page spread (back_cover, front_cover)
|
||||
# if so, crop it and calculate a second hash
|
||||
narrow_cover_hash = None
|
||||
aspect_ratio = self.get_aspect_ratio(cover_image_data)
|
||||
aspect_ratio = float(cover_image.height) / float(cover_image.width)
|
||||
if aspect_ratio < 1.0:
|
||||
right_side_image_data = self.crop_cover(cover_image_data)
|
||||
if right_side_image_data is not None:
|
||||
narrow_cover_hash = self.calculate_hash(right_side_image_data)
|
||||
im = self._crop_double_page(cover_image)
|
||||
if im is not None:
|
||||
images.append(("double page", im))
|
||||
|
||||
keys = self.get_search_keys()
|
||||
# normalize the issue number, None will return as ""
|
||||
keys["issue_number"] = IssueString(keys["issue_number"]).as_string()
|
||||
# Check and remove black borders. Helps in identifying comics with an excessive black border like https://comicvine.gamespot.com/marvel-graphic-novel-1-the-death-of-captain-marvel/4000-21782/
|
||||
cropped = self._crop_border(cover_image, self.config.Issue_Identifier__border_crop_percent)
|
||||
if cropped is not None:
|
||||
images.append(("black border cropped", cropped))
|
||||
|
||||
# we need, at minimum, a series and issue number
|
||||
if not (keys["series"] and keys["issue_number"]):
|
||||
self.log_msg("Not enough info for a search!")
|
||||
return []
|
||||
return images
|
||||
|
||||
def _get_images(self, ca: ComicArchive, md: GenericMetadata) -> list[tuple[str, Image.Image]]:
|
||||
covers: list[tuple[str, Image.Image]] = []
|
||||
for cover_index in md.get_cover_page_index_list():
|
||||
image_data = ca.get_page(cover_index)
|
||||
covers.extend(self._process_cover(f"{cover_index}", image_data))
|
||||
return covers
|
||||
|
||||
def _get_extra_images(self, ca: ComicArchive, md: GenericMetadata) -> list[tuple[str, Image.Image]]:
|
||||
assert md
|
||||
covers: list[tuple[str, Image.Image]] = []
|
||||
for cover_index in range(1, min(3, ca.get_number_of_pages())):
|
||||
image_data = ca.get_page(cover_index)
|
||||
covers.extend(self._process_cover(f"{cover_index}", image_data))
|
||||
return covers
|
||||
|
||||
def _get_search_keys(self, md: GenericMetadata) -> Any:
|
||||
search_keys = SearchKeys(
|
||||
series=md.series,
|
||||
issue_number=IssueString(md.issue).as_string(),
|
||||
alternate_number=IssueString(md.alternate_number).as_string(),
|
||||
month=md.month,
|
||||
year=md.year,
|
||||
issue_count=md.issue_count,
|
||||
alternate_count=md.alternate_count,
|
||||
publisher=md.publisher,
|
||||
imprint=md.imprint,
|
||||
)
|
||||
return search_keys
|
||||
|
||||
def _get_search_terms(
|
||||
self, ca: ComicArchive, md: GenericMetadata
|
||||
) -> tuple[SearchKeys, list[tuple[str, Image.Image]], list[tuple[str, Image.Image]]]:
|
||||
return self._get_search_keys(md), self._get_images(ca, md), self._get_extra_images(ca, md)
|
||||
|
||||
def _user_canceled(self, callback: Callable[..., Any] | None = None, *args: Any) -> Any:
|
||||
if self.cancel:
|
||||
raise IssueIdentifierCancelled
|
||||
if callback is not None:
|
||||
return callback(*args)
|
||||
|
||||
def _print_terms(self, keys: SearchKeys, images: list[tuple[str, Image.Image]]) -> None:
|
||||
assert keys["series"]
|
||||
assert keys["issue_number"]
|
||||
self.log_msg(f"Using {self.talker.name} to search for:")
|
||||
self.log_msg("\tSeries: " + keys["series"])
|
||||
self.log_msg("\tIssue: " + keys["issue_number"])
|
||||
if keys["issue_count"] is not None:
|
||||
self.log_msg("\tCount: " + str(keys["issue_count"]))
|
||||
if keys["year"] is not None:
|
||||
self.log_msg("\tYear: " + str(keys["year"]))
|
||||
# if keys["alternate_number"] is not None:
|
||||
# self.log_msg("\tAlternate Issue: " + str(keys["alternate_number"]))
|
||||
if keys["month"] is not None:
|
||||
self.log_msg("\tMonth: " + str(keys["month"]))
|
||||
if keys["year"] is not None:
|
||||
self.log_msg("\tYear: " + str(keys["year"]))
|
||||
if keys["issue_count"] is not None:
|
||||
self.log_msg("\tCount: " + str(keys["issue_count"]))
|
||||
# if keys["alternate_count"] is not None:
|
||||
# self.log_msg("\tAlternate Count: " + str(keys["alternate_count"]))
|
||||
# if keys["publisher"] is not None:
|
||||
# self.log_msg("\tPublisher: " + str(keys["publisher"]))
|
||||
# if keys["imprint"] is not None:
|
||||
# self.log_msg("\tImprint: " + str(keys["imprint"]))
|
||||
for name, _ in images:
|
||||
self.log_msg("Cover: " + name)
|
||||
|
||||
self.log_msg(f"Searching for {keys['series']} #{keys['issue_number']} ...")
|
||||
try:
|
||||
ct_search_results = self.talker.search_for_series(keys["series"])
|
||||
except TalkerError as e:
|
||||
self.log_msg(f"Error searching for series.\n{e}")
|
||||
return []
|
||||
|
||||
if self.cancel:
|
||||
return []
|
||||
def _filter_series(self, terms: SearchKeys, search_results: list[ComicSeries]) -> list[ComicSeries]:
|
||||
assert terms["series"]
|
||||
|
||||
if ct_search_results is None:
|
||||
return []
|
||||
|
||||
series_second_round_list = []
|
||||
|
||||
for item in ct_search_results:
|
||||
filtered_results = []
|
||||
for item in search_results:
|
||||
length_approved = False
|
||||
publisher_approved = True
|
||||
date_approved = True
|
||||
|
||||
# remove any series that starts after the issue year
|
||||
if keys["year"] is not None and item.start_year is not None:
|
||||
if keys["year"] < item.start_year:
|
||||
if terms["year"] is not None and item.start_year is not None:
|
||||
if terms["year"] < item.start_year:
|
||||
date_approved = False
|
||||
|
||||
for name in [item.name, *item.aliases]:
|
||||
if utils.titles_match(keys["series"], name, self.series_match_thresh):
|
||||
if utils.titles_match(terms["series"], name, self.series_match_thresh):
|
||||
length_approved = True
|
||||
break
|
||||
# remove any series from publishers on the filter
|
||||
if item.publisher is not None:
|
||||
publisher = item.publisher
|
||||
if publisher is not None and publisher.casefold() in self.publisher_filter:
|
||||
if item.publisher is not None and item.publisher.casefold() in self.publisher_filter:
|
||||
publisher_approved = False
|
||||
|
||||
if length_approved and publisher_approved and date_approved:
|
||||
series_second_round_list.append(item)
|
||||
|
||||
self.log_msg("Searching in " + str(len(series_second_round_list)) + " series")
|
||||
|
||||
if self.progress_callback is not None:
|
||||
self.progress_callback(0, len(series_second_round_list))
|
||||
|
||||
# now sort the list by name length
|
||||
series_second_round_list.sort(key=lambda x: len(x.name), reverse=False)
|
||||
|
||||
series_by_id = {series.id: series for series in series_second_round_list}
|
||||
|
||||
issue_list = None
|
||||
try:
|
||||
if len(series_by_id) > 0:
|
||||
issue_list = self.talker.fetch_issues_by_series_issue_num_and_year(
|
||||
list(series_by_id.keys()), keys["issue_number"], keys["year"]
|
||||
filtered_results.append(item)
|
||||
else:
|
||||
logger.debug(
|
||||
"Filtered out series: '%s' length approved: '%s', publisher approved: '%s', date approved: '%s'",
|
||||
item.name,
|
||||
length_approved,
|
||||
publisher_approved,
|
||||
date_approved,
|
||||
)
|
||||
except TalkerError as e:
|
||||
self.log_msg(f"Issue with while searching for series details. Aborting...\n{e}")
|
||||
return []
|
||||
return filtered_results
|
||||
|
||||
if issue_list is None:
|
||||
return []
|
||||
def _calculate_hashes(self, images: list[tuple[str, Image.Image]]) -> list[tuple[str, int]]:
|
||||
hashes = []
|
||||
for name, image in images:
|
||||
hashes.append((name, ImageHasher(image=image).average_hash()))
|
||||
return hashes
|
||||
|
||||
shortlist = []
|
||||
# now re-associate the issues and series
|
||||
# is this really needed?
|
||||
for issue in issue_list:
|
||||
if issue.series_id in series_by_id:
|
||||
shortlist.append((series_by_id[issue.series_id], issue))
|
||||
|
||||
if keys["year"] is None:
|
||||
self.log_msg(f"Found {len(shortlist)} series that have an issue #{keys['issue_number']}")
|
||||
else:
|
||||
self.log_msg(
|
||||
f"Found {len(shortlist)} series that have an issue #{keys['issue_number']} from {keys['year']}"
|
||||
)
|
||||
|
||||
# now we have a shortlist of series with the desired issue number
|
||||
# Do first round of cover matching
|
||||
counter = len(shortlist)
|
||||
for series, issue in shortlist:
|
||||
if self.progress_callback is not None:
|
||||
self.progress_callback(counter, len(shortlist) * 3)
|
||||
counter += 1
|
||||
def _match_covers(
|
||||
self,
|
||||
terms: SearchKeys,
|
||||
images: list[tuple[str, Image.Image]],
|
||||
issues: list[tuple[ComicSeries, GenericMetadata]],
|
||||
use_alternates: bool,
|
||||
) -> list[IssueResult]:
|
||||
assert terms["issue_number"]
|
||||
match_results: list[IssueResult] = []
|
||||
hashes = self._calculate_hashes(images)
|
||||
counter = 0
|
||||
alternate = ""
|
||||
if use_alternates:
|
||||
alternate = " Alternate"
|
||||
for series, issue in issues:
|
||||
self._user_canceled(self.progress_callback, counter, len(issues))
|
||||
counter += 1
|
||||
|
||||
self.log_msg(
|
||||
f"Examining covers for ID: {series.id} {series.name} ({series.start_year}):",
|
||||
f"Examining{alternate} covers for Series ID: {series.id} {series.name} ({series.start_year}):",
|
||||
)
|
||||
|
||||
# Now check the cover match against the primary image
|
||||
hash_list = [cover_hash]
|
||||
if narrow_cover_hash is not None:
|
||||
hash_list.append(narrow_cover_hash)
|
||||
|
||||
cropped_border = self.crop_border(cover_image_data, self.config.Issue_Identifier__border_crop_percent)
|
||||
if cropped_border is not None:
|
||||
hash_list.append(self.calculate_hash(cropped_border))
|
||||
logger.info("Adding cropped cover to the hashlist")
|
||||
|
||||
try:
|
||||
image_url = issue._cover_image or ""
|
||||
alt_urls = issue._alternate_images
|
||||
|
||||
score_item = self.get_issue_cover_match_score(
|
||||
image_url, alt_urls, hash_list, use_remote_alternates=False
|
||||
)
|
||||
score_item = self._get_issue_cover_match_score(image_url, alt_urls, hashes, use_alt_urls=use_alternates)
|
||||
except Exception:
|
||||
logger.exception("Scoring series failed")
|
||||
self.match_list = []
|
||||
return self.match_list
|
||||
logger.exception(f"Scoring series{alternate} covers failed")
|
||||
return []
|
||||
|
||||
match = IssueResult(
|
||||
series=f"{series.name} ({series.start_year})",
|
||||
distance=score_item["score"],
|
||||
issue_number=keys["issue_number"],
|
||||
cv_issue_count=series.count_of_issues,
|
||||
url_image_hash=score_item["hash"],
|
||||
issue_number=terms["issue_number"],
|
||||
issue_count=series.count_of_issues,
|
||||
url_image_hash=score_item["remote_hash"],
|
||||
issue_title=issue.title or "",
|
||||
issue_id=issue.issue_id or "",
|
||||
series_id=series.id,
|
||||
@ -543,142 +493,188 @@ class IssueIdentifier:
|
||||
if series.publisher is not None:
|
||||
match.publisher = series.publisher
|
||||
|
||||
self.match_list.append(match)
|
||||
match_results.append(match)
|
||||
|
||||
self.log_msg(f"best score {match.distance:03}")
|
||||
|
||||
self.log_msg("")
|
||||
return match_results
|
||||
|
||||
if len(self.match_list) == 0:
|
||||
def _print_match(self, item: IssueResult) -> None:
|
||||
self.log_msg(
|
||||
"-----> {} #{} {} ({}/{}) -- score: {}".format(
|
||||
item.series,
|
||||
item.issue_number,
|
||||
item.issue_title,
|
||||
item.month,
|
||||
item.year,
|
||||
item.distance,
|
||||
)
|
||||
)
|
||||
|
||||
def _search_for_issues(self, terms: SearchKeys) -> list[tuple[ComicSeries, GenericMetadata]]:
|
||||
try:
|
||||
search_results = self.talker.search_for_series(
|
||||
terms["series"],
|
||||
callback=lambda x, y: self._user_canceled(self.progress_callback, x, y),
|
||||
series_match_thresh=self.config.Issue_Identifier__series_match_search_thresh,
|
||||
)
|
||||
except TalkerError as e:
|
||||
self.log_msg(f"Error searching for series.\n{e}")
|
||||
return []
|
||||
# except IssueIdentifierCancelled:
|
||||
# return []
|
||||
|
||||
if not search_results:
|
||||
return []
|
||||
|
||||
filtered_series = self._filter_series(terms, search_results)
|
||||
if not filtered_series:
|
||||
return []
|
||||
|
||||
self.log_msg(f"Searching in {len(filtered_series)} series")
|
||||
|
||||
self._user_canceled(self.progress_callback, 0, len(filtered_series))
|
||||
|
||||
series_by_id = {series.id: series for series in filtered_series}
|
||||
|
||||
try:
|
||||
talker_result = self.talker.fetch_issues_by_series_issue_num_and_year(
|
||||
list(series_by_id.keys()), terms["issue_number"], terms["year"]
|
||||
)
|
||||
except TalkerError as e:
|
||||
self.log_msg(f"Issue with while searching for series details. Aborting...\n{e}")
|
||||
return []
|
||||
# except IssueIdentifierCancelled:
|
||||
# return []
|
||||
|
||||
if not talker_result:
|
||||
return []
|
||||
|
||||
self._user_canceled(self.progress_callback, 0, 0)
|
||||
|
||||
issues: list[tuple[ComicSeries, GenericMetadata]] = []
|
||||
|
||||
# now re-associate the issues and series
|
||||
for issue in talker_result:
|
||||
if issue.series_id in series_by_id:
|
||||
issues.append((series_by_id[issue.series_id], issue))
|
||||
else:
|
||||
logger.warning("Talker '%s' is returning arbitrary series when searching by id", self.talker.id)
|
||||
return issues
|
||||
|
||||
def _cover_matching(
|
||||
self,
|
||||
terms: SearchKeys,
|
||||
images: list[tuple[str, Image.Image]],
|
||||
extra_images: list[tuple[str, Image.Image]],
|
||||
issues: list[tuple[ComicSeries, GenericMetadata]],
|
||||
) -> list[IssueResult]:
|
||||
cover_matching_1 = self._match_covers(terms, images, issues, use_alternates=False)
|
||||
|
||||
if len(cover_matching_1) == 0:
|
||||
self.log_msg(":-( no matches!")
|
||||
self.search_result = self.result_no_matches
|
||||
return self.match_list
|
||||
return cover_matching_1
|
||||
|
||||
# sort list by image match scores
|
||||
self.match_list.sort(key=attrgetter("distance"))
|
||||
cover_matching_1.sort(key=attrgetter("distance"))
|
||||
|
||||
lst = []
|
||||
for i in self.match_list:
|
||||
for i in cover_matching_1:
|
||||
lst.append(i.distance)
|
||||
|
||||
self.log_msg(f"Compared to covers in {len(self.match_list)} issue(s): {lst}")
|
||||
self.log_msg(f"Compared to covers in {len(cover_matching_1)} issue(s): {lst}")
|
||||
|
||||
def print_match(item: IssueResult) -> None:
|
||||
self.log_msg(
|
||||
"-----> {} #{} {} ({}/{}) -- score: {}".format(
|
||||
item.series,
|
||||
item.issue_number,
|
||||
item.issue_title,
|
||||
item.month,
|
||||
item.year,
|
||||
item.distance,
|
||||
)
|
||||
)
|
||||
|
||||
best_score: int = self.match_list[0].distance
|
||||
|
||||
if best_score >= self.min_score_thresh:
|
||||
cover_matching_2 = []
|
||||
final_cover_matching = cover_matching_1
|
||||
if cover_matching_1[0].distance >= self.min_score_thresh:
|
||||
# we have 1 or more low-confidence matches (all bad cover scores)
|
||||
# look at a few more pages in the archive, and also alternate covers online
|
||||
self.log_msg("Very weak scores for the cover. Analyzing alternate pages and covers...")
|
||||
hash_list = [cover_hash]
|
||||
if narrow_cover_hash is not None:
|
||||
hash_list.append(narrow_cover_hash)
|
||||
for page_index in range(1, min(3, ca.get_number_of_pages())):
|
||||
image_data = ca.get_page(page_index)
|
||||
page_hash = self.calculate_hash(image_data)
|
||||
hash_list.append(page_hash)
|
||||
|
||||
second_match_list = []
|
||||
counter = 2 * len(self.match_list)
|
||||
for m in self.match_list:
|
||||
if self.progress_callback is not None:
|
||||
self.progress_callback(counter, len(self.match_list) * 3)
|
||||
counter += 1
|
||||
self.log_msg(f"Examining alternate covers for ID: {m.series_id} {m.series}:")
|
||||
try:
|
||||
score_item = self.get_issue_cover_match_score(
|
||||
m.image_url,
|
||||
m.alt_image_urls,
|
||||
hash_list,
|
||||
use_remote_alternates=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("failed examining alt covers")
|
||||
self.match_list = []
|
||||
return self.match_list
|
||||
self.log_msg(f"--->{score_item['score']}")
|
||||
self.log_msg("")
|
||||
temp = self._match_covers(terms, images + extra_images, issues, use_alternates=True)
|
||||
for score in temp:
|
||||
if score.distance < self.min_alternate_score_thresh:
|
||||
cover_matching_2.append(score)
|
||||
|
||||
if score_item["score"] < self.min_alternate_score_thresh:
|
||||
second_match_list.append(m)
|
||||
m.distance = score_item["score"]
|
||||
if len(cover_matching_2) > 0:
|
||||
# We did good, found something!
|
||||
self.log_msg("Success in secondary/alternate cover matching!")
|
||||
|
||||
if len(second_match_list) == 0:
|
||||
if len(self.match_list) == 1:
|
||||
self.log_msg("No matching pages in the issue.")
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
print_match(self.match_list[0])
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
self.search_result = self.result_found_match_but_bad_cover_score
|
||||
else:
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
self.log_msg("Multiple bad cover matches! Need to use other info...")
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
self.search_result = self.result_multiple_matches_with_bad_image_scores
|
||||
return self.match_list
|
||||
|
||||
# We did good, found something!
|
||||
self.log_msg("Success in secondary/alternate cover matching!")
|
||||
|
||||
self.match_list = second_match_list
|
||||
# sort new list by image match scores
|
||||
self.match_list.sort(key=attrgetter("distance"))
|
||||
best_score = self.match_list[0].distance
|
||||
self.log_msg("[Second round cover matching: best score = {best_score}]")
|
||||
# now drop down into the rest of the processing
|
||||
|
||||
if self.progress_callback is not None:
|
||||
self.progress_callback(99, 100)
|
||||
final_cover_matching = cover_matching_2
|
||||
# sort new list by image match scores
|
||||
final_cover_matching.sort(key=attrgetter("distance"))
|
||||
self.log_msg("[Second round cover matching: best score = {best_score}]")
|
||||
# now drop down into the rest of the processing
|
||||
|
||||
best_score = final_cover_matching[0].distance
|
||||
# now pare down list, remove any item more than specified distant from the top scores
|
||||
for match_item in reversed(self.match_list):
|
||||
if match_item.distance > best_score + self.min_score_distance:
|
||||
self.match_list.remove(match_item)
|
||||
for match_item in reversed(final_cover_matching):
|
||||
if match_item.distance > (best_score + self.min_score_distance):
|
||||
final_cover_matching.remove(match_item)
|
||||
return final_cover_matching
|
||||
|
||||
def identify(self, ca: ComicArchive, md: GenericMetadata) -> tuple[int, list[IssueResult]]:
|
||||
if not self._check_requirements(ca):
|
||||
return self.result_no_matches, []
|
||||
|
||||
terms, images, extra_images = self._get_search_terms(ca, md)
|
||||
|
||||
# we need, at minimum, a series and issue number
|
||||
if not (terms["series"] and terms["issue_number"]):
|
||||
self.log_msg("Not enough info for a search!")
|
||||
return self.result_no_matches, []
|
||||
|
||||
self._print_terms(terms, images)
|
||||
|
||||
issues = self._search_for_issues(terms)
|
||||
|
||||
self.log_msg(f"Found {len(issues)} series that have an issue #{terms['issue_number']}")
|
||||
|
||||
final_cover_matching = self._cover_matching(terms, images, extra_images, issues)
|
||||
|
||||
# One more test for the case choosing limited series first issue vs a trade with the same cover:
|
||||
# if we have a given issue count > 1 and the series from CV has count==1, remove it from match list
|
||||
if len(self.match_list) >= 2 and keys["issue_count"] is not None and keys["issue_count"] != 1:
|
||||
new_list = []
|
||||
for match in self.match_list:
|
||||
if match.cv_issue_count != 1:
|
||||
new_list.append(match)
|
||||
else:
|
||||
if len(final_cover_matching) > 1 and terms["issue_count"] is not None and terms["issue_count"] != 1:
|
||||
for match in final_cover_matching.copy():
|
||||
if match.issue_count == 1:
|
||||
self.log_msg(
|
||||
f"Removing series {match.series} [{match.series_id}] from consideration (only 1 issue)"
|
||||
)
|
||||
final_cover_matching.remove(match)
|
||||
|
||||
if len(new_list) > 0:
|
||||
self.match_list = new_list
|
||||
|
||||
if len(self.match_list) == 1:
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
print_match(self.match_list[0])
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
self.search_result = self.result_one_good_match
|
||||
|
||||
elif len(self.match_list) == 0:
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
self.log_msg("No matches found :(")
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
self.search_result = self.result_no_matches
|
||||
best_score = final_cover_matching[0].distance
|
||||
if best_score >= self.min_score_thresh:
|
||||
if len(final_cover_matching) == 1:
|
||||
self.log_msg("No matching pages in the issue.")
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
self._print_match(final_cover_matching[0])
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
search_result = self.result_found_match_but_bad_cover_score
|
||||
else:
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
self.log_msg("Multiple bad cover matches! Need to use other info...")
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
search_result = self.result_multiple_matches_with_bad_image_scores
|
||||
else:
|
||||
# we've got multiple good matches:
|
||||
self.log_msg("More than one likely candidate.")
|
||||
self.search_result = self.result_multiple_good_matches
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
for match_item in self.match_list:
|
||||
print_match(match_item)
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
if len(final_cover_matching) == 1:
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
self._print_match(final_cover_matching[0])
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
search_result = self.result_one_good_match
|
||||
|
||||
return self.match_list
|
||||
elif len(self.match_list) == 0:
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
self.log_msg("No matches found :(")
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
search_result = self.result_no_matches
|
||||
else:
|
||||
# we've got multiple good matches:
|
||||
self.log_msg("More than one likely candidate.")
|
||||
search_result = self.result_multiple_good_matches
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
for match_item in final_cover_matching:
|
||||
self._print_match(match_item)
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
|
||||
return search_result, final_cover_matching
|
||||
|
@ -84,7 +84,7 @@ class RenameWindow(QtWidgets.QDialog):
|
||||
md = ca.read_metadata(self.data_style)
|
||||
if md.is_empty:
|
||||
md = ca.metadata_from_filename(
|
||||
self.config[0].Filename_Parsing__complicated_parser,
|
||||
self.config[0].Filename_Parsing__filename_parser,
|
||||
self.config[0].Filename_Parsing__remove_c2c,
|
||||
self.config[0].Filename_Parsing__remove_fcbd,
|
||||
self.config[0].Filename_Parsing__remove_publisher,
|
||||
|
@ -2,58 +2,18 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import pathlib
|
||||
import sys
|
||||
from enum import Enum, auto
|
||||
from typing import Any
|
||||
from enum import auto
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
"""
|
||||
Enum where members are also (and must be) strings
|
||||
"""
|
||||
|
||||
def __new__(cls, *values: Any) -> Any:
|
||||
"values must already be of type `str`"
|
||||
if len(values) > 3:
|
||||
raise TypeError(f"too many arguments for str(): {values!r}")
|
||||
if len(values) == 1:
|
||||
# it must be a string
|
||||
if not isinstance(values[0], str):
|
||||
raise TypeError(f"{values[0]!r} is not a string")
|
||||
if len(values) >= 2:
|
||||
# check that encoding argument is a string
|
||||
if not isinstance(values[1], str):
|
||||
raise TypeError(f"encoding must be a string, not {values[1]!r}")
|
||||
if len(values) == 3:
|
||||
# check that errors argument is a string
|
||||
if not isinstance(values[2], str):
|
||||
raise TypeError("errors must be a string, not %r" % (values[2]))
|
||||
value = str(*values)
|
||||
member = str.__new__(cls, value)
|
||||
member._value_ = value
|
||||
return member
|
||||
|
||||
@staticmethod
|
||||
def _generate_next_value_(name: str, start: int, count: int, last_values: Any) -> str:
|
||||
"""
|
||||
Return the lower-cased version of the member name.
|
||||
"""
|
||||
return name.lower()
|
||||
|
||||
else:
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class IssueResult:
|
||||
series: str
|
||||
distance: int
|
||||
issue_number: str
|
||||
cv_issue_count: int | None
|
||||
issue_count: int | None
|
||||
url_image_hash: int
|
||||
issue_title: str
|
||||
issue_id: str
|
||||
@ -69,7 +29,7 @@ class IssueResult:
|
||||
return f"series: {self.series}; series id: {self.series_id}; issue number: {self.issue_number}; issue id: {self.issue_id}; published: {self.month} {self.year}"
|
||||
|
||||
|
||||
class Action(StrEnum):
|
||||
class Action(utils.StrEnum):
|
||||
print = auto()
|
||||
delete = auto()
|
||||
copy = auto()
|
||||
@ -80,14 +40,14 @@ class Action(StrEnum):
|
||||
list_plugins = auto()
|
||||
|
||||
|
||||
class MatchStatus(StrEnum):
|
||||
class MatchStatus(utils.StrEnum):
|
||||
good_match = auto()
|
||||
no_match = auto()
|
||||
multiple_match = auto()
|
||||
low_confidence_match = auto()
|
||||
|
||||
|
||||
class Status(StrEnum):
|
||||
class Status(utils.StrEnum):
|
||||
success = auto()
|
||||
match_failure = auto()
|
||||
write_failure = auto()
|
||||
|
@ -33,6 +33,7 @@ from comictaggerlib.issueidentifier import IssueIdentifier
|
||||
from comictaggerlib.issueselectionwindow import IssueSelectionWindow
|
||||
from comictaggerlib.matchselectionwindow import MatchSelectionWindow
|
||||
from comictaggerlib.progresswindow import IDProgressWindow
|
||||
from comictaggerlib.resulttypes import IssueResult
|
||||
from comictaggerlib.ui import qtutils, ui_path
|
||||
from comictaggerlib.ui.qtutils import new_web_view, reduce_widget_font_size
|
||||
from comictalker.comictalker import ComicTalker, TalkerError
|
||||
@ -76,15 +77,17 @@ class SearchThread(QtCore.QThread):
|
||||
|
||||
|
||||
class IdentifyThread(QtCore.QThread):
|
||||
identifyComplete = pyqtSignal()
|
||||
identifyComplete = pyqtSignal((int, list))
|
||||
identifyLogMsg = pyqtSignal(str)
|
||||
identifyProgress = pyqtSignal(int, int)
|
||||
|
||||
def __init__(self, identifier: IssueIdentifier) -> None:
|
||||
def __init__(self, identifier: IssueIdentifier, ca: ComicArchive, md: GenericMetadata) -> None:
|
||||
QtCore.QThread.__init__(self)
|
||||
self.identifier = identifier
|
||||
self.identifier.set_output_function(self.log_output)
|
||||
self.identifier.set_progress_callback(self.progress_callback)
|
||||
self.ca = ca
|
||||
self.md = md
|
||||
|
||||
def log_output(self, text: str) -> None:
|
||||
self.identifyLogMsg.emit(str(text))
|
||||
@ -93,8 +96,7 @@ class IdentifyThread(QtCore.QThread):
|
||||
self.identifyProgress.emit(cur, total)
|
||||
|
||||
def run(self) -> None:
|
||||
self.identifier.search()
|
||||
self.identifyComplete.emit()
|
||||
self.identifyComplete.emit(*self.identifier.identify(self.ca, self.md))
|
||||
|
||||
|
||||
class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
@ -245,12 +247,12 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
md.year = self.year
|
||||
md.issue_count = self.issue_count
|
||||
|
||||
self.ii.set_additional_metadata(md)
|
||||
self.ii.only_use_additional_meta_data = True
|
||||
# self.ii.set_additional_metadata(md)
|
||||
# self.ii.only_use_additional_meta_data = True
|
||||
|
||||
self.ii.cover_page_index = int(self.cover_index_list[0])
|
||||
# self.ii.cover_page_index = int(self.cover_index_list[0])
|
||||
|
||||
self.id_thread = IdentifyThread(self.ii)
|
||||
self.id_thread = IdentifyThread(self.ii, self.comic_archive, md)
|
||||
self.id_thread.identifyComplete.connect(self.identify_complete)
|
||||
self.id_thread.identifyLogMsg.connect(self.log_id_output)
|
||||
self.id_thread.identifyProgress.connect(self.identify_progress)
|
||||
@ -276,35 +278,33 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
if self.ii is not None:
|
||||
self.ii.cancel = True
|
||||
|
||||
def identify_complete(self) -> None:
|
||||
if self.ii is not None and self.iddialog is not None and self.comic_archive is not None:
|
||||
matches = self.ii.match_list
|
||||
result = self.ii.search_result
|
||||
def identify_complete(self, result: int, issues: list[IssueResult]) -> None:
|
||||
if self.iddialog is not None and self.comic_archive is not None:
|
||||
|
||||
found_match = None
|
||||
choices = False
|
||||
if result == self.ii.result_no_matches:
|
||||
QtWidgets.QMessageBox.information(self, "Auto-Select Result", " No matches found :-(")
|
||||
elif result == self.ii.result_found_match_but_bad_cover_score:
|
||||
if result == IssueIdentifier.result_no_matches:
|
||||
QtWidgets.QMessageBox.information(self, "Auto-Select Result", " No issues found :-(")
|
||||
elif result == IssueIdentifier.result_found_match_but_bad_cover_score:
|
||||
QtWidgets.QMessageBox.information(
|
||||
self,
|
||||
"Auto-Select Result",
|
||||
" Found a match, but cover doesn't seem the same. Verify before committing!",
|
||||
)
|
||||
found_match = matches[0]
|
||||
elif result == self.ii.result_found_match_but_not_first_page:
|
||||
found_match = issues[0]
|
||||
elif result == IssueIdentifier.result_found_match_but_not_first_page:
|
||||
QtWidgets.QMessageBox.information(
|
||||
self, "Auto-Select Result", " Found a match, but not with the first page of the archive."
|
||||
)
|
||||
found_match = matches[0]
|
||||
elif result == self.ii.result_multiple_matches_with_bad_image_scores:
|
||||
found_match = issues[0]
|
||||
elif result == IssueIdentifier.result_multiple_matches_with_bad_image_scores:
|
||||
QtWidgets.QMessageBox.information(
|
||||
self, "Auto-Select Result", " Found some possibilities, but no confidence. Proceed manually."
|
||||
)
|
||||
choices = True
|
||||
elif result == self.ii.result_one_good_match:
|
||||
found_match = matches[0]
|
||||
elif result == self.ii.result_multiple_good_matches:
|
||||
elif result == IssueIdentifier.result_one_good_match:
|
||||
found_match = issues[0]
|
||||
elif result == IssueIdentifier.result_multiple_good_matches:
|
||||
QtWidgets.QMessageBox.information(
|
||||
self, "Auto-Select Result", " Found multiple likely matches. Please select."
|
||||
)
|
||||
@ -312,7 +312,7 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
|
||||
if choices:
|
||||
selector = MatchSelectionWindow(
|
||||
self, matches, self.comic_archive, talker=self.talker, config=self.config
|
||||
self, issues, self.comic_archive, talker=self.talker, config=self.config
|
||||
)
|
||||
selector.setModal(True)
|
||||
selector.exec()
|
||||
|
@ -29,9 +29,11 @@ from PyQt5 import QtCore, QtGui, QtWidgets, uic
|
||||
|
||||
import comictaggerlib.ui.talkeruigenerator
|
||||
from comicapi import utils
|
||||
from comicapi.archivers.archiver import Archiver
|
||||
from comicapi.genericmetadata import md_test
|
||||
from comictaggerlib import ctsettings
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.ctsettings.plugin import group_for_plugin
|
||||
from comictaggerlib.filerenamer import FileRenamer, Replacement, Replacements
|
||||
from comictaggerlib.ui import ui_path
|
||||
from comictalker.comictalker import ComicTalker
|
||||
@ -155,7 +157,6 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
self.lblRarHelp.setText(linuxRarHelp)
|
||||
|
||||
elif platform.system() == "Darwin":
|
||||
self.leRarExePath.setReadOnly(False)
|
||||
|
||||
self.lblRarHelp.setText(macRarHelp)
|
||||
self.name = "Preferences"
|
||||
@ -191,6 +192,8 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
self.sources = comictaggerlib.ui.talkeruigenerator.generate_source_option_tabs(
|
||||
self.tComicTalkers, self.config, self.talkers
|
||||
)
|
||||
self.cbFilenameParser.clear()
|
||||
self.cbFilenameParser.addItems(utils.Parser)
|
||||
self.connect_signals()
|
||||
self.settings_to_form()
|
||||
self.rename_test()
|
||||
@ -208,7 +211,7 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
self.btnTemplateHelp.clicked.connect(self.show_template_help)
|
||||
self.cbxMoveFiles.clicked.connect(self.dir_test)
|
||||
self.leDirectory.textEdited.connect(self.dir_test)
|
||||
self.cbxComplicatedParser.clicked.connect(self.switch_parser)
|
||||
self.cbFilenameParser.currentIndexChanged.connect(self.switch_parser)
|
||||
|
||||
self.btnAddLiteralReplacement.clicked.connect(self.addLiteralReplacement)
|
||||
self.btnAddValueReplacement.clicked.connect(self.addValueReplacement)
|
||||
@ -243,7 +246,7 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
self.btnResetSettings.clicked.disconnect()
|
||||
self.btnTemplateHelp.clicked.disconnect()
|
||||
self.cbxChangeExtension.clicked.disconnect()
|
||||
self.cbxComplicatedParser.clicked.disconnect()
|
||||
self.cbFilenameParser.currentIndexChanged.disconnect()
|
||||
self.cbxMoveFiles.clicked.disconnect()
|
||||
self.cbxRenameStrict.clicked.disconnect()
|
||||
self.cbxSmartCleanup.clicked.disconnect()
|
||||
@ -272,9 +275,10 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
self._filename_parser_test(self.leFilenameParserTest.text())
|
||||
|
||||
def _filename_parser_test(self, filename: str) -> None:
|
||||
self.cbFilenameParser: QtWidgets.QComboBox
|
||||
filename_info = utils.parse_filename(
|
||||
filename=filename,
|
||||
complicated_parser=self.cbxComplicatedParser.isChecked(),
|
||||
parser=utils.Parser(self.cbFilenameParser.currentText()),
|
||||
remove_c2c=self.cbxRemoveC2C.isChecked(),
|
||||
remove_fcbd=self.cbxRemoveFCBD.isChecked(),
|
||||
remove_publisher=self.cbxRemovePublisher.isChecked(),
|
||||
@ -357,8 +361,9 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
self.lblRenameTest.setText(str(e))
|
||||
|
||||
def switch_parser(self) -> None:
|
||||
complicated = self.cbxComplicatedParser.isChecked()
|
||||
currentParser = utils.Parser(self.cbFilenameParser.currentText())
|
||||
|
||||
complicated = currentParser == utils.Parser.COMPLICATED
|
||||
self.cbxRemoveC2C.setEnabled(complicated)
|
||||
self.cbxRemoveFCBD.setEnabled(complicated)
|
||||
self.cbxRemovePublisher.setEnabled(complicated)
|
||||
@ -367,8 +372,9 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
def settings_to_form(self) -> None:
|
||||
self.disconnect_signals()
|
||||
# Copy values from settings to form
|
||||
if "archiver" in self.config[1] and "rar" in self.config[1]["archiver"].v:
|
||||
self.leRarExePath.setText(getattr(self.config[0], self.config[1]["archiver"].v["rar"].internal_name))
|
||||
archive_group = group_for_plugin(Archiver)
|
||||
if archive_group in self.config[1] and "rar" in self.config[1][archive_group].v:
|
||||
self.leRarExePath.setText(getattr(self.config[0], self.config[1][archive_group].v["rar"].internal_name))
|
||||
else:
|
||||
self.leRarExePath.setEnabled(False)
|
||||
self.sbNameMatchIdentifyThresh.setValue(self.config[0].Issue_Identifier__series_match_identify_thresh)
|
||||
@ -378,7 +384,7 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
self.cbxCheckForNewVersion.setChecked(self.config[0].General__check_for_new_version)
|
||||
self.cbxShortMetadataNames.setChecked(self.config[0].General__use_short_metadata_names)
|
||||
|
||||
self.cbxComplicatedParser.setChecked(self.config[0].Filename_Parsing__complicated_parser)
|
||||
self.cbFilenameParser.setCurrentText(self.config[0].Filename_Parsing__filename_parser)
|
||||
self.cbxRemoveC2C.setChecked(self.config[0].Filename_Parsing__remove_c2c)
|
||||
self.cbxRemoveFCBD.setChecked(self.config[0].Filename_Parsing__remove_fcbd)
|
||||
self.cbxRemovePublisher.setChecked(self.config[0].Filename_Parsing__remove_publisher)
|
||||
@ -482,11 +488,12 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
)
|
||||
|
||||
# Copy values from form to settings and save
|
||||
if "archiver" in self.config[1] and "rar" in self.config[1]["archiver"].v:
|
||||
setattr(self.config[0], self.config[1]["archiver"].v["rar"].internal_name, str(self.leRarExePath.text()))
|
||||
archive_group = group_for_plugin(Archiver)
|
||||
if archive_group in self.config[1] and "rar" in self.config[1][archive_group].v:
|
||||
setattr(self.config[0], self.config[1][archive_group].v["rar"].internal_name, str(self.leRarExePath.text()))
|
||||
|
||||
# make sure rar program is now in the path for the rar class
|
||||
if self.config[0].archiver_rar: # type: ignore[attr-defined]
|
||||
if self.config[0].Archive__rar:
|
||||
utils.add_to_path(os.path.dirname(str(self.leRarExePath.text())))
|
||||
|
||||
if not str(self.leIssueNumPadding.text()).isdigit():
|
||||
@ -504,7 +511,7 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
self.config[0].Issue_Identifier__series_match_search_thresh = self.sbNameMatchSearchThresh.value()
|
||||
self.config[0].Issue_Identifier__publisher_filter = utils.split(self.tePublisherFilter.toPlainText(), "\n")
|
||||
|
||||
self.config[0].Filename_Parsing__complicated_parser = self.cbxComplicatedParser.isChecked()
|
||||
self.config[0].Filename_Parsing__filename_parser = utils.Parser(self.cbFilenameParser.currentText())
|
||||
self.config[0].Filename_Parsing__remove_c2c = self.cbxRemoveC2C.isChecked()
|
||||
self.config[0].Filename_Parsing__remove_fcbd = self.cbxRemoveFCBD.isChecked()
|
||||
self.config[0].Filename_Parsing__remove_publisher = self.cbxRemovePublisher.isChecked()
|
||||
|
@ -1004,7 +1004,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
# copy the form onto metadata object
|
||||
self.form_to_metadata()
|
||||
new_metadata = self.comic_archive.metadata_from_filename(
|
||||
self.config[0].Filename_Parsing__complicated_parser,
|
||||
self.config[0].Filename_Parsing__filename_parser,
|
||||
self.config[0].Filename_Parsing__remove_c2c,
|
||||
self.config[0].Filename_Parsing__remove_fcbd,
|
||||
self.config[0].Filename_Parsing__remove_publisher,
|
||||
@ -1044,6 +1044,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
dialog.setNameFilters(filters)
|
||||
dialog.setFileMode(QtWidgets.QFileDialog.FileMode.ExistingFiles)
|
||||
|
||||
if os.environ.get("XDG_SESSION_DESKTOP", "") == "KDE":
|
||||
dialog.setOption(QtWidgets.QFileDialog.Option.DontUseNativeDialog)
|
||||
|
||||
if self.config[0].internal__last_opened_folder is not None:
|
||||
dialog.setDirectory(self.config[0].internal__last_opened_folder)
|
||||
return dialog
|
||||
@ -1144,40 +1147,44 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
|
||||
def commit_metadata(self) -> None:
|
||||
if self.metadata is not None and self.comic_archive is not None:
|
||||
reply = QtWidgets.QMessageBox.question(
|
||||
self,
|
||||
"Save Tags",
|
||||
f"Are you sure you wish to save {', '.join([metadata_styles[style].name() for style in self.save_data_styles])} tags to this archive?",
|
||||
QtWidgets.QMessageBox.StandardButton.Yes,
|
||||
QtWidgets.QMessageBox.StandardButton.No,
|
||||
)
|
||||
if self.config[0].General__prompt_on_save:
|
||||
reply = QtWidgets.QMessageBox.question(
|
||||
self,
|
||||
"Save Tags",
|
||||
f"Are you sure you wish to save {', '.join([metadata_styles[style].name() for style in self.save_data_styles])} tags to this archive?",
|
||||
QtWidgets.QMessageBox.StandardButton.Yes,
|
||||
QtWidgets.QMessageBox.StandardButton.No,
|
||||
)
|
||||
else:
|
||||
reply = QtWidgets.QMessageBox.StandardButton.Yes
|
||||
|
||||
if reply == QtWidgets.QMessageBox.StandardButton.Yes:
|
||||
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
|
||||
self.form_to_metadata()
|
||||
if reply != QtWidgets.QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
|
||||
self.form_to_metadata()
|
||||
|
||||
failed_style: str = ""
|
||||
# Save each style
|
||||
for style in self.save_data_styles:
|
||||
success = self.comic_archive.write_metadata(self.metadata, style)
|
||||
if not success:
|
||||
failed_style = metadata_styles[style].name()
|
||||
break
|
||||
failed_style: str = ""
|
||||
# Save each style
|
||||
for style in self.save_data_styles:
|
||||
success = self.comic_archive.write_metadata(self.metadata, style)
|
||||
if not success:
|
||||
failed_style = metadata_styles[style].name()
|
||||
break
|
||||
|
||||
self.comic_archive.load_cache(list(metadata_styles))
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
self.comic_archive.load_cache(list(metadata_styles))
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
if failed_style:
|
||||
QtWidgets.QMessageBox.warning(
|
||||
self,
|
||||
"Save failed",
|
||||
f"The tag save operation seemed to fail for: {failed_style}",
|
||||
)
|
||||
else:
|
||||
self.clear_dirty_flag()
|
||||
self.update_info_box()
|
||||
self.update_menus()
|
||||
self.fileSelectionList.update_current_row()
|
||||
if failed_style:
|
||||
QtWidgets.QMessageBox.warning(
|
||||
self,
|
||||
"Save failed",
|
||||
f"The tag save operation seemed to fail for: {failed_style}",
|
||||
)
|
||||
else:
|
||||
self.clear_dirty_flag()
|
||||
self.update_info_box()
|
||||
self.update_menus()
|
||||
self.fileSelectionList.update_current_row()
|
||||
|
||||
self.metadata = self.comic_archive.read_metadata(self.load_data_style)
|
||||
self.update_ui_for_archive()
|
||||
@ -1725,7 +1732,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
logger.error("Failed to load metadata for %s: %s", ca.path, e)
|
||||
if md.is_empty:
|
||||
md = ca.metadata_from_filename(
|
||||
self.config[0].Filename_Parsing__complicated_parser,
|
||||
self.config[0].Filename_Parsing__filename_parser,
|
||||
self.config[0].Filename_Parsing__remove_c2c,
|
||||
self.config[0].Filename_Parsing__remove_fcbd,
|
||||
self.config[0].Filename_Parsing__remove_publisher,
|
||||
@ -1752,17 +1759,15 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
md.issue = "1"
|
||||
else:
|
||||
md.issue = utils.xlate(md.volume)
|
||||
ii.set_additional_metadata(md)
|
||||
ii.only_use_additional_meta_data = True
|
||||
# ii.set_additional_metadata(md)
|
||||
# ii.only_use_additional_meta_data = True
|
||||
ii.set_output_function(self.auto_tag_log)
|
||||
ii.cover_page_index = md.get_cover_page_index_list()[0]
|
||||
# ii.cover_page_index = md.get_cover_page_index_list()[0]
|
||||
if self.atprogdialog is not None:
|
||||
ii.set_cover_url_callback(self.atprogdialog.set_test_image)
|
||||
ii.set_name_series_match_threshold(dlg.name_length_match_tolerance)
|
||||
|
||||
matches: list[IssueResult] = ii.search()
|
||||
|
||||
result = ii.search_result
|
||||
result, matches = ii.identify(ca, md)
|
||||
|
||||
found_match = False
|
||||
choices = False
|
||||
|
@ -41,64 +41,6 @@
|
||||
<string/>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="2" column="0">
|
||||
<widget class="QPushButton" name="btnResetSettings">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Default Settings</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QPushButton" name="btnClearCache">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Clear Cache</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>If you need to free up the disk space, or the responses seems out of date, clear the online cache.</string>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QLabel" name="lblDefaultSettings">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Revert to default settings</string>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0" colspan="2">
|
||||
<widget class="QCheckBox" name="cbxCheckForNewVersion">
|
||||
<property name="text">
|
||||
@ -116,6 +58,77 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QPushButton" name="btnResetSettings">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Default Settings</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QPushButton" name="btnClearCache">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Clear Cache</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QLabel" name="lblDefaultSettings">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Revert to default settings</string>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>btnResetSettings</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>If you need to free up the disk space, or the responses seems out of date, clear the online cache.</string>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>btnClearCache</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QCheckBox" name="cbxPromptOnSave">
|
||||
<property name="text">
|
||||
<string>Prompts the user to confirm saving tags</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
@ -166,6 +179,9 @@
|
||||
<property name="text">
|
||||
<string>Default Name Match Ratio Threshold: Search:</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>sbNameMatchSearchThresh</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
@ -176,6 +192,9 @@
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>sbNameMatchIdentifyThresh</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
@ -183,6 +202,9 @@
|
||||
<property name="text">
|
||||
<string>Always use Publisher Filter on "manual" searches:</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>cbxUseFilter</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
@ -200,6 +222,9 @@
|
||||
<property name="text">
|
||||
<string>Publisher Filter:</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>tePublisherFilter</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
@ -301,9 +326,19 @@
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_7">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="cbxComplicatedParser">
|
||||
<widget class="QLabel" name="lblFilenamearser">
|
||||
<property name="text">
|
||||
<string>Use "Complicated" Parser</string>
|
||||
<string>Select the filename parser</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>cbFilenameParser</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="cbFilenameParser">
|
||||
<property name="insertPolicy">
|
||||
<enum>QComboBox::NoInsert</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
@ -539,6 +574,9 @@
|
||||
<property name="text">
|
||||
<string>Template:</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>leRenameTemplate</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
@ -569,6 +607,9 @@
|
||||
<property name="text">
|
||||
<string>Issue # Zero Padding</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>leIssueNumPadding</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
@ -622,6 +663,9 @@
|
||||
<property name="text">
|
||||
<string>Destination Directory:</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>leDirectory</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="9" column="1">
|
||||
@ -727,6 +771,9 @@
|
||||
<property name="text">
|
||||
<string>Value Text Replacements</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>twValueReplacements</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0" colspan="2">
|
||||
@ -734,6 +781,9 @@
|
||||
<property name="text">
|
||||
<string>Literal Text Replacements</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>twLiteralReplacements</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
@ -769,6 +819,9 @@
|
||||
<property name="text">
|
||||
<string>RAR program</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>leRarExePath</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
@ -780,7 +833,7 @@
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
|
@ -927,6 +927,9 @@
|
||||
<property name="acceptDrops">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="acceptRichText">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
@ -941,6 +944,9 @@
|
||||
<property name="acceptDrops">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="acceptRichText">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
@ -1053,6 +1059,9 @@
|
||||
<property name="acceptDrops">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="acceptRichText">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
@ -1079,6 +1088,9 @@
|
||||
<property name="acceptDrops">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="acceptRichText">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
@ -1105,6 +1117,9 @@
|
||||
<property name="acceptDrops">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="acceptRichText">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
@ -1137,6 +1152,9 @@
|
||||
<property name="acceptDrops">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="acceptRichText">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
|
@ -37,6 +37,7 @@ install_requires =
|
||||
appdirs==1.4.4
|
||||
beautifulsoup4>=4.1
|
||||
chardet>=5.1.0,<6
|
||||
comicfn2dict>=0.2.0
|
||||
importlib-metadata>=3.3.0
|
||||
isocodes>=2023.11.26
|
||||
natsort>=8.1.0
|
||||
@ -46,7 +47,7 @@ install_requires =
|
||||
pyrate-limiter>=2.6,<3
|
||||
rapidfuzz>=2.12.0
|
||||
requests==2.*
|
||||
settngs==0.9.2
|
||||
settngs==0.10.0
|
||||
text2digits
|
||||
typing-extensions>=4.3.0
|
||||
wordninja
|
||||
|
@ -78,33 +78,17 @@ metadata = [
|
||||
|
||||
metadata_keys = [
|
||||
(
|
||||
comicapi.genericmetadata.GenericMetadata(),
|
||||
comicapi.genericmetadata.md_test,
|
||||
{
|
||||
"issue_count": 6,
|
||||
"issue_number": "1",
|
||||
"month": 10,
|
||||
"series": "Cory Doctorow's Futuristic Tales of the Here and Now",
|
||||
"year": 2007,
|
||||
},
|
||||
),
|
||||
(
|
||||
comicapi.genericmetadata.GenericMetadata(series="test"),
|
||||
{
|
||||
"issue_count": 6,
|
||||
"issue_number": "1",
|
||||
"month": 10,
|
||||
"series": "test",
|
||||
"year": 2007,
|
||||
},
|
||||
),
|
||||
(
|
||||
comicapi.genericmetadata.GenericMetadata(series="test", issue="3"),
|
||||
{
|
||||
"issue_count": 6,
|
||||
"issue_number": "3",
|
||||
"month": 10,
|
||||
"series": "test",
|
||||
"year": 2007,
|
||||
"alternate_count": 7,
|
||||
"alternate_number": "2",
|
||||
"imprint": "craphound.com",
|
||||
"publisher": "IDW Publishing",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
@ -5,6 +5,7 @@ import io
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
import comictaggerlib.imagehasher
|
||||
import comictaggerlib.issueidentifier
|
||||
import testing.comicdata
|
||||
import testing.comicvine
|
||||
@ -13,12 +14,16 @@ from comictaggerlib.resulttypes import IssueResult
|
||||
|
||||
def test_crop(cbz_double_cover, config, tmp_path, comicvine_api):
|
||||
config, definitions = config
|
||||
ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz_double_cover, config, comicvine_api)
|
||||
cropped = ii.crop_cover(cbz_double_cover.archiver.read_file("double_cover.jpg"))
|
||||
original_cover = cbz_double_cover.get_page(0)
|
||||
|
||||
original_hash = ii.calculate_hash(original_cover)
|
||||
cropped_hash = ii.calculate_hash(cropped)
|
||||
ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz_double_cover, config, comicvine_api)
|
||||
|
||||
im = Image.open(io.BytesIO(cbz_double_cover.archiver.read_file("double_cover.jpg")))
|
||||
|
||||
cropped = ii._crop_double_page(im)
|
||||
original = cbz_double_cover.get_page(0)
|
||||
|
||||
original_hash = comictaggerlib.imagehasher.ImageHasher(data=original).average_hash()
|
||||
cropped_hash = comictaggerlib.imagehasher.ImageHasher(image=cropped).average_hash()
|
||||
|
||||
assert original_hash == cropped_hash
|
||||
|
||||
@ -27,23 +32,24 @@ def test_crop(cbz_double_cover, config, tmp_path, comicvine_api):
|
||||
def test_get_search_keys(cbz, config, additional_md, expected, comicvine_api):
|
||||
config, definitions = config
|
||||
ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, config, comicvine_api)
|
||||
ii.set_additional_metadata(additional_md)
|
||||
|
||||
assert expected == ii.get_search_keys()
|
||||
assert expected == ii._get_search_keys(additional_md)
|
||||
|
||||
|
||||
def test_get_issue_cover_match_score(cbz, config, comicvine_api):
|
||||
config, definitions = config
|
||||
ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, config, comicvine_api)
|
||||
score = ii.get_issue_cover_match_score(
|
||||
score = ii._get_issue_cover_match_score(
|
||||
"https://comicvine.gamespot.com/a/uploads/scale_large/0/574/585444-109004_20080707014047_large.jpg",
|
||||
["https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/"],
|
||||
[ii.calculate_hash(cbz.get_page(0))],
|
||||
[("Cover 1", ii.calculate_hash(cbz.get_page(0)))],
|
||||
)
|
||||
expected = {
|
||||
"hash": 212201432349720,
|
||||
"remote_hash": 212201432349720,
|
||||
"score": 0,
|
||||
"url": "https://comicvine.gamespot.com/a/uploads/scale_large/0/574/585444-109004_20080707014047_large.jpg",
|
||||
"local_hash": 212201432349720,
|
||||
"local_hash_name": "Cover 1",
|
||||
}
|
||||
assert expected == score
|
||||
|
||||
@ -51,13 +57,13 @@ def test_get_issue_cover_match_score(cbz, config, comicvine_api):
|
||||
def test_search(cbz, config, comicvine_api):
|
||||
config, definitions = config
|
||||
ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, config, comicvine_api)
|
||||
results = ii.search()
|
||||
result, issues = ii.identify(cbz, cbz.read_metadata("cr"))
|
||||
cv_expected = IssueResult(
|
||||
series=f"{testing.comicvine.cv_volume_result['results']['name']} ({testing.comicvine.cv_volume_result['results']['start_year']})",
|
||||
distance=0,
|
||||
issue_number=testing.comicvine.cv_issue_result["results"]["issue_number"],
|
||||
alt_image_urls=[],
|
||||
cv_issue_count=testing.comicvine.cv_volume_result["results"]["count_of_issues"],
|
||||
issue_count=testing.comicvine.cv_volume_result["results"]["count_of_issues"],
|
||||
issue_title=testing.comicvine.cv_issue_result["results"]["name"],
|
||||
issue_id=str(testing.comicvine.cv_issue_result["results"]["id"]),
|
||||
series_id=str(testing.comicvine.cv_volume_result["results"]["id"]),
|
||||
@ -68,7 +74,7 @@ def test_search(cbz, config, comicvine_api):
|
||||
description=testing.comicvine.cv_issue_result["results"]["description"],
|
||||
url_image_hash=212201432349720,
|
||||
)
|
||||
for r, e in zip(results, [cv_expected]):
|
||||
for r, e in zip(issues, [cv_expected]):
|
||||
assert r == e
|
||||
|
||||
|
||||
@ -80,14 +86,10 @@ def test_crop_border(cbz, config, comicvine_api):
|
||||
bg = Image.new("RGBA", (100, 100), (0, 0, 0, 255))
|
||||
fg = Image.new("RGBA", (50, 50), (255, 255, 255, 255))
|
||||
bg.paste(fg, (bg.width // 2 - (fg.width // 2), bg.height // 2 - (fg.height // 2)))
|
||||
output = io.BytesIO()
|
||||
bg.save(output, format="PNG")
|
||||
image_data = output.getvalue()
|
||||
output.close()
|
||||
|
||||
cropped = ii.crop_border(image_data, 49)
|
||||
cropped = ii._crop_border(bg, 49)
|
||||
|
||||
im = Image.open(io.BytesIO(cropped))
|
||||
assert im.width == fg.width
|
||||
assert im.height == fg.height
|
||||
assert list(im.getdata()) == list(fg.getdata())
|
||||
assert cropped
|
||||
assert cropped.width == fg.width
|
||||
assert cropped.height == fg.height
|
||||
assert list(cropped.getdata()) == list(fg.getdata())
|
||||
|
Reference in New Issue
Block a user