Display message when a ratelimit is hit
This commit is contained in:
parent
92ce2987ea
commit
63c836a327
@ -18,7 +18,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Callable
|
||||
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets, uic
|
||||
|
||||
@ -40,7 +39,6 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
|
||||
parent: QtWidgets.QWidget,
|
||||
match_set_list: list[Result],
|
||||
read_tags: list[str],
|
||||
fetch_func: Callable[[IssueResult], GenericMetadata],
|
||||
config: ct_ns,
|
||||
talker: ComicTalker,
|
||||
) -> None:
|
||||
@ -79,7 +77,6 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
|
||||
|
||||
self.match_set_list = match_set_list
|
||||
self._tags = read_tags
|
||||
self.fetch_func = fetch_func
|
||||
|
||||
self.current_match_set_idx = 0
|
||||
|
||||
@ -89,6 +86,9 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
|
||||
|
||||
self.update_data()
|
||||
|
||||
def fetch(self, match: IssueResult) -> GenericMetadata:
|
||||
return self.current_talker().fetch_comic_data(issue_id=match.issue_id)
|
||||
|
||||
def update_data(self) -> None:
|
||||
self.current_match_set = self.match_set_list[self.current_match_set_idx]
|
||||
|
||||
@ -248,7 +248,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
|
||||
# now get the particular issue data
|
||||
|
||||
try:
|
||||
self.current_match_set.md = ct_md = self.fetch_func(match)
|
||||
self.current_match_set.md = ct_md = self.fetch(match)
|
||||
except TalkerError as e:
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
QtWidgets.QMessageBox.critical(self, f"{e.source} {e.code_name} Error", f"{e}")
|
||||
|
@ -17,16 +17,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
from PyQt5 import QtCore, QtWidgets, uic
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi.comicarchive import ComicArchive, tags
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comictaggerlib.coverimagewidget import CoverImageWidget
|
||||
from comictaggerlib.ctsettings.settngs_namespace import SettngsNS
|
||||
from comictaggerlib.md import read_selected_tags
|
||||
from comictaggerlib.resulttypes import Action, OnlineMatchResults, Result, Status
|
||||
from comictaggerlib.tag import identify_comic
|
||||
from comictaggerlib.ui import ui_path
|
||||
from comictalker.comictalker import ComicTalker
|
||||
from comictalker.comictalker import ComicTalker, RLCallBack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AutoTagThread(QtCore.QThread):
|
||||
autoTagComplete = QtCore.pyqtSignal(OnlineMatchResults, list)
|
||||
autoTagLogMsg = QtCore.pyqtSignal(str)
|
||||
autoTagProgress = QtCore.pyqtSignal(object, object, object, bytes, bytes) # see progress_callback
|
||||
ratelimit = QtCore.pyqtSignal(float, float)
|
||||
|
||||
def __init__(
|
||||
self, series_override: str, ca_list: list[ComicArchive], config: SettngsNS, talker: ComicTalker
|
||||
) -> None:
|
||||
QtCore.QThread.__init__(self)
|
||||
self.series_override = series_override
|
||||
self.ca_list = ca_list
|
||||
self.config = config
|
||||
self.talker = talker
|
||||
self.canceled = False
|
||||
|
||||
def log_output(self, text: str) -> None:
|
||||
self.autoTagLogMsg.emit(str(text))
|
||||
|
||||
def progress_callback(
|
||||
self, cur: int | None, total: int | None, path: pathlib.Path | None, archive_image: bytes, remote_image: bytes
|
||||
) -> None:
|
||||
self.autoTagProgress.emit(cur, total, path, archive_image, remote_image)
|
||||
|
||||
def run(self) -> None:
|
||||
match_results = OnlineMatchResults()
|
||||
archives_to_remove = []
|
||||
for prog_idx, ca in enumerate(self.ca_list):
|
||||
self.log_output("==========================================================================\n")
|
||||
self.log_output(f"Auto-Tagging {prog_idx} of {len(self.ca_list)}\n")
|
||||
self.log_output(f"{ca.path}\n")
|
||||
try:
|
||||
cover_idx = ca.read_tags(self.config.internal__read_tags[0]).get_cover_page_index_list()[0]
|
||||
except Exception as e:
|
||||
cover_idx = 0
|
||||
logger.error("Failed to load metadata for %s: %s", ca.path, e)
|
||||
image_data = ca.get_page(cover_idx)
|
||||
self.progress_callback(prog_idx, len(self.ca_list), ca.path, image_data, b"")
|
||||
|
||||
if self.canceled:
|
||||
break
|
||||
|
||||
if ca.is_writable():
|
||||
success, match_results = self.identify_and_tag_single_archive(ca, match_results)
|
||||
if self.canceled:
|
||||
break
|
||||
|
||||
if success and self.config.internal__remove_archive_after_successful_match:
|
||||
archives_to_remove.append(ca)
|
||||
self.autoTagComplete.emit(match_results, archives_to_remove)
|
||||
|
||||
def on_rate_limit(self, full_time: float, sleep_time: float) -> None:
|
||||
self.log_output(
|
||||
f"Rate limit reached: {full_time:.0f}s until next request. Waiting {sleep_time:.0f}s for ratelimit"
|
||||
)
|
||||
self.ratelimit.emit(full_time, sleep_time)
|
||||
|
||||
def identify_and_tag_single_archive(
|
||||
self, ca: ComicArchive, match_results: OnlineMatchResults
|
||||
) -> tuple[Result, OnlineMatchResults]:
|
||||
|
||||
ratelimit_callback = RLCallBack(
|
||||
self.on_rate_limit,
|
||||
60,
|
||||
)
|
||||
|
||||
# read in tags, and parse file name if not there
|
||||
md, tags_used, error = read_selected_tags(self.config.internal__read_tags, ca)
|
||||
if error is not None:
|
||||
QtWidgets.QMessageBox.warning(
|
||||
None,
|
||||
"Aborting...",
|
||||
f"One or more of the read tags failed to load for {ca.path}. Aborting to prevent any possible further damage. Check log for details.",
|
||||
)
|
||||
logger.error("Failed to load tags from %s: %s", ca.path, error)
|
||||
return (
|
||||
Result(
|
||||
Action.save,
|
||||
original_path=ca.path,
|
||||
status=Status.read_failure,
|
||||
),
|
||||
match_results,
|
||||
)
|
||||
|
||||
if md.is_empty:
|
||||
md = ca.metadata_from_filename(
|
||||
self.config.Filename_Parsing__filename_parser,
|
||||
self.config.Filename_Parsing__remove_c2c,
|
||||
self.config.Filename_Parsing__remove_fcbd,
|
||||
self.config.Filename_Parsing__remove_publisher,
|
||||
self.config.Filename_Parsing__split_words,
|
||||
self.config.Filename_Parsing__allow_issue_start_with_letter,
|
||||
self.config.Filename_Parsing__protofolius_issue_number_scheme,
|
||||
)
|
||||
if self.config.Auto_Tag__ignore_leading_numbers_in_filename and md.series is not None:
|
||||
# remove all leading numbers
|
||||
md.series = re.sub(r"(^[\d.]*)(.*)", r"\2", md.series)
|
||||
|
||||
# use the dialog specified search string
|
||||
if self.series_override:
|
||||
md.series = self.series_override
|
||||
|
||||
if not self.config.Auto_Tag__use_year_when_identifying:
|
||||
md.year = None
|
||||
# If it's empty we need it to stay empty for identify_comic to report the correct error
|
||||
if (md.issue is None or md.issue == "") and not md.is_empty:
|
||||
if self.config.Auto_Tag__assume_issue_one:
|
||||
md.issue = "1"
|
||||
else:
|
||||
md.issue = utils.xlate(md.volume)
|
||||
|
||||
def on_progress(x: int, y: int, image: bytes) -> None:
|
||||
# We don't (currently) care about the progress of an individual comic here we just want the cover for the autotagprogresswindow
|
||||
self.progress_callback(None, None, None, b"", image)
|
||||
|
||||
if self.canceled:
|
||||
return (
|
||||
Result(
|
||||
Action.save,
|
||||
original_path=ca.path,
|
||||
status=Status.read_failure,
|
||||
),
|
||||
match_results,
|
||||
)
|
||||
res, match_results = identify_comic(
|
||||
ca,
|
||||
md,
|
||||
tags_used,
|
||||
match_results,
|
||||
self.config,
|
||||
self.talker,
|
||||
self.log_output,
|
||||
on_rate_limit=ratelimit_callback,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
if self.canceled:
|
||||
return res, match_results
|
||||
|
||||
if res.status == Status.success:
|
||||
assert res.md
|
||||
|
||||
def write_Tags(ca: ComicArchive, md: GenericMetadata) -> bool:
|
||||
for tag_id in self.config.Runtime_Options__tags_write:
|
||||
# write out the new data
|
||||
if not ca.write_tags(md, tag_id):
|
||||
self.log_output(f"{tags[tag_id].name()} save failed! Aborting any additional tag saves.\n")
|
||||
return False
|
||||
return True
|
||||
|
||||
# Save tags
|
||||
if write_Tags(ca, res.md):
|
||||
match_results.good_matches.append(res)
|
||||
res.tags_written = self.config.Runtime_Options__tags_write
|
||||
self.log_output("Save complete!\n")
|
||||
else:
|
||||
res.status = Status.write_failure
|
||||
match_results.write_failures.append(res)
|
||||
|
||||
ca.reset_cache()
|
||||
ca.load_cache({*self.config.Runtime_Options__tags_read})
|
||||
|
||||
return res, match_results
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.canceled = True
|
||||
|
||||
|
||||
class AutoTagProgressWindow(QtWidgets.QDialog):
|
||||
def __init__(self, parent: QtWidgets.QWidget, talker: ComicTalker) -> None:
|
||||
super().__init__(parent)
|
||||
@ -46,8 +222,6 @@ class AutoTagProgressWindow(QtWidgets.QDialog):
|
||||
gridlayout.addWidget(self.testCoverWidget)
|
||||
gridlayout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.isdone = False
|
||||
|
||||
self.setWindowFlags(
|
||||
QtCore.Qt.WindowType(
|
||||
self.windowFlags()
|
||||
@ -67,6 +241,20 @@ class AutoTagProgressWindow(QtWidgets.QDialog):
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
|
||||
# @QtCore.pyqtSlot(int, int, 'Optional[pathlib.Path]', bytes, bytes)
|
||||
def on_progress(
|
||||
self, x: int | None, y: int | None, title: pathlib.Path | None, archive_image: bytes, remote_image: bytes
|
||||
) -> None:
|
||||
if x is not None and y is not None:
|
||||
self.progressBar: QtWidgets.QProgressBar
|
||||
self.progressBar.setValue(x)
|
||||
self.progressBar.setMaximum(y)
|
||||
if title:
|
||||
self.setWindowTitle(str(title))
|
||||
if archive_image:
|
||||
self.set_archive_image(archive_image)
|
||||
if remote_image:
|
||||
self.set_test_image(remote_image)
|
||||
|
||||
def reject(self) -> None:
|
||||
QtWidgets.QDialog.reject(self)
|
||||
self.isdone = True
|
||||
|
@ -17,14 +17,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Collection
|
||||
from functools import partial
|
||||
from typing import Any, TextIO
|
||||
|
||||
from comicapi import merge, utils
|
||||
@ -34,10 +33,10 @@ from comictaggerlib.cbltransformer import CBLTransformer
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.filerenamer import FileRenamer, get_rename_dir
|
||||
from comictaggerlib.graphics import graphics_path
|
||||
from comictaggerlib.issueidentifier import IssueIdentifier
|
||||
from comictaggerlib.md import prepare_metadata
|
||||
from comictaggerlib.quick_tag import QuickTag
|
||||
from comictaggerlib.resulttypes import Action, IssueResult, MatchStatus, OnlineMatchResults, Result, Status
|
||||
from comictaggerlib.tag import identify_comic
|
||||
from comictalker.comictalker import ComicTalker, TalkerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -130,7 +129,7 @@ class CLI:
|
||||
def fetch_metadata(self, issue_id: str) -> GenericMetadata:
|
||||
# now get the particular issue data
|
||||
try:
|
||||
ct_md = self.current_talker().fetch_comic_data(issue_id)
|
||||
ct_md = self.current_talker().fetch_comic_data(issue_id=issue_id, on_rate_limit=None)
|
||||
except TalkerError as e:
|
||||
logger.exception(f"Error retrieving issue details. Save aborted.\n{e}")
|
||||
return GenericMetadata()
|
||||
@ -441,123 +440,6 @@ class CLI:
|
||||
logger.exception("Quick Tagging failed")
|
||||
return None
|
||||
|
||||
def normal_tag(
|
||||
self, ca: ComicArchive, tags_read: list[str], md: GenericMetadata, match_results: OnlineMatchResults
|
||||
) -> tuple[GenericMetadata, list[IssueResult], Result | None, OnlineMatchResults]:
|
||||
# ct_md, results, matches, match_results
|
||||
if md is None or md.is_empty:
|
||||
logger.error("No metadata given to search online with!")
|
||||
res = Result(
|
||||
Action.save,
|
||||
status=Status.match_failure,
|
||||
original_path=ca.path,
|
||||
match_status=MatchStatus.no_match,
|
||||
tags_written=self.config.Runtime_Options__tags_write,
|
||||
tags_read=tags_read,
|
||||
)
|
||||
match_results.no_matches.append(res)
|
||||
return GenericMetadata(), [], res, match_results
|
||||
|
||||
ii = IssueIdentifier(ca, self.config, self.current_talker())
|
||||
|
||||
ii.set_output_function(functools.partial(self.output, already_logged=True))
|
||||
if not self.config.Auto_Tag__use_year_when_identifying:
|
||||
md.year = None
|
||||
if self.config.Auto_Tag__ignore_leading_numbers_in_filename and md.series is not None:
|
||||
md.series = re.sub(r"^([\d.]+)(.*)", r"\2", md.series)
|
||||
result, matches = ii.identify(ca, md)
|
||||
|
||||
found_match = False
|
||||
choices = False
|
||||
low_confidence = False
|
||||
|
||||
if result == IssueIdentifier.result_no_matches:
|
||||
pass
|
||||
elif result == IssueIdentifier.result_found_match_but_bad_cover_score:
|
||||
low_confidence = True
|
||||
found_match = True
|
||||
elif result == IssueIdentifier.result_found_match_but_not_first_page:
|
||||
found_match = True
|
||||
elif result == IssueIdentifier.result_multiple_matches_with_bad_image_scores:
|
||||
low_confidence = True
|
||||
choices = True
|
||||
elif result == IssueIdentifier.result_one_good_match:
|
||||
found_match = True
|
||||
elif result == IssueIdentifier.result_multiple_good_matches:
|
||||
choices = True
|
||||
|
||||
if choices:
|
||||
if low_confidence:
|
||||
logger.error("Online search: Multiple low confidence matches. Save aborted")
|
||||
res = Result(
|
||||
Action.save,
|
||||
status=Status.match_failure,
|
||||
original_path=ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.low_confidence_match,
|
||||
tags_written=self.config.Runtime_Options__tags_write,
|
||||
tags_read=tags_read,
|
||||
)
|
||||
match_results.low_confidence_matches.append(res)
|
||||
return GenericMetadata(), matches, res, match_results
|
||||
|
||||
logger.error("Online search: Multiple good matches. Save aborted")
|
||||
res = Result(
|
||||
Action.save,
|
||||
status=Status.match_failure,
|
||||
original_path=ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.multiple_match,
|
||||
tags_written=self.config.Runtime_Options__tags_write,
|
||||
tags_read=tags_read,
|
||||
)
|
||||
match_results.multiple_matches.append(res)
|
||||
return GenericMetadata(), matches, res, match_results
|
||||
if low_confidence and self.config.Runtime_Options__abort_on_low_confidence:
|
||||
logger.error("Online search: Low confidence match. Save aborted")
|
||||
res = Result(
|
||||
Action.save,
|
||||
status=Status.match_failure,
|
||||
original_path=ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.low_confidence_match,
|
||||
tags_written=self.config.Runtime_Options__tags_write,
|
||||
tags_read=tags_read,
|
||||
)
|
||||
match_results.low_confidence_matches.append(res)
|
||||
return GenericMetadata(), matches, res, match_results
|
||||
if not found_match:
|
||||
logger.error("Online search: No match found. Save aborted")
|
||||
res = Result(
|
||||
Action.save,
|
||||
status=Status.match_failure,
|
||||
original_path=ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.no_match,
|
||||
tags_written=self.config.Runtime_Options__tags_write,
|
||||
tags_read=tags_read,
|
||||
)
|
||||
match_results.no_matches.append(res)
|
||||
return GenericMetadata(), matches, res, match_results
|
||||
|
||||
# we got here, so we have a single match
|
||||
|
||||
# now get the particular issue data
|
||||
ct_md = self.fetch_metadata(matches[0].issue_id)
|
||||
if ct_md.is_empty:
|
||||
res = Result(
|
||||
Action.save,
|
||||
status=Status.fetch_data_failure,
|
||||
original_path=ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.good_match,
|
||||
tags_written=self.config.Runtime_Options__tags_write,
|
||||
tags_read=tags_read,
|
||||
)
|
||||
match_results.fetch_data_failures.append(res)
|
||||
return GenericMetadata(), matches, res, match_results
|
||||
return ct_md, matches, None, match_results
|
||||
|
||||
def save(self, ca: ComicArchive, match_results: OnlineMatchResults) -> tuple[Result, OnlineMatchResults]:
|
||||
if self.config.Runtime_Options__skip_existing_tags:
|
||||
for tag_id in self.config.Runtime_Options__tags_write:
|
||||
@ -568,7 +450,6 @@ class CLI:
|
||||
Action.save,
|
||||
original_path=ca.path,
|
||||
status=Status.existing_tags,
|
||||
tags_written=self.config.Runtime_Options__tags_write,
|
||||
),
|
||||
match_results,
|
||||
)
|
||||
@ -581,22 +462,30 @@ class CLI:
|
||||
if self.config.Auto_Tag__assume_issue_one:
|
||||
md.issue = "1"
|
||||
|
||||
matches: list[IssueResult] = []
|
||||
# matches: list[IssueResult] = []
|
||||
# now, search online
|
||||
|
||||
ct_md = GenericMetadata()
|
||||
res = Result(
|
||||
Action.save,
|
||||
status=Status.success,
|
||||
original_path=ca.path,
|
||||
md=prepare_metadata(md, ct_md, self.config),
|
||||
tags_read=tags_read,
|
||||
)
|
||||
if self.config.Auto_Tag__online:
|
||||
if self.config.Auto_Tag__issue_id is not None:
|
||||
# we were given the actual issue ID to search with
|
||||
try:
|
||||
ct_md = self.current_talker().fetch_comic_data(self.config.Auto_Tag__issue_id)
|
||||
ct_md = self.current_talker().fetch_comic_data(
|
||||
issue_id=self.config.Auto_Tag__issue_id, on_rate_limit=None
|
||||
)
|
||||
except TalkerError as e:
|
||||
logger.exception(f"Error retrieving issue details. Save aborted.\n{e}")
|
||||
res = Result(
|
||||
Action.save,
|
||||
original_path=ca.path,
|
||||
status=Status.fetch_data_failure,
|
||||
tags_written=self.config.Runtime_Options__tags_write,
|
||||
tags_read=tags_read,
|
||||
)
|
||||
match_results.fetch_data_failures.append(res)
|
||||
@ -609,53 +498,69 @@ class CLI:
|
||||
status=Status.match_failure,
|
||||
original_path=ca.path,
|
||||
match_status=MatchStatus.no_match,
|
||||
tags_written=self.config.Runtime_Options__tags_write,
|
||||
tags_read=tags_read,
|
||||
)
|
||||
match_results.no_matches.append(res)
|
||||
return res, match_results
|
||||
res = Result(
|
||||
Action.save,
|
||||
status=Status.success,
|
||||
original_path=ca.path,
|
||||
match_status=MatchStatus.good_match,
|
||||
md=prepare_metadata(md, ct_md, self.config),
|
||||
tags_read=tags_read,
|
||||
)
|
||||
|
||||
else:
|
||||
qt_md = self.try_quick_tag(ca, md)
|
||||
if qt_md is None or qt_md.is_empty:
|
||||
if qt_md is not None and not qt_md.is_empty:
|
||||
ct_md = qt_md
|
||||
res = Result(
|
||||
Action.save,
|
||||
status=Status.success,
|
||||
original_path=ca.path,
|
||||
online_results=[
|
||||
IssueResult(
|
||||
series=ct_md.series or "",
|
||||
distance=-1,
|
||||
issue_number=ct_md.issue or "",
|
||||
issue_count=ct_md.issue_count,
|
||||
url_image_hash=-1,
|
||||
issue_title=ct_md.title or "",
|
||||
issue_id=ct_md.issue_id or "",
|
||||
series_id=ct_md.issue_id or "",
|
||||
month=ct_md.month,
|
||||
year=ct_md.year,
|
||||
publisher=ct_md.publisher,
|
||||
image_url=ct_md._cover_image or "",
|
||||
alt_image_urls=[],
|
||||
description=ct_md.description or "",
|
||||
)
|
||||
],
|
||||
match_status=MatchStatus.good_match,
|
||||
md=prepare_metadata(md, ct_md, self.config),
|
||||
tags_read=tags_read,
|
||||
)
|
||||
else:
|
||||
if qt_md is not None:
|
||||
self.output("Failed to find match via quick tag")
|
||||
ct_md, matches, res, match_results = self.normal_tag(ca, tags_read, md, match_results) # type: ignore[assignment]
|
||||
if res is not None:
|
||||
return res, match_results
|
||||
else:
|
||||
self.output("Successfully matched via quick tag")
|
||||
ct_md = qt_md
|
||||
matches = [
|
||||
IssueResult(
|
||||
series=ct_md.series or "",
|
||||
distance=-1,
|
||||
issue_number=ct_md.issue or "",
|
||||
issue_count=ct_md.issue_count,
|
||||
url_image_hash=-1,
|
||||
issue_title=ct_md.title or "",
|
||||
issue_id=ct_md.issue_id or "",
|
||||
series_id=ct_md.issue_id or "",
|
||||
month=ct_md.month,
|
||||
year=ct_md.year,
|
||||
publisher=None,
|
||||
image_url=ct_md._cover_image or "",
|
||||
alt_image_urls=[],
|
||||
description=ct_md.description or "",
|
||||
)
|
||||
]
|
||||
|
||||
res = Result(
|
||||
Action.save,
|
||||
status=Status.success,
|
||||
original_path=ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.good_match,
|
||||
md=prepare_metadata(md, ct_md, self.config),
|
||||
tags_written=self.config.Runtime_Options__tags_write,
|
||||
tags_read=tags_read,
|
||||
)
|
||||
res, match_results = identify_comic(
|
||||
ca,
|
||||
md,
|
||||
tags_read,
|
||||
match_results,
|
||||
self.config,
|
||||
self.current_talker(),
|
||||
partial(self.output, already_logged=True),
|
||||
on_rate_limit=None,
|
||||
)
|
||||
if res.status != Status.success:
|
||||
return res, match_results
|
||||
self.output("Successfully matched via quick tag")
|
||||
assert res.md
|
||||
|
||||
res.tags_written = self.config.Runtime_Options__tags_write
|
||||
# ok, done building our metadata. time to save
|
||||
if self.write_tags(ca, res.md):
|
||||
match_results.good_matches.append(res)
|
||||
|
@ -128,7 +128,8 @@ def open_tagger_window(
|
||||
ctypes.windll.user32.SetWindowPos(console_wnd, None, 0, 0, 0, 0, swp_hidewindow) # type: ignore[attr-defined]
|
||||
|
||||
if platform.system() != "Linux":
|
||||
img = QtGui.QPixmap(str(graphics_path / "tags.png"))
|
||||
img = QtGui.QPixmap()
|
||||
img.loadFromData((graphics_path / "tags.png").read_bytes())
|
||||
|
||||
splash = QtWidgets.QSplashScreen(img)
|
||||
splash.show()
|
||||
|
@ -16,8 +16,12 @@
|
||||
# limitations under the License.
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
import io
|
||||
import logging
|
||||
import pathlib
|
||||
from enum import Enum, auto
|
||||
from operator import attrgetter
|
||||
from typing import Any, Callable
|
||||
|
||||
@ -27,11 +31,10 @@ from comicapi import utils
|
||||
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
|
||||
from comictaggerlib.imagehasher import ImageHasher
|
||||
from comictaggerlib.resulttypes import IssueResult
|
||||
from comictalker.comictalker import ComicTalker, TalkerError
|
||||
from comictalker.comictalker import ComicTalker, RLCallBack, TalkerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -69,25 +72,36 @@ class IssueIdentifierNetworkError(Exception): ...
|
||||
class IssueIdentifierCancelled(Exception): ...
|
||||
|
||||
|
||||
class IssueIdentifier:
|
||||
result_no_matches = 0
|
||||
result_found_match_but_bad_cover_score = 1
|
||||
result_found_match_but_not_first_page = 2
|
||||
result_multiple_matches_with_bad_image_scores = 3
|
||||
result_one_good_match = 4
|
||||
result_multiple_good_matches = 5
|
||||
class Result(Enum):
|
||||
single_good_match = auto()
|
||||
no_matches = auto()
|
||||
single_bad_cover_score = auto()
|
||||
multiple_bad_cover_scores = auto()
|
||||
multiple_good_matches = auto()
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class IssueIdentifierOptions:
|
||||
series_match_search_thresh: int
|
||||
series_match_identify_thresh: int
|
||||
use_publisher_filter: bool
|
||||
publisher_filter: list[str]
|
||||
quiet: bool
|
||||
cache_dir: pathlib.Path
|
||||
border_crop_percent: int
|
||||
talker: ComicTalker
|
||||
|
||||
|
||||
class IssueIdentifier:
|
||||
def __init__(
|
||||
self,
|
||||
comic_archive: ComicArchive,
|
||||
config: ct_ns,
|
||||
talker: ComicTalker,
|
||||
metadata: GenericMetadata = GenericMetadata(),
|
||||
config: IssueIdentifierOptions,
|
||||
on_rate_limit: RLCallBack | None,
|
||||
output: Callable[[str], Any] = print,
|
||||
on_progress: Callable[[int, int, bytes], Any] | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.talker = talker
|
||||
self.comic_archive: ComicArchive = comic_archive
|
||||
self.md = metadata
|
||||
self.talker = config.talker
|
||||
self.image_hasher = 1
|
||||
|
||||
self.only_use_additional_meta_data = False
|
||||
@ -108,30 +122,24 @@ class IssueIdentifier:
|
||||
|
||||
# used to eliminate series names that are too long based on our search
|
||||
# string
|
||||
self.series_match_thresh = config.Issue_Identifier__series_match_identify_thresh
|
||||
self.series_match_thresh = config.series_match_identify_thresh
|
||||
|
||||
# used to eliminate unlikely publishers
|
||||
self.use_publisher_filter = config.Auto_Tag__use_publisher_filter
|
||||
self.publisher_filter = [s.strip().casefold() for s in config.Auto_Tag__publisher_filter]
|
||||
self.use_publisher_filter = config.use_publisher_filter
|
||||
self.publisher_filter = [s.strip().casefold() for s in config.publisher_filter]
|
||||
|
||||
self.additional_metadata = GenericMetadata()
|
||||
self.output_function: Callable[[str], None] = print
|
||||
self.progress_callback: Callable[[int, int], None] | None = None
|
||||
self.cover_url_callback: Callable[[bytes], None] | None = None
|
||||
self.search_result = self.result_no_matches
|
||||
self.output_function = output
|
||||
self.progress_callback: Callable[[int, int, bytes], Any] = lambda *x: ...
|
||||
if on_progress:
|
||||
self.progress_callback = on_progress
|
||||
self.on_rate_limit = on_rate_limit
|
||||
self.search_result = Result.no_matches
|
||||
self.cancel = False
|
||||
self.current_progress = (0, 0)
|
||||
|
||||
self.match_list: list[IssueResult] = []
|
||||
|
||||
def set_output_function(self, func: Callable[[str], None]) -> None:
|
||||
self.output_function = func
|
||||
|
||||
def set_progress_callback(self, cb_func: Callable[[int, int], None]) -> None:
|
||||
self.progress_callback = cb_func
|
||||
|
||||
def set_cover_url_callback(self, cb_func: Callable[[bytes], None]) -> None:
|
||||
self.cover_url_callback = cb_func
|
||||
|
||||
def calculate_hash(self, image_data: bytes) -> int:
|
||||
if self.image_hasher == 3:
|
||||
return ImageHasher(data=image_data).p_hash()
|
||||
@ -161,23 +169,23 @@ class IssueIdentifier:
|
||||
# Always send to logger so that we have a record for troubleshooting
|
||||
logger.info(log_msg, **kwargs)
|
||||
|
||||
# If we are verbose or quiet we don't need to call the output function
|
||||
if self.config.Runtime_Options__verbose > 0 or self.config.Runtime_Options__quiet:
|
||||
# If we are quiet we don't need to call the output function
|
||||
if self.config.quiet:
|
||||
return
|
||||
|
||||
# default output is stdout
|
||||
self.output_function(*args, **kwargs)
|
||||
|
||||
def identify(self, ca: ComicArchive, md: GenericMetadata) -> tuple[int, list[IssueResult]]:
|
||||
def identify(self, ca: ComicArchive, md: GenericMetadata) -> tuple[Result, list[IssueResult]]:
|
||||
if not self._check_requirements(ca):
|
||||
return self.result_no_matches, []
|
||||
return 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, []
|
||||
return Result.no_matches, []
|
||||
|
||||
self._print_terms(terms, images)
|
||||
|
||||
@ -207,28 +215,28 @@ class IssueIdentifier:
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
self._print_match(final_cover_matching[0])
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
search_result = self.result_found_match_but_bad_cover_score
|
||||
search_result = Result.single_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
|
||||
search_result = Result.multiple_bad_cover_scores
|
||||
else:
|
||||
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
|
||||
search_result = Result.single_good_match
|
||||
|
||||
elif len(self.match_list) == 0:
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
self.log_msg("No matches found :(")
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
search_result = self.result_no_matches
|
||||
search_result = 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
|
||||
search_result = Result.multiple_good_matches
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
for match_item in final_cover_matching:
|
||||
self._print_match(match_item)
|
||||
@ -289,14 +297,16 @@ class IssueIdentifier:
|
||||
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
|
||||
)
|
||||
alt_url_image_data = ImageFetcher(self.config.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)
|
||||
self._user_canceled(
|
||||
functools.partial(
|
||||
self.progress_callback, self.current_progress[0], self.current_progress[1], alt_url_image_data
|
||||
)
|
||||
)
|
||||
|
||||
remote_hashes.append((url, self.calculate_hash(alt_url_image_data)))
|
||||
|
||||
@ -318,7 +328,7 @@ class IssueIdentifier:
|
||||
if not primary_img_url:
|
||||
return Score(score=100, url="", remote_hash=0)
|
||||
|
||||
self._user_canceled()
|
||||
# self._user_canceled()
|
||||
|
||||
urls = [primary_img_url]
|
||||
if use_alt_urls:
|
||||
@ -381,7 +391,7 @@ class IssueIdentifier:
|
||||
images.append(("double page", im))
|
||||
|
||||
# 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)
|
||||
cropped = self._crop_border(cover_image, self.config.border_crop_percent)
|
||||
if cropped is not None:
|
||||
images.append(("black border cropped", cropped))
|
||||
|
||||
@ -421,11 +431,11 @@ class IssueIdentifier:
|
||||
) -> 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:
|
||||
def _user_canceled(self, callback: Callable[[], Any] | None = None) -> Any:
|
||||
if self.cancel:
|
||||
raise IssueIdentifierCancelled
|
||||
if callback is not None:
|
||||
return callback(*args)
|
||||
return callback()
|
||||
|
||||
def _print_terms(self, keys: SearchKeys, images: list[tuple[str, Image.Image]]) -> None:
|
||||
assert keys["series"]
|
||||
@ -508,7 +518,8 @@ class IssueIdentifier:
|
||||
if use_alternates:
|
||||
alternate = " Alternate"
|
||||
for series, issue in issues:
|
||||
self._user_canceled(self.progress_callback, counter, len(issues))
|
||||
self.current_progress = counter, len(issues)
|
||||
self._user_canceled(functools.partial(self.progress_callback, counter, len(issues), b""))
|
||||
counter += 1
|
||||
|
||||
self.log_msg(
|
||||
@ -566,8 +577,9 @@ class IssueIdentifier:
|
||||
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,
|
||||
callback=lambda x, y: self._user_canceled(functools.partial(self.progress_callback, x, y, b"")),
|
||||
series_match_thresh=self.config.series_match_search_thresh,
|
||||
on_rate_limit=self.on_rate_limit,
|
||||
)
|
||||
except TalkerError as e:
|
||||
self.log_msg(f"Error searching for series.\n{e}")
|
||||
@ -584,13 +596,16 @@ class IssueIdentifier:
|
||||
|
||||
self.log_msg(f"Searching in {len(filtered_series)} series")
|
||||
|
||||
self._user_canceled(self.progress_callback, 0, len(filtered_series))
|
||||
self._user_canceled(functools.partial(self.progress_callback, 0, len(filtered_series), b""))
|
||||
|
||||
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"]
|
||||
list(series_by_id.keys()),
|
||||
terms["issue_number"],
|
||||
terms["year"],
|
||||
on_rate_limit=self.on_rate_limit,
|
||||
)
|
||||
except TalkerError as e:
|
||||
self.log_msg(f"Issue with while searching for series details. Aborting...\n{e}")
|
||||
@ -601,7 +616,7 @@ class IssueIdentifier:
|
||||
if not talker_result:
|
||||
return []
|
||||
|
||||
self._user_canceled(self.progress_callback, 0, 0)
|
||||
self._user_canceled(functools.partial(self.progress_callback, 0, 0, b""))
|
||||
|
||||
issues: list[tuple[ComicSeries, GenericMetadata]] = []
|
||||
|
||||
|
@ -18,15 +18,15 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets, uic
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comicapi.issuestring import IssueString
|
||||
from comictaggerlib.coverimagewidget import CoverImageWidget
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.ui import qtutils, ui_path
|
||||
from comictaggerlib.ui.qtutils import new_web_view
|
||||
from comictalker.comictalker import ComicTalker, TalkerError
|
||||
from comictaggerlib.seriesselectionwindow import SeriesSelectionWindow
|
||||
from comictaggerlib.ui import ui_path
|
||||
from comictalker.comictalker import ComicTalker, RLCallBack, TalkerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -39,106 +39,52 @@ class IssueNumberTableWidgetItem(QtWidgets.QTableWidgetItem):
|
||||
return (IssueString(self_str).as_float() or 0) < (IssueString(other_str).as_float() or 0)
|
||||
|
||||
|
||||
class IssueSelectionWindow(QtWidgets.QDialog):
|
||||
class IssueSelectionWindow(SeriesSelectionWindow):
|
||||
ui_file = ui_path / "issueselectionwindow.ui"
|
||||
CoverImageMode = CoverImageWidget.AltCoverMode
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget,
|
||||
config: ct_ns,
|
||||
talker: ComicTalker,
|
||||
series_id: str,
|
||||
issue_number: str,
|
||||
series_id: str = "",
|
||||
issue_number: str = "",
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
with (ui_path / "issueselectionwindow.ui").open(encoding="utf-8") as uifile:
|
||||
uic.loadUi(uifile, self)
|
||||
|
||||
self.coverWidget = CoverImageWidget(
|
||||
self.coverImageContainer,
|
||||
CoverImageWidget.AltCoverMode,
|
||||
config.Runtime_Options__config.user_cache_dir,
|
||||
)
|
||||
gridlayout = QtWidgets.QGridLayout(self.coverImageContainer)
|
||||
gridlayout.addWidget(self.coverWidget)
|
||||
gridlayout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.teDescription: QtWidgets.QWidget
|
||||
webengine = new_web_view(self)
|
||||
if webengine:
|
||||
self.teDescription = qtutils.replaceWidget(self.splitter, self.teDescription, webengine)
|
||||
logger.info("successfully loaded QWebEngineView")
|
||||
else:
|
||||
logger.info("failed to open QWebEngineView")
|
||||
|
||||
self.setWindowFlags(
|
||||
QtCore.Qt.WindowType(
|
||||
self.windowFlags()
|
||||
| QtCore.Qt.WindowType.WindowSystemMenuHint
|
||||
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
|
||||
)
|
||||
)
|
||||
|
||||
super().__init__(parent, config, talker)
|
||||
self.series_id = series_id
|
||||
self.issue_id: str = ""
|
||||
self.config = config
|
||||
self.talker = talker
|
||||
self.issue_list: dict[str, GenericMetadata] = {}
|
||||
|
||||
# Display talker logo and set url
|
||||
self.lblIssuesSourceName.setText(talker.attribution)
|
||||
|
||||
self.imageIssuesSourceWidget = CoverImageWidget(
|
||||
self.imageIssuesSourceLogo,
|
||||
CoverImageWidget.URLMode,
|
||||
config.Runtime_Options__config.user_cache_dir,
|
||||
False,
|
||||
)
|
||||
self.imageIssuesSourceWidget.showControls = False
|
||||
gridlayoutIssuesSourceLogo = QtWidgets.QGridLayout(self.imageIssuesSourceLogo)
|
||||
gridlayoutIssuesSourceLogo.addWidget(self.imageIssuesSourceWidget)
|
||||
gridlayoutIssuesSourceLogo.setContentsMargins(0, 2, 0, 0)
|
||||
self.imageIssuesSourceWidget.set_url(talker.logo_url)
|
||||
|
||||
self.issue_number = issue_number
|
||||
if issue_number is None or issue_number == "":
|
||||
self.issue_number = "1"
|
||||
else:
|
||||
self.issue_number = issue_number
|
||||
|
||||
self.initial_id: str = ""
|
||||
self.perform_query()
|
||||
|
||||
self.twList.resizeColumnsToContents()
|
||||
self.twList.currentItemChanged.connect(self.current_item_changed)
|
||||
self.twList.cellDoubleClicked.connect(self.cell_double_clicked)
|
||||
|
||||
# now that the list has been sorted, find the initial record, and
|
||||
# select it
|
||||
if not self.initial_id:
|
||||
self.twList.selectRow(0)
|
||||
else:
|
||||
if self.initial_id:
|
||||
for r in range(0, self.twList.rowCount()):
|
||||
issue_id = self.twList.item(r, 0).data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
if issue_id == self.initial_id:
|
||||
self.twList.selectRow(r)
|
||||
break
|
||||
|
||||
self.leFilter.textChanged.connect(self.filter)
|
||||
|
||||
def filter(self, text: str) -> None:
|
||||
rows = set(range(self.twList.rowCount()))
|
||||
for r in rows:
|
||||
self.twList.showRow(r)
|
||||
if text.strip():
|
||||
shown_rows = {x.row() for x in self.twList.findItems(text, QtCore.Qt.MatchFlag.MatchContains)}
|
||||
for r in rows - shown_rows:
|
||||
self.twList.hideRow(r)
|
||||
def showEvent(self, event: QtGui.QShowEvent) -> None:
|
||||
return
|
||||
|
||||
def perform_query(self) -> None:
|
||||
def perform_query(self) -> None: # type: ignore[override]
|
||||
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
|
||||
|
||||
try:
|
||||
self.issue_list = {
|
||||
x.issue_id: x for x in self.talker.fetch_issues_in_series(self.series_id) if x.issue_id is not None
|
||||
x.issue_id: x
|
||||
for x in self.talker.fetch_issues_in_series(
|
||||
self.series_id, on_rate_limit=RLCallBack(self.on_ratelimit, 10)
|
||||
)
|
||||
if x.issue_id is not None
|
||||
}
|
||||
except TalkerError as e:
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
@ -168,14 +114,7 @@ class IssueSelectionWindow(QtWidgets.QDialog):
|
||||
def cell_double_clicked(self, r: int, c: int) -> None:
|
||||
self.accept()
|
||||
|
||||
def set_description(self, widget: QtWidgets.QWidget, text: str) -> None:
|
||||
if isinstance(widget, QtWidgets.QTextEdit):
|
||||
widget.setText(text.replace("</figure>", "</div>").replace("<figure", "<div"))
|
||||
else:
|
||||
html = text
|
||||
widget.setHtml(html, QtCore.QUrl(self.talker.website))
|
||||
|
||||
def update_row(self, row: int, issue: GenericMetadata) -> None:
|
||||
def update_row(self, row: int, issue: GenericMetadata) -> None: # type: ignore[override]
|
||||
item_text = issue.issue or ""
|
||||
item = self.twList.item(row, 0)
|
||||
item.setText(item_text)
|
||||
@ -201,31 +140,19 @@ class IssueSelectionWindow(QtWidgets.QDialog):
|
||||
qtw_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
|
||||
qtw_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
|
||||
def current_item_changed(self, curr: QtCore.QModelIndex | None, prev: QtCore.QModelIndex | None) -> None:
|
||||
if curr is None:
|
||||
return
|
||||
if prev is not None and prev.row() == curr.row():
|
||||
return
|
||||
|
||||
row = curr.row()
|
||||
def _fetch(self, row: int) -> GenericMetadata: # type: ignore[override]
|
||||
self.issue_id = self.twList.item(row, 0).data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
|
||||
# list selection was changed, update the issue cover
|
||||
issue = self.issue_list[self.issue_id]
|
||||
if not (issue.issue and issue.year and issue.month and issue._cover_image and issue.title):
|
||||
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
|
||||
try:
|
||||
issue = self.talker.fetch_comic_data(issue_id=self.issue_id)
|
||||
issue = self.talker.fetch_comic_data(
|
||||
issue_id=self.issue_id, on_rate_limit=RLCallBack(self.ratelimit, 10)
|
||||
)
|
||||
except TalkerError:
|
||||
pass
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
self.issue_number = issue.issue or ""
|
||||
self.coverWidget.set_issue_details(self.issue_id, [issue._cover_image or "", *issue._alternate_images])
|
||||
if issue.description is None:
|
||||
self.set_description(self.teDescription, "")
|
||||
else:
|
||||
self.set_description(self.teDescription, issue.description)
|
||||
|
||||
# Update current record information
|
||||
self.update_row(row, issue)
|
||||
self.cover_widget.set_issue_details(self.issue_id, [issue._cover_image or "", *issue._alternate_images])
|
||||
self.set_description(self.teDescription, issue.description or "")
|
||||
return issue
|
||||
|
@ -28,7 +28,6 @@ def setup_logging(verbose: int, log_dir: pathlib.Path) -> None:
|
||||
logging.getLogger("comicapi").setLevel(logging.DEBUG)
|
||||
logging.getLogger("comictaggerlib").setLevel(logging.DEBUG)
|
||||
logging.getLogger("comictalker").setLevel(logging.DEBUG)
|
||||
logging.getLogger("pyrate_limiter").setLevel(logging.DEBUG)
|
||||
|
||||
log_file = log_dir / "ComicTagger.log"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
@ -2,7 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi import merge, utils
|
||||
from comicapi.comicarchive import ComicArchive
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comictaggerlib import ctversion
|
||||
from comictaggerlib.cbltransformer import CBLTransformer
|
||||
@ -37,3 +38,25 @@ def prepare_metadata(md: GenericMetadata, new_md: GenericMetadata, config: Settn
|
||||
notes=utils.combine_notes(final_md.notes, notes, "Tagged with ComicTagger"),
|
||||
description=cleanup_html(final_md.description, config.Metadata_Options__remove_html_tables) or None,
|
||||
)
|
||||
|
||||
|
||||
def read_selected_tags(
|
||||
tag_ids: list[str], ca: ComicArchive, mode: merge.Mode = merge.Mode.OVERLAY, merge_lists: bool = False
|
||||
) -> tuple[GenericMetadata, list[str], Exception | None]:
|
||||
md = GenericMetadata()
|
||||
error = None
|
||||
tags_used = []
|
||||
try:
|
||||
for tag_id in tag_ids:
|
||||
metadata = ca.read_tags(tag_id)
|
||||
if not metadata.is_empty:
|
||||
md.overlay(
|
||||
metadata,
|
||||
mode=mode,
|
||||
merge_lists=merge_lists,
|
||||
)
|
||||
tags_used.append(tag_id)
|
||||
except Exception as e:
|
||||
error = e
|
||||
|
||||
return md, tags_used, error
|
||||
|
@ -158,7 +158,7 @@ class QuickTag:
|
||||
metadata_results = self.get_results(filtered_results)
|
||||
chosen_result = self.display_results(metadata_results, tags, interactive)
|
||||
|
||||
return self.talker.fetch_comic_data(issue_id=chosen_result.issue_id)
|
||||
return self.talker.fetch_comic_data(issue_id=chosen_result.issue_id, on_rate_limit=None)
|
||||
|
||||
def SearchHashes(
|
||||
self, simple: bool, max_hamming_distance: int, ahash: str, dhash: str, phash: str, exact_only: bool
|
||||
@ -196,10 +196,10 @@ class QuickTag:
|
||||
self.output(f"Retrieving basic {self.talker.name} data")
|
||||
# Try to do a bulk feth of basic issue data
|
||||
if hasattr(self.talker, "fetch_comics"):
|
||||
md_results = self.talker.fetch_comics(issue_ids=list(all_ids))
|
||||
md_results = self.talker.fetch_comics(issue_ids=list(all_ids), on_rate_limit=None)
|
||||
else:
|
||||
for md_id in all_ids:
|
||||
md_results.append(self.talker.fetch_comic_data(issue_id=md_id))
|
||||
md_results.append(self.talker.fetch_comic_data(issue_id=md_id, on_rate_limit=None))
|
||||
return md_results
|
||||
|
||||
def get_simple_results(self, results: list[SimpleResult]) -> list[tuple[int, GenericMetadata]]:
|
||||
|
@ -29,13 +29,14 @@ from comicapi.comicarchive import ComicArchive
|
||||
from comicapi.genericmetadata import ComicSeries, GenericMetadata
|
||||
from comictaggerlib.coverimagewidget import CoverImageWidget
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.issueidentifier import IssueIdentifier
|
||||
from comictaggerlib.issueselectionwindow import IssueSelectionWindow
|
||||
from comictaggerlib.ctsettings.settngs_namespace import SettngsNS
|
||||
from comictaggerlib.issueidentifier import IssueIdentifier, IssueIdentifierOptions
|
||||
from comictaggerlib.issueidentifier import Result as IIResult
|
||||
from comictaggerlib.matchselectionwindow import MatchSelectionWindow
|
||||
from comictaggerlib.progresswindow import IDProgressWindow
|
||||
from comictaggerlib.resulttypes import IssueResult
|
||||
from comictaggerlib.ui import qtutils, ui_path
|
||||
from comictalker.comictalker import ComicTalker, TalkerError
|
||||
from comictalker.comictalker import ComicTalker, RLCallBack, TalkerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -43,6 +44,7 @@ logger = logging.getLogger(__name__)
|
||||
class SearchThread(QtCore.QThread):
|
||||
searchComplete = pyqtSignal()
|
||||
progressUpdate = pyqtSignal(int, int)
|
||||
ratelimit = pyqtSignal(float, float)
|
||||
|
||||
def __init__(
|
||||
self, talker: ComicTalker, series_name: str, refresh: bool, literal: bool = False, series_match_thresh: int = 90
|
||||
@ -61,7 +63,12 @@ class SearchThread(QtCore.QThread):
|
||||
try:
|
||||
self.ct_error = False
|
||||
self.ct_search_results = self.talker.search_for_series(
|
||||
self.series_name, self.prog_callback, self.refresh, self.literal, self.series_match_thresh
|
||||
self.series_name,
|
||||
callback=self.prog_callback,
|
||||
refresh_cache=self.refresh,
|
||||
literal=self.literal,
|
||||
series_match_thresh=self.series_match_thresh,
|
||||
on_rate_limit=RLCallBack(self.on_ratelimit, 10),
|
||||
)
|
||||
except TalkerError as e:
|
||||
self.ct_search_results = []
|
||||
@ -74,60 +81,91 @@ class SearchThread(QtCore.QThread):
|
||||
def prog_callback(self, current: int, total: int) -> None:
|
||||
self.progressUpdate.emit(current, total)
|
||||
|
||||
def on_ratelimit(self, full_time: float, sleep_time: float) -> None:
|
||||
self.ratelimit.emit(full_time, sleep_time)
|
||||
|
||||
|
||||
class IdentifyThread(QtCore.QThread):
|
||||
identifyComplete = pyqtSignal((int, list))
|
||||
ratelimit = pyqtSignal(float, float)
|
||||
identifyComplete = pyqtSignal(IIResult, list)
|
||||
identifyLogMsg = pyqtSignal(str)
|
||||
identifyProgress = pyqtSignal(int, int)
|
||||
|
||||
def __init__(self, identifier: IssueIdentifier, ca: ComicArchive, md: GenericMetadata) -> None:
|
||||
def __init__(self, ca: ComicArchive, config: SettngsNS, talker: ComicTalker, 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)
|
||||
iio = IssueIdentifierOptions(
|
||||
series_match_search_thresh=config.Issue_Identifier__series_match_search_thresh,
|
||||
series_match_identify_thresh=config.Issue_Identifier__series_match_identify_thresh,
|
||||
use_publisher_filter=config.Auto_Tag__use_publisher_filter,
|
||||
publisher_filter=config.Auto_Tag__publisher_filter,
|
||||
quiet=config.Runtime_Options__quiet,
|
||||
cache_dir=config.Runtime_Options__config.user_cache_dir,
|
||||
border_crop_percent=config.Issue_Identifier__border_crop_percent,
|
||||
talker=talker,
|
||||
)
|
||||
self.identifier = IssueIdentifier(
|
||||
iio,
|
||||
on_rate_limit=RLCallBack(self.on_ratelimit, 10),
|
||||
output=self.log_output,
|
||||
on_progress=self.progress_callback,
|
||||
)
|
||||
self.ca = ca
|
||||
self.md = md
|
||||
|
||||
def log_output(self, text: str) -> None:
|
||||
self.identifyLogMsg.emit(str(text))
|
||||
|
||||
def progress_callback(self, cur: int, total: int) -> None:
|
||||
def progress_callback(self, cur: int, total: int, image: bytes) -> None:
|
||||
self.identifyProgress.emit(cur, total)
|
||||
|
||||
def run(self) -> None:
|
||||
self.identifyComplete.emit(*self.identifier.identify(self.ca, self.md))
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.identifier.cancel = True
|
||||
|
||||
def on_ratelimit(self, full_time: float, sleep_time: float) -> None:
|
||||
self.ratelimit.emit(full_time, sleep_time)
|
||||
|
||||
|
||||
class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
ui_file = ui_path / "seriesselectionwindow.ui"
|
||||
CoverImageMode = CoverImageWidget.URLMode
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget,
|
||||
series_name: str,
|
||||
issue_number: str,
|
||||
year: int | None,
|
||||
issue_count: int | None,
|
||||
comic_archive: ComicArchive | None,
|
||||
config: ct_ns,
|
||||
talker: ComicTalker,
|
||||
series_name: str = "",
|
||||
issue_number: str = "",
|
||||
comic_archive: ComicArchive | None = None,
|
||||
year: int | None = None,
|
||||
issue_count: int | None = None,
|
||||
autoselect: bool = False,
|
||||
literal: bool = False,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
with (ui_path / "seriesselectionwindow.ui").open(encoding="utf-8") as uifile:
|
||||
with self.ui_file.open(encoding="utf-8") as uifile:
|
||||
uic.loadUi(uifile, self)
|
||||
|
||||
self.imageWidget = CoverImageWidget(
|
||||
self.imageContainer, CoverImageWidget.URLMode, config.Runtime_Options__config.user_cache_dir
|
||||
self.cover_widget = CoverImageWidget(
|
||||
self.coverImageContainer,
|
||||
self.CoverImageMode,
|
||||
config.Runtime_Options__config.user_cache_dir,
|
||||
)
|
||||
gridlayout = QtWidgets.QGridLayout(self.imageContainer)
|
||||
gridlayout.addWidget(self.imageWidget)
|
||||
gridlayout = QtWidgets.QGridLayout(self.coverImageContainer)
|
||||
gridlayout.addWidget(self.cover_widget)
|
||||
gridlayout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.teDetails: QtWidgets.QWidget
|
||||
self.teDescription: QtWidgets.QWidget
|
||||
webengine = qtutils.new_web_view(self)
|
||||
if webengine:
|
||||
self.teDetails = qtutils.replaceWidget(self.splitter, self.teDetails, webengine)
|
||||
self.teDescription = qtutils.replaceWidget(self.splitter, self.teDescription, webengine)
|
||||
logger.info("successfully loaded QWebEngineView")
|
||||
else:
|
||||
logger.info("failed to open QWebEngineView")
|
||||
|
||||
self.setWindowFlags(
|
||||
QtCore.Qt.WindowType(
|
||||
@ -138,29 +176,11 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
)
|
||||
|
||||
self.config = config
|
||||
self.series_name = series_name
|
||||
self.issue_number = issue_number
|
||||
self.issue_id: str = ""
|
||||
self.year = year
|
||||
self.issue_count = issue_count
|
||||
self.series_id: str = ""
|
||||
self.comic_archive = comic_archive
|
||||
self.immediate_autoselect = autoselect
|
||||
self.series_list: dict[str, ComicSeries] = {}
|
||||
self.literal = literal
|
||||
self.ii: IssueIdentifier | None = None
|
||||
self.iddialog: IDProgressWindow | None = None
|
||||
self.id_thread: IdentifyThread | None = None
|
||||
self.progdialog: QtWidgets.QProgressDialog | None = None
|
||||
self.search_thread: SearchThread | None = None
|
||||
|
||||
self.use_filter = self.config.Auto_Tag__use_publisher_filter
|
||||
|
||||
# Load to retrieve settings
|
||||
self.talker = talker
|
||||
self.issue_id: str = ""
|
||||
|
||||
# Display talker logo and set url
|
||||
self.lblSourceName.setText(talker.attribution)
|
||||
self.lblIssuesSourceName.setText(talker.attribution)
|
||||
|
||||
self.imageSourceWidget = CoverImageWidget(
|
||||
self.imageSourceLogo,
|
||||
@ -177,19 +197,116 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
# Set the minimum row height to the default.
|
||||
# this way rows will be more consistent when resizeRowsToContents is called
|
||||
self.twList.verticalHeader().setMinimumSectionSize(self.twList.verticalHeader().defaultSectionSize())
|
||||
self.twList.resizeColumnsToContents()
|
||||
self.twList.currentItemChanged.connect(self.current_item_changed)
|
||||
self.twList.cellDoubleClicked.connect(self.cell_double_clicked)
|
||||
self.btnRequery.clicked.connect(self.requery)
|
||||
self.btnIssues.clicked.connect(self.show_issues)
|
||||
self.btnAutoSelect.clicked.connect(self.auto_select)
|
||||
|
||||
self.cbxFilter.setChecked(self.use_filter)
|
||||
self.cbxFilter.toggled.connect(self.filter_toggled)
|
||||
|
||||
self.update_buttons()
|
||||
self.leFilter.textChanged.connect(self.filter)
|
||||
self.twList.selectRow(0)
|
||||
|
||||
self.leFilter.textChanged.connect(self.filter)
|
||||
if series_name:
|
||||
self.series_name = series_name
|
||||
self.issue_number = issue_number
|
||||
self.year = year
|
||||
self.issue_count = issue_count
|
||||
self.series_id: str = ""
|
||||
self.comic_archive = comic_archive
|
||||
self.immediate_autoselect = autoselect
|
||||
self.series_list: dict[str, ComicSeries] = {}
|
||||
self.literal = literal
|
||||
self.iddialog: IDProgressWindow | None = None
|
||||
self.id_thread: IdentifyThread | None = None
|
||||
self.progdialog: QtWidgets.QProgressDialog | None = None
|
||||
self.search_thread: SearchThread | None = None
|
||||
|
||||
self.use_publisher_filter = self.config.Auto_Tag__use_publisher_filter
|
||||
|
||||
self.btnRequery.clicked.connect(self.requery)
|
||||
self.btnIssues.clicked.connect(self.show_issues)
|
||||
self.btnAutoSelect.clicked.connect(self.auto_select)
|
||||
|
||||
self.cbxPublisherFilter.setChecked(self.use_publisher_filter)
|
||||
self.cbxPublisherFilter.toggled.connect(self.publisher_filter_toggled)
|
||||
|
||||
self.update_buttons()
|
||||
|
||||
def showEvent(self, event: QtGui.QShowEvent) -> None:
|
||||
self.perform_query()
|
||||
if not self.series_list:
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
QtWidgets.QMessageBox.information(self, "Search Result", "No matches found!")
|
||||
QtCore.QTimer.singleShot(200, self.close_me)
|
||||
|
||||
elif self.immediate_autoselect:
|
||||
# defer the immediate autoselect so this dialog has time to pop up
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
QtCore.QTimer.singleShot(10, self.do_immediate_autoselect)
|
||||
|
||||
def perform_query(self, refresh: bool = False) -> None:
|
||||
self.search_thread = SearchThread(
|
||||
self.talker,
|
||||
self.series_name,
|
||||
refresh,
|
||||
self.literal,
|
||||
self.config.Issue_Identifier__series_match_search_thresh,
|
||||
)
|
||||
self.search_thread.searchComplete.connect(self.search_complete)
|
||||
self.search_thread.progressUpdate.connect(self.search_progress_update)
|
||||
self.search_thread.ratelimit.connect(self.on_ratelimit)
|
||||
self.search_thread.ratelimit.connect(self.parent().on_ratelimit)
|
||||
self.search_thread.start()
|
||||
|
||||
self.progdialog = QtWidgets.QProgressDialog("Searching Online", "Cancel", 0, 100, self)
|
||||
self.progdialog.setWindowTitle("Online Search")
|
||||
self.progdialog.canceled.connect(self.search_canceled)
|
||||
self.progdialog.setModal(True)
|
||||
self.progdialog.setMinimumDuration(300)
|
||||
|
||||
if refresh or self.search_thread.isRunning():
|
||||
self.progdialog.exec()
|
||||
else:
|
||||
self.progdialog = None
|
||||
|
||||
def cell_double_clicked(self, r: int, c: int) -> None:
|
||||
self.show_issues()
|
||||
|
||||
def on_ratelimit(self, full_time: float, sleep_time: float) -> None:
|
||||
self.log_output(
|
||||
f"Rate limit reached: {full_time:.0f}s until next request. Waiting {sleep_time:.0f}s for ratelimit"
|
||||
)
|
||||
|
||||
def update_row(self, row: int, series: ComicSeries) -> None:
|
||||
item_text = series.name
|
||||
item = self.twList.item(row, 0)
|
||||
item.setText(item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.UserRole, series.id)
|
||||
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
|
||||
item_text = f"{series.start_year:04}" if series.start_year is not None else ""
|
||||
item = self.twList.item(row, 1)
|
||||
item.setText(item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
|
||||
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
|
||||
item_text = f"{series.count_of_issues:04}" if series.count_of_issues is not None else ""
|
||||
item = self.twList.item(row, 2)
|
||||
item.setText(item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.DisplayRole, series.count_of_issues)
|
||||
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
|
||||
item_text = series.publisher if series.publisher is not None else ""
|
||||
item = self.twList.item(row, 3)
|
||||
item.setText(item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
|
||||
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
|
||||
def set_description(self, widget: QtWidgets.QWidget, text: str) -> None:
|
||||
if isinstance(widget, QtWidgets.QTextEdit):
|
||||
widget.setText(text.replace("</figure>", "</div>").replace("<figure", "<div"))
|
||||
else:
|
||||
html = text
|
||||
widget.setHtml(html, QUrl(self.talker.website))
|
||||
|
||||
def filter(self, text: str) -> None:
|
||||
rows = set(range(self.twList.rowCount()))
|
||||
@ -200,6 +317,28 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
for r in rows - shown_rows:
|
||||
self.twList.hideRow(r)
|
||||
|
||||
def _fetch(self, row: int) -> ComicSeries:
|
||||
self.series_id = self.twList.item(row, 0).data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
|
||||
# list selection was changed, update the info on the series
|
||||
series = self.series_list[self.series_id]
|
||||
if not (
|
||||
series.name
|
||||
and series.start_year
|
||||
and series.count_of_issues
|
||||
and series.publisher
|
||||
and series.description
|
||||
and series.image_url
|
||||
):
|
||||
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
|
||||
try:
|
||||
series = self.talker.fetch_series(self.series_id, on_rate_limit=RLCallBack(self.ratelimit, 10))
|
||||
except TalkerError:
|
||||
pass
|
||||
self.set_description(self.teDescription, series.description or "")
|
||||
self.cover_widget.set_url(series.image_url)
|
||||
return series
|
||||
|
||||
def update_buttons(self) -> None:
|
||||
enabled = bool(self.series_list)
|
||||
|
||||
@ -214,8 +353,8 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
self.perform_query(refresh=True)
|
||||
self.twList.selectRow(0)
|
||||
|
||||
def filter_toggled(self) -> None:
|
||||
self.use_filter = not self.use_filter
|
||||
def publisher_filter_toggled(self) -> None:
|
||||
self.use_publisher_filter = self.cbxPublisherFilter.isChecked()
|
||||
self.perform_query(refresh=False)
|
||||
|
||||
def auto_select(self) -> None:
|
||||
@ -229,27 +368,27 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
|
||||
self.iddialog = IDProgressWindow(self)
|
||||
self.iddialog.setModal(True)
|
||||
self.iddialog.rejected.connect(self.identify_cancel)
|
||||
self.iddialog.show()
|
||||
|
||||
self.ii = IssueIdentifier(self.comic_archive, self.config, self.talker)
|
||||
|
||||
md = GenericMetadata()
|
||||
md.series = self.series_name
|
||||
md.issue = self.issue_number
|
||||
md.year = self.year
|
||||
md.issue_count = self.issue_count
|
||||
|
||||
self.id_thread = IdentifyThread(self.ii, self.comic_archive, md)
|
||||
self.id_thread = IdentifyThread(self.comic_archive, self.config, self.talker, md)
|
||||
self.id_thread.identifyComplete.connect(self.identify_complete)
|
||||
self.id_thread.identifyLogMsg.connect(self.log_id_output)
|
||||
self.id_thread.identifyLogMsg.connect(self.log_output)
|
||||
self.id_thread.identifyProgress.connect(self.identify_progress)
|
||||
self.id_thread.ratelimit.connect(self.on_ratelimit)
|
||||
self.id_thread.ratelimit.connect(self.parent().on_ratelimit)
|
||||
self.iddialog.rejected.connect(self.id_thread.cancel)
|
||||
|
||||
self.id_thread.start()
|
||||
|
||||
self.iddialog.exec()
|
||||
|
||||
def log_id_output(self, text: str) -> None:
|
||||
def log_output(self, text: str) -> None:
|
||||
if self.iddialog is not None:
|
||||
self.iddialog.textEdit.append(text.rstrip())
|
||||
self.iddialog.textEdit.ensureCursorVisible()
|
||||
@ -262,37 +401,28 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
self.iddialog.progressBar.setMaximum(total)
|
||||
self.iddialog.progressBar.setValue(cur)
|
||||
|
||||
def identify_cancel(self) -> None:
|
||||
if self.ii is not None:
|
||||
self.ii.cancel = True
|
||||
|
||||
def identify_complete(self, result: int, issues: list[IssueResult]) -> None:
|
||||
def identify_complete(self, result: IIResult, issues: list[IssueResult]) -> None:
|
||||
if self.iddialog is not None and self.comic_archive is not None:
|
||||
|
||||
found_match = None
|
||||
choices = False
|
||||
if result == IssueIdentifier.result_no_matches:
|
||||
if result == IIResult.no_matches:
|
||||
QtWidgets.QMessageBox.information(self, "Auto-Select Result", " No issues found :-(")
|
||||
elif result == IssueIdentifier.result_found_match_but_bad_cover_score:
|
||||
elif result == IIResult.single_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 = 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 = issues[0]
|
||||
elif result == IssueIdentifier.result_multiple_matches_with_bad_image_scores:
|
||||
elif result == IIResult.multiple_bad_cover_scores:
|
||||
QtWidgets.QMessageBox.information(
|
||||
self, "Auto-Select Result", " Found some possibilities, but no confidence. Proceed manually."
|
||||
)
|
||||
choices = True
|
||||
elif result == IssueIdentifier.result_one_good_match:
|
||||
elif result == IIResult.single_good_match:
|
||||
found_match = issues[0]
|
||||
elif result == IssueIdentifier.result_multiple_good_matches:
|
||||
elif result == IIResult.multiple_good_matches:
|
||||
QtWidgets.QMessageBox.information(
|
||||
self, "Auto-Select Result", " Found multiple likely matches. Please select."
|
||||
)
|
||||
@ -317,6 +447,8 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
self.show_issues()
|
||||
|
||||
def show_issues(self) -> None:
|
||||
from comictaggerlib.issueselectionwindow import IssueSelectionWindow
|
||||
|
||||
selector = IssueSelectionWindow(self, self.config, self.talker, self.series_id, self.issue_number)
|
||||
title = ""
|
||||
for series in self.series_list.values():
|
||||
@ -333,7 +465,7 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
self.issue_id = selector.issue_id
|
||||
self.accept()
|
||||
else:
|
||||
self.imageWidget.update_content()
|
||||
self.cover_widget.update_content()
|
||||
|
||||
def select_by_id(self) -> None:
|
||||
for r in range(self.twList.rowCount()):
|
||||
@ -341,29 +473,6 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
self.twList.selectRow(r)
|
||||
break
|
||||
|
||||
def perform_query(self, refresh: bool = False) -> None:
|
||||
self.search_thread = SearchThread(
|
||||
self.talker,
|
||||
self.series_name,
|
||||
refresh,
|
||||
self.literal,
|
||||
self.config.Issue_Identifier__series_match_search_thresh,
|
||||
)
|
||||
self.search_thread.searchComplete.connect(self.search_complete)
|
||||
self.search_thread.progressUpdate.connect(self.search_progress_update)
|
||||
self.search_thread.start()
|
||||
|
||||
self.progdialog = QtWidgets.QProgressDialog("Searching Online", "Cancel", 0, 100, self)
|
||||
self.progdialog.setWindowTitle("Online Search")
|
||||
self.progdialog.canceled.connect(self.search_canceled)
|
||||
self.progdialog.setModal(True)
|
||||
self.progdialog.setMinimumDuration(300)
|
||||
|
||||
if refresh or self.search_thread.isRunning():
|
||||
self.progdialog.exec()
|
||||
else:
|
||||
self.progdialog = None
|
||||
|
||||
def search_canceled(self) -> None:
|
||||
if self.progdialog is not None:
|
||||
logger.info("query cancelled")
|
||||
@ -379,8 +488,10 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
|
||||
def search_progress_update(self, current: int, total: int) -> None:
|
||||
if self.progdialog is not None:
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
self.progdialog.setMaximum(total)
|
||||
self.progdialog.setValue(current + 1)
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
|
||||
def search_complete(self) -> None:
|
||||
if self.progdialog is not None:
|
||||
@ -398,7 +509,7 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
tmp_list = self.search_thread.ct_search_results if self.search_thread is not None else []
|
||||
self.series_list = {x.id: x for x in tmp_list}
|
||||
# filter the publishers if enabled set
|
||||
if self.use_filter:
|
||||
if self.use_publisher_filter:
|
||||
try:
|
||||
publisher_filter = {s.strip().casefold() for s in self.config.Auto_Tag__publisher_filter}
|
||||
# use '' as publisher name if None
|
||||
@ -487,59 +598,10 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
# Resize row height so the whole series can still be seen
|
||||
self.twList.resizeRowsToContents()
|
||||
|
||||
def showEvent(self, event: QtGui.QShowEvent) -> None:
|
||||
self.perform_query()
|
||||
if not self.series_list:
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
QtWidgets.QMessageBox.information(self, "Search Result", "No matches found!")
|
||||
QtCore.QTimer.singleShot(200, self.close_me)
|
||||
|
||||
elif self.immediate_autoselect:
|
||||
# defer the immediate autoselect so this dialog has time to pop up
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
QtCore.QTimer.singleShot(10, self.do_immediate_autoselect)
|
||||
|
||||
def do_immediate_autoselect(self) -> None:
|
||||
self.immediate_autoselect = False
|
||||
self.auto_select()
|
||||
|
||||
def cell_double_clicked(self, r: int, c: int) -> None:
|
||||
self.show_issues()
|
||||
|
||||
def set_description(self, widget: QtWidgets.QWidget, text: str) -> None:
|
||||
if isinstance(widget, QtWidgets.QTextEdit):
|
||||
widget.setText(text.replace("</figure>", "</div>").replace("<figure", "<div"))
|
||||
else:
|
||||
html = text
|
||||
widget.setHtml(html, QUrl(self.talker.website))
|
||||
|
||||
def update_row(self, row: int, series: ComicSeries) -> None:
|
||||
item_text = series.name
|
||||
item = self.twList.item(row, 0)
|
||||
item.setText(item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.UserRole, series.id)
|
||||
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
|
||||
item_text = f"{series.start_year:04}" if series.start_year is not None else ""
|
||||
item = self.twList.item(row, 1)
|
||||
item.setText(item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
|
||||
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
|
||||
item_text = f"{series.count_of_issues:04}" if series.count_of_issues is not None else ""
|
||||
item = self.twList.item(row, 2)
|
||||
item.setText(item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.DisplayRole, series.count_of_issues)
|
||||
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
|
||||
item_text = series.publisher if series.publisher is not None else ""
|
||||
item = self.twList.item(row, 3)
|
||||
item.setText(item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
|
||||
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
|
||||
def current_item_changed(self, curr: QtCore.QModelIndex | None, prev: QtCore.QModelIndex | None) -> None:
|
||||
if curr is None:
|
||||
return
|
||||
@ -547,31 +609,9 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
return
|
||||
|
||||
row = curr.row()
|
||||
self.series_id = self.twList.item(row, 0).data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
|
||||
# list selection was changed, update the info on the series
|
||||
series = self.series_list[self.series_id]
|
||||
if not (
|
||||
series.name
|
||||
and series.start_year
|
||||
and series.count_of_issues
|
||||
and series.publisher
|
||||
and series.description
|
||||
and series.image_url
|
||||
):
|
||||
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
|
||||
# Changing of usernames and passwords with using cache can cause talker errors to crash out
|
||||
try:
|
||||
series = self.talker.fetch_series(self.series_id)
|
||||
except TalkerError:
|
||||
pass
|
||||
item = self._fetch(row)
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
if series.description is None:
|
||||
self.set_description(self.teDetails, "")
|
||||
else:
|
||||
self.set_description(self.teDetails, series.description)
|
||||
self.imageWidget.set_url(series.image_url)
|
||||
|
||||
# Update current record information
|
||||
self.update_row(row, series)
|
||||
self.update_row(row, item)
|
||||
|
123
comictaggerlib/tag.py
Normal file
123
comictaggerlib/tag.py
Normal file
@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from comicapi.comicarchive import ComicArchive
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.issueidentifier import IssueIdentifier, IssueIdentifierOptions
|
||||
from comictaggerlib.issueidentifier import Result as IIResult
|
||||
from comictaggerlib.md import prepare_metadata
|
||||
from comictaggerlib.resulttypes import Action, MatchStatus, OnlineMatchResults, Result, Status
|
||||
from comictalker.comictalker import ComicTalker, RLCallBack, TalkerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def identify_comic(
|
||||
ca: ComicArchive,
|
||||
md: GenericMetadata,
|
||||
tags_read: list[str],
|
||||
match_results: OnlineMatchResults,
|
||||
config: ct_ns,
|
||||
talker: ComicTalker,
|
||||
output: Callable[[str], Any],
|
||||
on_rate_limit: RLCallBack | None,
|
||||
on_progress: Callable[[int, int, bytes], Any] | None = None,
|
||||
) -> tuple[Result, OnlineMatchResults]:
|
||||
# ct_md, results, matches, match_results
|
||||
if md is None or md.is_empty:
|
||||
logger.error("No metadata given to search online with!")
|
||||
res = Result(
|
||||
Action.save,
|
||||
status=Status.match_failure,
|
||||
original_path=ca.path,
|
||||
match_status=MatchStatus.no_match,
|
||||
tags_read=tags_read,
|
||||
)
|
||||
match_results.no_matches.append(res)
|
||||
return res, match_results
|
||||
iio = IssueIdentifierOptions(
|
||||
series_match_search_thresh=config.Issue_Identifier__series_match_search_thresh,
|
||||
series_match_identify_thresh=config.Issue_Identifier__series_match_identify_thresh,
|
||||
use_publisher_filter=config.Auto_Tag__use_publisher_filter,
|
||||
publisher_filter=config.Auto_Tag__publisher_filter,
|
||||
quiet=config.Runtime_Options__quiet,
|
||||
cache_dir=config.Runtime_Options__config.user_cache_dir,
|
||||
border_crop_percent=config.Issue_Identifier__border_crop_percent,
|
||||
talker=talker,
|
||||
)
|
||||
ii = IssueIdentifier(
|
||||
iio,
|
||||
output=output,
|
||||
on_rate_limit=on_rate_limit,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
if not config.Auto_Tag__use_year_when_identifying:
|
||||
md.year = None
|
||||
if config.Auto_Tag__ignore_leading_numbers_in_filename and md.series is not None:
|
||||
md.series = re.sub(r"^([\d.]+)", "", md.series)
|
||||
|
||||
result, matches = ii.identify(ca, md)
|
||||
|
||||
res = Result(
|
||||
Action.save,
|
||||
status=Status.match_failure,
|
||||
original_path=ca.path,
|
||||
online_results=matches,
|
||||
tags_read=tags_read,
|
||||
)
|
||||
if result == IIResult.multiple_bad_cover_scores:
|
||||
res.match_status = MatchStatus.low_confidence_match
|
||||
|
||||
logger.error("Online search: Multiple low confidence matches. Save aborted")
|
||||
match_results.low_confidence_matches.append(res)
|
||||
return res, match_results
|
||||
|
||||
if result == IIResult.single_bad_cover_score and config.Runtime_Options__abort_on_low_confidence:
|
||||
logger.error("Online search: Low confidence match. Save aborted")
|
||||
res.match_status = MatchStatus.low_confidence_match
|
||||
|
||||
match_results.low_confidence_matches.append(res)
|
||||
return res, match_results
|
||||
|
||||
if result == IIResult.multiple_good_matches:
|
||||
logger.error("Online search: Multiple good matches. Save aborted")
|
||||
res.match_status = MatchStatus.multiple_match
|
||||
|
||||
match_results.multiple_matches.append(res)
|
||||
return res, match_results
|
||||
|
||||
if result == IIResult.no_matches:
|
||||
logger.error("Online search: No match found. Save aborted")
|
||||
res.match_status = MatchStatus.no_match
|
||||
|
||||
match_results.no_matches.append(res)
|
||||
return res, match_results
|
||||
|
||||
# we got here, so we have a single match
|
||||
# now get the particular issue data
|
||||
try:
|
||||
ct_md = talker.fetch_comic_data(issue_id=matches[0].issue_id, on_rate_limit=on_rate_limit)
|
||||
except TalkerError as e:
|
||||
logger.exception(f"Error retrieving issue details. Save aborted.\n{e}")
|
||||
ct_md = GenericMetadata()
|
||||
|
||||
ct_md = prepare_metadata(md, ct_md, config)
|
||||
|
||||
if ct_md.is_empty:
|
||||
res.status = Status.fetch_data_failure
|
||||
res.match_status = MatchStatus.good_match
|
||||
|
||||
match_results.fetch_data_failures.append(res)
|
||||
return res, match_results
|
||||
|
||||
res.status = Status.success
|
||||
res.md = ct_md
|
||||
if result == IIResult.single_good_match:
|
||||
res.match_status = MatchStatus.good_match
|
||||
|
||||
return res, match_results
|
@ -22,7 +22,6 @@ import operator
|
||||
import os
|
||||
import pickle
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import webbrowser
|
||||
from collections.abc import Sequence
|
||||
@ -43,7 +42,7 @@ from comicapi.issuestring import IssueString
|
||||
from comictaggerlib import ctsettings, ctversion
|
||||
from comictaggerlib.applicationlogwindow import ApplicationLogWindow, QTextEditLogger
|
||||
from comictaggerlib.autotagmatchwindow import AutoTagMatchWindow
|
||||
from comictaggerlib.autotagprogresswindow import AutoTagProgressWindow
|
||||
from comictaggerlib.autotagprogresswindow import AutoTagProgressWindow, AutoTagThread
|
||||
from comictaggerlib.autotagstartwindow import AutoTagStartWindow
|
||||
from comictaggerlib.cbltransformer import CBLTransformer
|
||||
from comictaggerlib.coverimagewidget import CoverImageWidget
|
||||
@ -52,20 +51,20 @@ from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.exportwindow import ExportConflictOpts, ExportWindow
|
||||
from comictaggerlib.fileselectionlist import FileSelectionList
|
||||
from comictaggerlib.graphics import graphics_path
|
||||
from comictaggerlib.issueidentifier import IssueIdentifier
|
||||
from comictaggerlib.logwindow import LogWindow
|
||||
from comictaggerlib.md import prepare_metadata
|
||||
from comictaggerlib.md import prepare_metadata, read_selected_tags
|
||||
from comictaggerlib.optionalmsgdialog import OptionalMessageDialog
|
||||
from comictaggerlib.pagebrowser import PageBrowserWindow
|
||||
from comictaggerlib.pagelisteditor import PageListEditor
|
||||
from comictaggerlib.renamewindow import RenameWindow
|
||||
from comictaggerlib.resulttypes import Action, MatchStatus, OnlineMatchResults, Result, Status
|
||||
from comictaggerlib.resulttypes import OnlineMatchResults
|
||||
from comictaggerlib.seriesselectionwindow import SeriesSelectionWindow
|
||||
from comictaggerlib.settingswindow import SettingsWindow
|
||||
from comictaggerlib.ui import ui_path
|
||||
from comictaggerlib.ui.pyqttoast import Toast, ToastPreset
|
||||
from comictaggerlib.ui.qtutils import center_window_on_parent, enable_widget
|
||||
from comictaggerlib.versionchecker import VersionChecker
|
||||
from comictalker.comictalker import ComicTalker, TalkerError
|
||||
from comictalker.comictalker import ComicTalker, RLCallBack, TalkerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -77,6 +76,8 @@ def execute(f: Callable[[], Any]) -> None:
|
||||
class TaggerWindow(QtWidgets.QMainWindow):
|
||||
appName = "ComicTagger"
|
||||
version = ctversion.version
|
||||
ratelimit = QtCore.pyqtSignal(float, float)
|
||||
finish = QtCore.pyqtSignal(GenericMetadata, str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -288,6 +289,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
|
||||
self.page_list_editor.set_blur(self.config[0].General__blur)
|
||||
|
||||
self.ratelimit.connect(self.on_ratelimit)
|
||||
self.finish.connect(self.finish_query)
|
||||
|
||||
def _sync_blur(*args: Any) -> None:
|
||||
self.config[0].General__blur = self.page_list_editor.blur
|
||||
|
||||
@ -1071,13 +1075,13 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
|
||||
selector = SeriesSelectionWindow(
|
||||
self,
|
||||
series_name,
|
||||
issue_number,
|
||||
year,
|
||||
issue_count,
|
||||
self.comic_archive,
|
||||
self.config[0],
|
||||
self.current_talker(),
|
||||
series_name,
|
||||
issue_number,
|
||||
self.comic_archive,
|
||||
year,
|
||||
issue_count,
|
||||
autoselect,
|
||||
literal,
|
||||
)
|
||||
@ -1088,31 +1092,74 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
selector.exec()
|
||||
|
||||
if selector.result():
|
||||
# we should now have a series ID
|
||||
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
|
||||
|
||||
# copy the form onto metadata object
|
||||
self.form_to_metadata()
|
||||
class QueryThread(QtCore.QThread):
|
||||
def __init__(
|
||||
self,
|
||||
talker: ComicTalker,
|
||||
issue_id: str,
|
||||
series_id: str,
|
||||
issue_number: str,
|
||||
finish: QtCore.pyqtSignal,
|
||||
on_rate_limit: QtCore.pyqtSignal,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.issue_id = issue_id
|
||||
self.series_id = series_id
|
||||
self.issue_number = issue_number
|
||||
self.talker = talker
|
||||
self.finish = finish
|
||||
self.on_rate_limit = on_rate_limit
|
||||
|
||||
try:
|
||||
new_metadata = self.current_talker().fetch_comic_data(
|
||||
issue_id=selector.issue_id, series_id=selector.series_id, issue_number=selector.issue_number
|
||||
)
|
||||
except TalkerError as e:
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
QtWidgets.QMessageBox.critical(self, f"{e.source} {e.code_name} Error", f"{e}")
|
||||
return
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
def run(self) -> None:
|
||||
try:
|
||||
new_metadata = self.talker.fetch_comic_data(
|
||||
issue_id=self.issue_id,
|
||||
series_id=self.series_id,
|
||||
issue_number=self.issue_number,
|
||||
on_rate_limit=RLCallBack(lambda x, y: self.on_rate_limit.emit(x, y), 60),
|
||||
)
|
||||
except TalkerError as e:
|
||||
QtWidgets.QMessageBox.critical(None, f"{e.source} {e.code_name} Error", f"{e}")
|
||||
return
|
||||
self.finish.emit(new_metadata, self.issue_number)
|
||||
|
||||
if new_metadata is None or new_metadata.is_empty:
|
||||
QtWidgets.QMessageBox.critical(
|
||||
self, "Search", f"Could not find an issue {selector.issue_number} for that series"
|
||||
)
|
||||
return
|
||||
self.querythread = QueryThread(
|
||||
self.current_talker(),
|
||||
selector.issue_id,
|
||||
selector.series_id,
|
||||
selector.issue_number,
|
||||
self.finish,
|
||||
self.ratelimit,
|
||||
)
|
||||
self.querythread.start()
|
||||
|
||||
self.metadata = prepare_metadata(self.metadata, new_metadata, self.config[0])
|
||||
# Now push the new combined data into the edit controls
|
||||
self.metadata_to_form()
|
||||
def finish_query(self, new_metadata: GenericMetadata, issue_number: str) -> None:
|
||||
# we should now have a series ID
|
||||
# QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
# copy the form onto metadata object
|
||||
self.form_to_metadata()
|
||||
|
||||
if new_metadata is None or new_metadata.is_empty:
|
||||
QtWidgets.QMessageBox.critical(None, "Search", f"Could not find an issue {new_metadata} for that series")
|
||||
return
|
||||
|
||||
self.metadata = prepare_metadata(self.metadata, new_metadata, self.config[0])
|
||||
# Now push the new combined data into the edit controls
|
||||
self.metadata_to_form()
|
||||
|
||||
def on_ratelimit(self, full_time: float, sleep_time: float) -> None:
|
||||
toast = Toast(self)
|
||||
toast.setDuration(10000)
|
||||
toast.setTitle("Rate Limit Hit!")
|
||||
toast.setText(
|
||||
f"Rate limit reached: {full_time:.0f}s until next request. Waiting {sleep_time:.0f}s for ratelimit"
|
||||
)
|
||||
toast.applyPreset(ToastPreset.WARNING)
|
||||
toast.setPositionRelativeToWidget(self)
|
||||
toast.show()
|
||||
|
||||
def write_tags(self) -> None:
|
||||
if self.metadata is not None and self.comic_archive is not None:
|
||||
@ -1155,7 +1202,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
self.update_menus()
|
||||
|
||||
# Only try to read if write was successful
|
||||
self.metadata, error = self.read_selected_tags(self.selected_read_tags, self.comic_archive)
|
||||
self.metadata, _, error = self.read_selected_tags(self.selected_read_tags, self.comic_archive)
|
||||
if error is not None:
|
||||
QtWidgets.QMessageBox.warning(
|
||||
self,
|
||||
@ -1640,7 +1687,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
success_count = 0
|
||||
for prog_idx, ca in enumerate(ca_list, 1):
|
||||
ca_saved = False
|
||||
md, error = self.read_selected_tags(src_tag_ids, ca)
|
||||
md, _, error = self.read_selected_tags(src_tag_ids, ca)
|
||||
if error is not None:
|
||||
failed_list.append(ca.path)
|
||||
continue
|
||||
@ -1696,193 +1743,6 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
|
||||
def identify_and_tag_single_archive(
|
||||
self, ca: ComicArchive, match_results: OnlineMatchResults, dlg: AutoTagStartWindow
|
||||
) -> tuple[bool, OnlineMatchResults]:
|
||||
|
||||
success = False
|
||||
ii = IssueIdentifier(ca, self.config[0], self.current_talker())
|
||||
|
||||
# read in tags, and parse file name if not there
|
||||
md, error = self.read_selected_tags(self.selected_read_tags, ca)
|
||||
if error is not None:
|
||||
QtWidgets.QMessageBox.warning(
|
||||
self,
|
||||
"Aborting...",
|
||||
f"One or more of the read tags failed to load for {ca.path}. Aborting to prevent any possible further damage. Check log for details.",
|
||||
)
|
||||
logger.error("Failed to load tags from %s: %s", self.ca.path, error)
|
||||
return False, match_results
|
||||
|
||||
if md.is_empty:
|
||||
md = ca.metadata_from_filename(
|
||||
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,
|
||||
dlg.split_words,
|
||||
self.config[0].Filename_Parsing__allow_issue_start_with_letter,
|
||||
self.config[0].Filename_Parsing__protofolius_issue_number_scheme,
|
||||
)
|
||||
if dlg.ignore_leading_digits_in_filename and md.series is not None:
|
||||
# remove all leading numbers
|
||||
md.series = re.sub(r"(^[\d.]*)(.*)", r"\2", md.series)
|
||||
|
||||
# use the dialog specified search string
|
||||
if dlg.search_string:
|
||||
md.series = dlg.search_string
|
||||
|
||||
if md is None or md.is_empty:
|
||||
logger.error("No metadata given to search online with!")
|
||||
return False, match_results
|
||||
|
||||
if dlg.dont_use_year:
|
||||
md.year = None
|
||||
if md.issue is None or md.issue == "":
|
||||
if dlg.assume_issue_one:
|
||||
md.issue = "1"
|
||||
else:
|
||||
md.issue = utils.xlate(md.volume)
|
||||
|
||||
ii.set_output_function(self.auto_tag_log)
|
||||
if self.atprogdialog is not None:
|
||||
ii.set_cover_url_callback(self.atprogdialog.set_test_image)
|
||||
ii.series_match_thresh = dlg.name_length_match_tolerance
|
||||
|
||||
result, matches = ii.identify(ca, md)
|
||||
|
||||
found_match = False
|
||||
choices = False
|
||||
low_confidence = False
|
||||
|
||||
if result == ii.result_no_matches:
|
||||
pass
|
||||
elif result == ii.result_found_match_but_bad_cover_score:
|
||||
low_confidence = True
|
||||
found_match = True
|
||||
elif result == ii.result_found_match_but_not_first_page:
|
||||
found_match = True
|
||||
elif result == ii.result_multiple_matches_with_bad_image_scores:
|
||||
low_confidence = True
|
||||
choices = True
|
||||
elif result == ii.result_one_good_match:
|
||||
found_match = True
|
||||
elif result == ii.result_multiple_good_matches:
|
||||
choices = True
|
||||
|
||||
if choices:
|
||||
if low_confidence:
|
||||
self.auto_tag_log("Online search: Multiple low-confidence matches. Save aborted\n")
|
||||
match_results.low_confidence_matches.append(
|
||||
Result(
|
||||
Action.save,
|
||||
Status.match_failure,
|
||||
ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.low_confidence_match,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.auto_tag_log("Online search: Multiple matches. Save aborted\n")
|
||||
match_results.multiple_matches.append(
|
||||
Result(
|
||||
Action.save,
|
||||
Status.match_failure,
|
||||
ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.multiple_match,
|
||||
)
|
||||
)
|
||||
elif low_confidence and not dlg.auto_save_on_low:
|
||||
self.auto_tag_log("Online search: Low confidence match. Save aborted\n")
|
||||
match_results.low_confidence_matches.append(
|
||||
Result(
|
||||
Action.save,
|
||||
Status.match_failure,
|
||||
ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.low_confidence_match,
|
||||
)
|
||||
)
|
||||
elif not found_match:
|
||||
self.auto_tag_log("Online search: No match found. Save aborted\n")
|
||||
match_results.no_matches.append(
|
||||
Result(
|
||||
Action.save,
|
||||
Status.match_failure,
|
||||
ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.no_match,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# a single match!
|
||||
if low_confidence:
|
||||
self.auto_tag_log("Online search: Low confidence match, but saving anyways, as indicated...\n")
|
||||
|
||||
# now get the particular issue data
|
||||
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
|
||||
|
||||
try:
|
||||
|
||||
ct_md = self.current_talker().fetch_comic_data(matches[0].issue_id)
|
||||
except TalkerError:
|
||||
logger.exception("Save aborted.")
|
||||
return False, match_results
|
||||
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
if ct_md is None or ct_md.is_empty:
|
||||
match_results.fetch_data_failures.append(
|
||||
Result(
|
||||
Action.save,
|
||||
Status.fetch_data_failure,
|
||||
ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.good_match,
|
||||
)
|
||||
)
|
||||
|
||||
if ct_md is not None:
|
||||
temp_opts = cast(ct_ns, settngs.get_namespace(self.config, True, True, True, False)[0])
|
||||
temp_opts.Auto_Tag__clear_tags = dlg.cbxClearMetadata.isChecked()
|
||||
|
||||
md = prepare_metadata(md, ct_md, temp_opts)
|
||||
|
||||
res = Result(
|
||||
Action.save,
|
||||
status=Status.success,
|
||||
original_path=ca.path,
|
||||
online_results=matches,
|
||||
match_status=MatchStatus.good_match,
|
||||
md=md,
|
||||
tags_written=self.selected_write_tags,
|
||||
)
|
||||
|
||||
def write_Tags() -> bool:
|
||||
for tag_id in self.selected_write_tags:
|
||||
# write out the new data
|
||||
if not ca.write_tags(md, tag_id):
|
||||
self.auto_tag_log(
|
||||
f"{tags[tag_id].name()} save failed! Aborting any additional tag saves.\n"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
# Save tags
|
||||
if write_Tags():
|
||||
match_results.good_matches.append(res)
|
||||
success = True
|
||||
self.auto_tag_log("Save complete!\n")
|
||||
else:
|
||||
res.status = Status.write_failure
|
||||
match_results.write_failures.append(res)
|
||||
|
||||
ca.reset_cache()
|
||||
ca.load_cache({*self.selected_read_tags, *self.selected_write_tags})
|
||||
|
||||
return success, match_results
|
||||
|
||||
def auto_tag(self) -> None:
|
||||
ca_list = self.fileSelectionList.get_selected_archive_list()
|
||||
tag_names = ", ".join([tags[tag_id].name() for tag_id in self.selected_write_tags])
|
||||
@ -1915,44 +1775,37 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
self.atprogdialog.show()
|
||||
self.atprogdialog.progressBar.setMaximum(len(ca_list))
|
||||
self.atprogdialog.setWindowTitle("Auto-Tagging")
|
||||
|
||||
center_window_on_parent(self.atprogdialog)
|
||||
temp_opts = cast(ct_ns, settngs.get_namespace(self.config, True, True, True, False)[0])
|
||||
temp_opts.Auto_Tag__clear_tags = atstartdlg.cbxClearMetadata.isChecked()
|
||||
temp_opts.Issue_Identifier__series_match_identify_thresh = atstartdlg.name_length_match_tolerance
|
||||
temp_opts.Auto_Tag__ignore_leading_numbers_in_filename = atstartdlg.ignore_leading_digits_in_filename
|
||||
temp_opts.Auto_Tag__use_year_when_identifying = not atstartdlg.dont_use_year
|
||||
temp_opts.Auto_Tag__assume_issue_one = atstartdlg.assume_issue_one
|
||||
temp_opts.internal__remove_archive_after_successful_match = atstartdlg.remove_after_success
|
||||
temp_opts.Runtime_Options__tags_read = self.selected_read_tags
|
||||
temp_opts.Runtime_Options__tags_write = self.selected_write_tags
|
||||
|
||||
self.autotagthread = AutoTagThread(atstartdlg.search_string, ca_list, self.config[0], self.current_talker())
|
||||
|
||||
self.autotagthread.autoTagComplete.connect(self.auto_tag_finished)
|
||||
self.autotagthread.autoTagLogMsg.connect(self.auto_tag_log)
|
||||
self.autotagthread.autoTagProgress.connect(self.atprogdialog.on_progress)
|
||||
self.autotagthread.ratelimit.connect(self.ratelimit)
|
||||
|
||||
self.atprogdialog.rejected.connect(self.autotagthread.cancel)
|
||||
|
||||
self.auto_tag_log("==========================================================================\n")
|
||||
self.auto_tag_log(f"Auto-Tagging Started for {len(ca_list)} items\n")
|
||||
self.autotagthread.start()
|
||||
|
||||
match_results = OnlineMatchResults()
|
||||
archives_to_remove = []
|
||||
for prog_idx, ca in enumerate(ca_list):
|
||||
self.auto_tag_log("==========================================================================\n")
|
||||
self.auto_tag_log(f"Auto-Tagging {prog_idx} of {len(ca_list)}\n")
|
||||
self.auto_tag_log(f"{ca.path}\n")
|
||||
try:
|
||||
cover_idx = ca.read_tags(self.selected_read_tags[0]).get_cover_page_index_list()[0]
|
||||
except Exception as e:
|
||||
cover_idx = 0
|
||||
logger.error("Failed to load metadata for %s: %s", ca.path, e)
|
||||
image_data = ca.get_page(cover_idx)
|
||||
self.atprogdialog.set_archive_image(image_data)
|
||||
self.atprogdialog.set_test_image(b"")
|
||||
def auto_tag_finished(self, match_results: OnlineMatchResults, archives_to_remove: list[ComicArchive]) -> None:
|
||||
tag_names = ", ".join([tags[tag_id].name() for tag_id in self.selected_write_tags])
|
||||
if self.atprogdialog:
|
||||
self.atprogdialog.close()
|
||||
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
if self.atprogdialog.isdone:
|
||||
break
|
||||
self.atprogdialog.progressBar.setValue(prog_idx)
|
||||
|
||||
self.atprogdialog.label.setText(str(ca.path))
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
|
||||
if ca.is_writable():
|
||||
success, match_results = self.identify_and_tag_single_archive(ca, match_results, atstartdlg)
|
||||
|
||||
if success and atstartdlg.remove_after_success:
|
||||
archives_to_remove.append(ca)
|
||||
|
||||
self.atprogdialog.close()
|
||||
|
||||
if atstartdlg.remove_after_success:
|
||||
self.fileSelectionList.remove_archive_list(archives_to_remove)
|
||||
self.fileSelectionList.remove_archive_list(archives_to_remove)
|
||||
self.fileSelectionList.update_selected_rows()
|
||||
|
||||
new_ca = self.fileSelectionList.get_current_archive()
|
||||
@ -1998,7 +1851,6 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
self,
|
||||
match_results.multiple_matches,
|
||||
self.selected_write_tags,
|
||||
lambda match: self.current_talker().fetch_comic_data(match.issue_id),
|
||||
self.config[0],
|
||||
self.current_talker(),
|
||||
)
|
||||
@ -2139,28 +1991,19 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
self.config[0].internal__last_opened_folder = os.path.abspath(os.path.split(comic_archive.path)[0])
|
||||
self.comic_archive = comic_archive
|
||||
|
||||
self.metadata, error = self.read_selected_tags(self.selected_read_tags, self.comic_archive)
|
||||
self.metadata, _, error = self.read_selected_tags(self.selected_read_tags, self.comic_archive)
|
||||
if error is not None:
|
||||
logger.error("Failed to load tags from %s: %s", self.comic_archive.path, error)
|
||||
self.exception(f"Failed to load tags from {self.comic_archive.path}, see log for details\n\n")
|
||||
|
||||
self.update_ui_for_archive()
|
||||
|
||||
def read_selected_tags(self, tag_ids: list[str], ca: ComicArchive) -> tuple[GenericMetadata, Exception | None]:
|
||||
md = GenericMetadata()
|
||||
error = None
|
||||
try:
|
||||
for tag_id in tag_ids:
|
||||
metadata = ca.read_tags(tag_id)
|
||||
md.overlay(
|
||||
metadata,
|
||||
mode=self.config[0].Metadata_Options__tag_merge,
|
||||
merge_lists=self.config[0].Metadata_Options__tag_merge_lists,
|
||||
)
|
||||
except Exception as e:
|
||||
error = e
|
||||
|
||||
return md, error
|
||||
def read_selected_tags(
|
||||
self, tag_ids: list[str], ca: ComicArchive
|
||||
) -> tuple[GenericMetadata, list[str], Exception | None]:
|
||||
return read_selected_tags(
|
||||
tag_ids, ca, self.config[0].Metadata_Options__tag_merge, self.config[0].Metadata_Options__tag_merge_lists
|
||||
)
|
||||
|
||||
def file_list_cleared(self) -> None:
|
||||
self.reset_app()
|
||||
|
@ -153,7 +153,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QWidget" name="imageIssuesSourceLogo" native="true">
|
||||
<widget class="QWidget" name="imageSourceLogo" native="true">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>300</width>
|
||||
|
BIN
comictaggerlib/ui/pyqttoast/.DS_Store
vendored
Normal file
BIN
comictaggerlib/ui/pyqttoast/.DS_Store
vendored
Normal file
Binary file not shown.
21
comictaggerlib/ui/pyqttoast/LICENSE
Normal file
21
comictaggerlib/ui/pyqttoast/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Niklas Henning
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
231
comictaggerlib/ui/pyqttoast/README.md
Normal file
231
comictaggerlib/ui/pyqttoast/README.md
Normal file
@ -0,0 +1,231 @@
|
||||
# PyQt Toast
|
||||
|
||||
[![PyPI](https://img.shields.io/badge/pypi-v1.3.2-blue)](https://pypi.org/project/pyqt-toast-notification/)
|
||||
[![Python](https://img.shields.io/badge/python-3.7+-blue)](https://github.com/niklashenning/pyqttoast)
|
||||
[![Build](https://img.shields.io/badge/build-passing-neon)](https://github.com/niklashenning/pyqttoast)
|
||||
[![Coverage](https://img.shields.io/badge/coverage-95%25-green)](https://github.com/niklashenning/pyqttoast)
|
||||
[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/niklashenning/pyqttoast/blob/master/LICENSE)
|
||||
|
||||
A fully customizable and modern toast notification library for PyQt and PySide
|
||||
|
||||
![pyqttoast](https://github.com/niklashenning/pyqt-toast/assets/58544929/c104f10e-08df-4665-98d8-3785822a20dc)
|
||||
|
||||
## Features
|
||||
* Supports showing multiple toasts at the same time
|
||||
* Supports queueing of toasts
|
||||
* Supports 7 different positions
|
||||
* Supports multiple screens
|
||||
* Supports positioning relative to widgets
|
||||
* Modern and fully customizable UI
|
||||
* Works with `PyQt5`, `PyQt6`, `PySide2`, and `PySide6`
|
||||
|
||||
## Installation
|
||||
```
|
||||
pip install pyqt-toast-notification
|
||||
```
|
||||
|
||||
## Usage
|
||||
Import the `Toast` class, instantiate it, and show the toast notification with the `show()` method:
|
||||
|
||||
```python
|
||||
from PyQt6.QtWidgets import QMainWindow, QPushButton
|
||||
from pyqttoast import Toast, ToastPreset
|
||||
|
||||
|
||||
class Window(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__(parent=None)
|
||||
|
||||
# Add button and connect click event
|
||||
self.button = QPushButton(self)
|
||||
self.button.setText('Show toast')
|
||||
self.button.clicked.connect(self.show_toast)
|
||||
|
||||
# Shows a toast notification every time the button is clicked
|
||||
def show_toast(self):
|
||||
toast = Toast(self)
|
||||
toast.setDuration(5000) # Hide after 5 seconds
|
||||
toast.setTitle('Success! Confirmation email sent.')
|
||||
toast.setText('Check your email to complete signup.')
|
||||
toast.applyPreset(ToastPreset.SUCCESS) # Apply style preset
|
||||
toast.show()
|
||||
```
|
||||
|
||||
> **IMPORTANT:** <br>An instance of `Toast` can only be shown **once**. If you want to show another one, even if the content is exactly the same, you have to create another instance.
|
||||
|
||||
|
||||
## Customization
|
||||
|
||||
* **Setting the position of the toasts (<u>static</u>):**
|
||||
```python
|
||||
Toast.setPosition(ToastPosition.BOTTOM_MIDDLE) # Default: ToastPosition.BOTTOM_RIGHT
|
||||
```
|
||||
> **AVAILABLE POSITIONS:** <br> `BOTTOM_LEFT`, `BOTTOM_MIDDLE`, `BOTTOM_RIGHT`, `TOP_LEFT`, `TOP_MIDDLE`, `TOP_RIGHT`, `CENTER`
|
||||
|
||||
|
||||
* **Setting whether the toasts should always be shown on the main screen (<u>static</u>):**
|
||||
```python
|
||||
Toast.setAlwaysOnMainScreen(True) # Default: False
|
||||
```
|
||||
|
||||
* **Positioning the toasts relative to a widget instead of a screen (<u>static</u>):**
|
||||
```python
|
||||
Toast.setPositionRelativeToWidget(some_widget) # Default: None
|
||||
```
|
||||
|
||||
* **Setting a limit on how many toasts can be shown at the same time (<u>static</u>):**
|
||||
```python
|
||||
Toast.setMaximumOnScreen(5) # Default: 3
|
||||
```
|
||||
> If you try to show more toasts than the maximum amount on screen, they will get added to a queue and get shown as soon as one of the currently showing toasts is closed.
|
||||
|
||||
|
||||
* **Setting the vertical spacing between the toasts (<u>static</u>):**
|
||||
```python
|
||||
Toast.setSpacing(20) # Default: 10
|
||||
```
|
||||
|
||||
* **Setting the x and y offset of the toast position (<u>static</u>):**
|
||||
```python
|
||||
Toast.setOffset(30, 55) # Default: 20, 45
|
||||
```
|
||||
|
||||
* **Making the toast show forever until it is closed:**
|
||||
```python
|
||||
toast.setDuration(0) # Default: 5000
|
||||
```
|
||||
|
||||
* **Enabling or disabling the duration bar:**
|
||||
```python
|
||||
toast.setShowDurationBar(False) # Default: True
|
||||
```
|
||||
|
||||
* **Adding an icon:**
|
||||
```python
|
||||
toast.setIcon(ToastIcon.SUCCESS) # Default: ToastIcon.INFORMATION
|
||||
toast.setShowIcon(True) # Default: False
|
||||
|
||||
# Or setting a custom icon:
|
||||
toast.setIcon(QPixmap('path/to/your/icon.png'))
|
||||
|
||||
# If you want to show the icon without recoloring it, set the icon color to None:
|
||||
toast.setIconColor(None) # Default: #5C5C5C
|
||||
```
|
||||
> **AVAILABLE ICONS:** <br> `SUCCESS`, `WARNING`, `ERROR`, `INFORMATION`, `CLOSE`
|
||||
|
||||
|
||||
* **Setting the icon size:**
|
||||
```python
|
||||
toast.setIconSize(QSize(14, 14)) # Default: QSize(18, 18)
|
||||
```
|
||||
|
||||
* **Enabling or disabling the icon separator:**
|
||||
```python
|
||||
toast.setShowIconSeparator(False) # Default: True
|
||||
```
|
||||
|
||||
* **Setting the close button alignment:**
|
||||
```python
|
||||
toast.setCloseButtonAlignment(ToastButtonAlignment.MIDDLE) # Default: ToastButtonAlignment.TOP
|
||||
```
|
||||
> **AVAILABLE ALIGNMENTS:** <br> `TOP`, `MIDDLE`, `BOTTOM`
|
||||
|
||||
* **Enabling or disabling the close button:**
|
||||
```python
|
||||
toast.setShowCloseButton(False) # Default: True
|
||||
```
|
||||
|
||||
* **Customizing the duration of the fade animations (milliseconds):**
|
||||
```python
|
||||
toast.setFadeInDuration(100) # Default: 250
|
||||
toast.setFadeOutDuration(150) # Default: 250
|
||||
```
|
||||
|
||||
* **Enabling or disabling duration reset on hover:**
|
||||
|
||||
```python
|
||||
toast.setResetDurationOnHover(False) # Default: True
|
||||
```
|
||||
|
||||
* **Making the corners rounded:**
|
||||
```python
|
||||
toast.setBorderRadius(3) # Default: 0
|
||||
```
|
||||
|
||||
* **Setting custom colors:**
|
||||
```python
|
||||
toast.setBackgroundColor(QColor('#292929')) # Default: #E7F4F9
|
||||
toast.setTitleColor(QColor('#FFFFFF')) # Default: #000000
|
||||
toast.setTextColor(QColor('#D0D0D0')) # Default: #5C5C5C
|
||||
toast.setDurationBarColor(QColor('#3E9141')) # Default: #5C5C5C
|
||||
toast.setIconColor(QColor('#3E9141')) # Default: #5C5C5C
|
||||
toast.setIconSeparatorColor(QColor('#585858')) # Default: #D9D9D9
|
||||
toast.setCloseButtonIconColor(QColor('#C9C9C9')) # Default: #000000
|
||||
```
|
||||
|
||||
* **Setting custom fonts:**
|
||||
```python
|
||||
# Init font
|
||||
font = QFont('Times', 10, QFont.Weight.Bold)
|
||||
|
||||
# Set fonts
|
||||
toast.setTitleFont(font) # Default: QFont('Arial', 9, QFont.Weight.Bold)
|
||||
toast.setTextFont(font) # Default: QFont('Arial', 9)
|
||||
```
|
||||
|
||||
* **Applying a style preset:**
|
||||
```python
|
||||
toast.applyPreset(ToastPreset.ERROR)
|
||||
```
|
||||
> **AVAILABLE PRESETS:** <br> `SUCCESS`, `WARNING`, `ERROR`, `INFORMATION`, `SUCCESS_DARK`, `WARNING_DARK`, `ERROR_DARK`, `INFORMATION_DARK`
|
||||
|
||||
* **Setting toast size constraints:**
|
||||
```python
|
||||
# Minimum and maximum size
|
||||
toast.setMinimumWidth(100)
|
||||
toast.setMaximumWidth(350)
|
||||
toast.setMinimumHeight(50)
|
||||
toast.setMaximumHeight(120)
|
||||
|
||||
# Fixed size (not recommended)
|
||||
toast.setFixedSize(QSize(350, 80))
|
||||
```
|
||||
|
||||
|
||||
**<br>Other customization options:**
|
||||
|
||||
| Option | Description | Default |
|
||||
|-------------------------------|---------------------------------------------------------------------------------|----------------------------|
|
||||
| `setFixedScreen()` | Fixed screen where the toasts will be shown (static) | `None` |
|
||||
| `setMovePositionWithWidget()` | Whether the toasts should move with widget if positioned relative to a widget | `True` |
|
||||
| `setIconSeparatorWidth()` | Width of the icon separator that separates the icon and text section | `2` |
|
||||
| `setCloseButtonIcon()` | Icon of the close button | `ToastIcon.CLOSE` |
|
||||
| `setCloseButtonIconSize()` | Size of the close button icon | `QSize(10, 10)` |
|
||||
| `setCloseButtonSize()` | Size of the close button | `QSize(24, 24)` |
|
||||
| `setStayOnTop()` | Whether the toast stays on top of other windows even when they are focused | `True` |
|
||||
| `setTextSectionSpacing()` | Vertical spacing between the title and the text | `8` |
|
||||
| `setMargins()` | Margins around the whole toast content | `QMargins(20, 18, 10, 18)` |
|
||||
| `setIconMargins()` | Margins around the icon | `QMargins(0, 0, 15, 0)` |
|
||||
| `setIconSectionMargins()` | Margins around the icon section (the area with the icon and the icon separator) | `QMargins(0, 0, 15, 0)` |
|
||||
| `setTextSectionMargins()` | Margins around the text section (the area with the title and the text) | `QMargins(0, 0, 15, 0)` |
|
||||
| `setCloseButtonMargins()` | Margins around the close button | `QMargins(0, -8, 0, -8)` |
|
||||
|
||||
## Demo
|
||||
https://github.com/niklashenning/pyqt-toast/assets/58544929/f4d7f4a4-6d69-4087-ae19-da54b6da499d
|
||||
|
||||
The demos for PyQt5, PyQt6, and PySide6 can be found in the [demo](demo) folder.
|
||||
|
||||
## Tests
|
||||
Installing the required test dependencies [PyQt6](https://pypi.org/project/PyQt6/), [pytest](https://github.com/pytest-dev/pytest), and [coveragepy](https://github.com/nedbat/coveragepy):
|
||||
```
|
||||
pip install PyQt6 pytest coverage
|
||||
```
|
||||
|
||||
To run the tests with coverage, clone this repository, go into the main directory and run:
|
||||
```
|
||||
coverage run -m pytest
|
||||
coverage report --ignore-errors -m
|
||||
```
|
||||
|
||||
## License
|
||||
This software is licensed under the [MIT license](https://github.com/niklashenning/pyqttoast/blob/master/LICENSE).
|
11
comictaggerlib/ui/pyqttoast/__init__.py
Normal file
11
comictaggerlib/ui/pyqttoast/__init__.py
Normal file
@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .toast import Toast, ToastButtonAlignment, ToastIcon, ToastPosition, ToastPreset
|
||||
|
||||
__all__ = [
|
||||
"Toast",
|
||||
"ToastButtonAlignment",
|
||||
"ToastIcon",
|
||||
"ToastPosition",
|
||||
"ToastPreset",
|
||||
]
|
43
comictaggerlib/ui/pyqttoast/constants.py
Normal file
43
comictaggerlib/ui/pyqttoast/constants.py
Normal file
@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtGui import QColor
|
||||
|
||||
UPDATE_POSITION_DURATION = 200
|
||||
DURATION_BAR_UPDATE_INTERVAL = 1
|
||||
DROP_SHADOW_SIZE = 5
|
||||
SUCCESS_ACCENT_COLOR = QColor("#3E9141")
|
||||
WARNING_ACCENT_COLOR = QColor("#E8B849")
|
||||
ERROR_ACCENT_COLOR = QColor("#BA2626")
|
||||
INFORMATION_ACCENT_COLOR = QColor("#007FFF")
|
||||
DEFAULT_ACCENT_COLOR = QColor("#5C5C5C")
|
||||
DEFAULT_BACKGROUND_COLOR = QColor("#E7F4F9")
|
||||
DEFAULT_TITLE_COLOR = QColor("#000000")
|
||||
DEFAULT_TEXT_COLOR = QColor("#5C5C5C")
|
||||
DEFAULT_ICON_SEPARATOR_COLOR = QColor("#D9D9D9")
|
||||
DEFAULT_CLOSE_BUTTON_ICON_COLOR = QColor("#000000")
|
||||
DEFAULT_BACKGROUND_COLOR_DARK = QColor("#292929")
|
||||
DEFAULT_TITLE_COLOR_DARK = QColor("#FFFFFF")
|
||||
DEFAULT_TEXT_COLOR_DARK = QColor("#D0D0D0")
|
||||
DEFAULT_ICON_SEPARATOR_COLOR_DARK = QColor("#585858")
|
||||
DEFAULT_CLOSE_BUTTON_ICON_COLOR_DARK = QColor("#C9C9C9")
|
||||
|
||||
__all__ = [
|
||||
"UPDATE_POSITION_DURATION",
|
||||
"DURATION_BAR_UPDATE_INTERVAL",
|
||||
"DROP_SHADOW_SIZE",
|
||||
"SUCCESS_ACCENT_COLOR",
|
||||
"WARNING_ACCENT_COLOR",
|
||||
"ERROR_ACCENT_COLOR",
|
||||
"INFORMATION_ACCENT_COLOR",
|
||||
"DEFAULT_ACCENT_COLOR",
|
||||
"DEFAULT_BACKGROUND_COLOR",
|
||||
"DEFAULT_TITLE_COLOR",
|
||||
"DEFAULT_TEXT_COLOR",
|
||||
"DEFAULT_ICON_SEPARATOR_COLOR",
|
||||
"DEFAULT_CLOSE_BUTTON_ICON_COLOR",
|
||||
"DEFAULT_BACKGROUND_COLOR_DARK",
|
||||
"DEFAULT_TITLE_COLOR_DARK",
|
||||
"DEFAULT_TEXT_COLOR_DARK",
|
||||
"DEFAULT_ICON_SEPARATOR_COLOR_DARK",
|
||||
"DEFAULT_CLOSE_BUTTON_ICON_COLOR_DARK",
|
||||
]
|
5
comictaggerlib/ui/pyqttoast/css/__init__.py
Normal file
5
comictaggerlib/ui/pyqttoast/css/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.resources
|
||||
|
||||
css_path = importlib.resources.files(__package__)
|
24
comictaggerlib/ui/pyqttoast/css/drop_shadow.css
Normal file
24
comictaggerlib/ui/pyqttoast/css/drop_shadow.css
Normal file
@ -0,0 +1,24 @@
|
||||
#drop-shadow-layer-1 {
|
||||
background: rgba(0, 0, 0, 3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#drop-shadow-layer-2 {
|
||||
background: rgba(0, 0, 0, 5);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#drop-shadow-layer-3 {
|
||||
background: rgba(0, 0, 0, 6);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#drop-shadow-layer-4 {
|
||||
background: rgba(0, 0, 0, 9);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#drop-shadow-layer-5 {
|
||||
background: rgba(0, 0, 0, 10);
|
||||
border-radius: 8px;
|
||||
}
|
7
comictaggerlib/ui/pyqttoast/css/toast.css
Normal file
7
comictaggerlib/ui/pyqttoast/css/toast.css
Normal file
@ -0,0 +1,7 @@
|
||||
#toast-close-button {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#toast-icon-widget {
|
||||
background: transparent;
|
||||
}
|
57
comictaggerlib/ui/pyqttoast/drop_shadow.py
Normal file
57
comictaggerlib/ui/pyqttoast/drop_shadow.py
Normal file
@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import QSize
|
||||
from PyQt5.QtWidgets import QWidget
|
||||
|
||||
from .css import css_path
|
||||
|
||||
|
||||
class DropShadow(QWidget):
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
"""Create a new DropShadow instance
|
||||
|
||||
:param parent: the parent widget
|
||||
"""
|
||||
|
||||
super().__init__(parent)
|
||||
|
||||
# Drawn manually since only one graphics effect can be applied
|
||||
self.layer_1 = QWidget(self)
|
||||
self.layer_1.setObjectName("drop-shadow-layer-1")
|
||||
|
||||
self.layer_2 = QWidget(self)
|
||||
self.layer_2.setObjectName("drop-shadow-layer-2")
|
||||
|
||||
self.layer_3 = QWidget(self)
|
||||
self.layer_3.setObjectName("drop-shadow-layer-3")
|
||||
|
||||
self.layer_4 = QWidget(self)
|
||||
self.layer_4.setObjectName("drop-shadow-layer-4")
|
||||
|
||||
self.layer_5 = QWidget(self)
|
||||
self.layer_5.setObjectName("drop-shadow-layer-5")
|
||||
|
||||
# Apply stylesheet
|
||||
self.setStyleSheet((css_path / "drop_shadow.css").read_text(encoding="utf-8"))
|
||||
|
||||
def resize(self, size: QSize) -> None:
|
||||
"""Resize the drop shadow widget
|
||||
|
||||
:param size: new size
|
||||
"""
|
||||
|
||||
super().resize(size)
|
||||
width = size.width()
|
||||
height = size.height()
|
||||
|
||||
self.layer_1.resize(width, height)
|
||||
self.layer_1.move(0, 0)
|
||||
self.layer_2.resize(width - 2, height - 2)
|
||||
self.layer_2.move(1, 1)
|
||||
self.layer_3.resize(width - 4, height - 4)
|
||||
self.layer_3.move(2, 2)
|
||||
self.layer_4.resize(width - 6, height - 6)
|
||||
self.layer_4.move(3, 3)
|
||||
self.layer_5.resize(width - 8, height - 8)
|
||||
self.layer_5.move(4, 4)
|
7
comictaggerlib/ui/pyqttoast/hooks/__init__.py
Normal file
7
comictaggerlib/ui/pyqttoast/hooks/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def get_hook_dirs() -> list[str]:
|
||||
return [os.path.dirname(__file__)]
|
5
comictaggerlib/ui/pyqttoast/hooks/hook-pyqttoast.py
Normal file
5
comictaggerlib/ui/pyqttoast/hooks/hook-pyqttoast.py
Normal file
@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PyInstaller.utils.hooks import collect_data_files
|
||||
|
||||
datas = collect_data_files("pyqttoast", excludes=["hooks"])
|
55
comictaggerlib/ui/pyqttoast/icon_utils.py
Normal file
55
comictaggerlib/ui/pyqttoast/icon_utils.py
Normal file
@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtGui import QColor, QImage, QPixmap, qRgba
|
||||
|
||||
from .icons import icon_path
|
||||
from .toast_enums import ToastIcon
|
||||
|
||||
|
||||
class IconUtils:
|
||||
|
||||
@staticmethod
|
||||
def get_icon_from_enum(enum_icon: ToastIcon) -> QPixmap:
|
||||
"""Get a QPixmap from a ToastIcon
|
||||
|
||||
:param enum_icon: ToastIcon
|
||||
:return: pixmap of the ToastIcon
|
||||
"""
|
||||
image = QPixmap()
|
||||
if enum_icon == ToastIcon.SUCCESS:
|
||||
image.loadFromData((icon_path / "success.png").read_bytes())
|
||||
elif enum_icon == ToastIcon.WARNING:
|
||||
image.loadFromData((icon_path / "warning.png").read_bytes())
|
||||
elif enum_icon == ToastIcon.ERROR:
|
||||
image.loadFromData((icon_path / "error.png").read_bytes())
|
||||
elif enum_icon == ToastIcon.INFORMATION:
|
||||
image.loadFromData((icon_path / "information.png").read_bytes())
|
||||
elif enum_icon == ToastIcon.CLOSE:
|
||||
image.loadFromData((icon_path / "close.png").read_bytes())
|
||||
return image
|
||||
|
||||
@staticmethod
|
||||
def recolor_image(image: QImage, color: QColor | None) -> QImage:
|
||||
"""Take an image and return a copy with the colors changed
|
||||
|
||||
:param image: image to recolor
|
||||
:param color: new color (None if the image should not be recolored)
|
||||
:return: recolored image
|
||||
"""
|
||||
|
||||
# Leave image as is if color is None
|
||||
if color is None:
|
||||
return image
|
||||
|
||||
# Loop through every pixel
|
||||
for x in range(0, image.width()):
|
||||
for y in range(0, image.height()):
|
||||
# Get current color of the pixel
|
||||
current_color = image.pixelColor(x, y)
|
||||
# Replace the rgb values with rgb of new color and keep alpha the same
|
||||
new_color_r = color.red()
|
||||
new_color_g = color.green()
|
||||
new_color_b = color.blue()
|
||||
new_color = QColor.fromRgba(qRgba(new_color_r, new_color_g, new_color_b, current_color.alpha()))
|
||||
image.setPixelColor(x, y, new_color)
|
||||
return image
|
5
comictaggerlib/ui/pyqttoast/icons/__init__.py
Normal file
5
comictaggerlib/ui/pyqttoast/icons/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.resources
|
||||
|
||||
icon_path = importlib.resources.files(__package__)
|
BIN
comictaggerlib/ui/pyqttoast/icons/close.png
Normal file
BIN
comictaggerlib/ui/pyqttoast/icons/close.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
BIN
comictaggerlib/ui/pyqttoast/icons/error.png
Normal file
BIN
comictaggerlib/ui/pyqttoast/icons/error.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
BIN
comictaggerlib/ui/pyqttoast/icons/information.png
Normal file
BIN
comictaggerlib/ui/pyqttoast/icons/information.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.9 KiB |
BIN
comictaggerlib/ui/pyqttoast/icons/success.png
Normal file
BIN
comictaggerlib/ui/pyqttoast/icons/success.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
BIN
comictaggerlib/ui/pyqttoast/icons/warning.png
Normal file
BIN
comictaggerlib/ui/pyqttoast/icons/warning.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.8 KiB |
1
comictaggerlib/ui/pyqttoast/tests/__init__.py
Normal file
1
comictaggerlib/ui/pyqttoast/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# This file is needed for the tests be able to run properly
|
1172
comictaggerlib/ui/pyqttoast/tests/toast_test.py
Normal file
1172
comictaggerlib/ui/pyqttoast/tests/toast_test.py
Normal file
File diff suppressed because it is too large
Load Diff
2404
comictaggerlib/ui/pyqttoast/toast.py
Normal file
2404
comictaggerlib/ui/pyqttoast/toast.py
Normal file
File diff suppressed because it is too large
Load Diff
38
comictaggerlib/ui/pyqttoast/toast_enums.py
Normal file
38
comictaggerlib/ui/pyqttoast/toast_enums.py
Normal file
@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ToastPreset(Enum):
|
||||
SUCCESS = 1
|
||||
WARNING = 2
|
||||
ERROR = 3
|
||||
INFORMATION = 4
|
||||
SUCCESS_DARK = 5
|
||||
WARNING_DARK = 6
|
||||
ERROR_DARK = 7
|
||||
INFORMATION_DARK = 8
|
||||
|
||||
|
||||
class ToastIcon(Enum):
|
||||
SUCCESS = 1
|
||||
WARNING = 2
|
||||
ERROR = 3
|
||||
INFORMATION = 4
|
||||
CLOSE = 5
|
||||
|
||||
|
||||
class ToastPosition(Enum):
|
||||
BOTTOM_LEFT = 1
|
||||
BOTTOM_MIDDLE = 2
|
||||
BOTTOM_RIGHT = 3
|
||||
TOP_LEFT = 4
|
||||
TOP_MIDDLE = 5
|
||||
TOP_RIGHT = 6
|
||||
CENTER = 7
|
||||
|
||||
|
||||
class ToastButtonAlignment(Enum):
|
||||
TOP = 1
|
||||
MIDDLE = 2
|
||||
BOTTOM = 3
|
@ -31,7 +31,7 @@
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item alignment="Qt::AlignTop">
|
||||
<widget class="QWidget" name="imageContainer" native="true">
|
||||
<widget class="QWidget" name="coverImageContainer" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
@ -66,7 +66,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="lblSourceName">
|
||||
<widget class="QLabel" name="lblIssuesSourceName">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
@ -177,7 +177,7 @@
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
<widget class="QTextEdit" name="teDetails">
|
||||
<widget class="QTextEdit" name="teDescription">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
@ -223,7 +223,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="cbxFilter">
|
||||
<widget class="QCheckBox" name="cbxPublisherFilter">
|
||||
<property name="toolTip">
|
||||
<string>Filter the publishers based on the publisher filter.</string>
|
||||
</property>
|
||||
|
@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, NamedTuple
|
||||
|
||||
import settngs
|
||||
|
||||
@ -25,6 +25,11 @@ from comictalker.talker_utils import fix_url
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RLCallBack(NamedTuple):
|
||||
callback: Callable[[float, float], None]
|
||||
interval: float
|
||||
|
||||
|
||||
class TalkerError(Exception):
|
||||
"""Base class exception for information sources.
|
||||
|
||||
@ -170,6 +175,8 @@ class ComicTalker:
|
||||
refresh_cache: bool = False,
|
||||
literal: bool = False,
|
||||
series_match_thresh: int = 90,
|
||||
*,
|
||||
on_rate_limit: RLCallBack | None = None,
|
||||
) -> list[ComicSeries]:
|
||||
"""
|
||||
This function should return a list of series that match the given series name
|
||||
@ -191,7 +198,12 @@ class ComicTalker:
|
||||
raise NotImplementedError
|
||||
|
||||
def fetch_comic_data(
|
||||
self, issue_id: str | None = None, series_id: str | None = None, issue_number: str = ""
|
||||
self,
|
||||
issue_id: str | None = None,
|
||||
series_id: str | None = None,
|
||||
issue_number: str = "",
|
||||
*,
|
||||
on_rate_limit: RLCallBack | None = None,
|
||||
) -> GenericMetadata:
|
||||
"""
|
||||
This function should return an instance of GenericMetadata for a single issue.
|
||||
@ -210,19 +222,34 @@ class ComicTalker:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def fetch_series(self, series_id: str) -> ComicSeries:
|
||||
def fetch_series(
|
||||
self,
|
||||
series_id: str,
|
||||
*,
|
||||
on_rate_limit: RLCallBack | None = None,
|
||||
) -> ComicSeries:
|
||||
"""
|
||||
This function should return an instance of ComicSeries from the given series ID.
|
||||
Caching MUST be implemented on this function.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def fetch_issues_in_series(self, series_id: str) -> list[GenericMetadata]:
|
||||
def fetch_issues_in_series(
|
||||
self,
|
||||
series_id: str,
|
||||
*,
|
||||
on_rate_limit: RLCallBack | None = None,
|
||||
) -> list[GenericMetadata]:
|
||||
"""Expected to return a list of issues with a given series ID"""
|
||||
raise NotImplementedError
|
||||
|
||||
def fetch_issues_by_series_issue_num_and_year(
|
||||
self, series_id_list: list[str], issue_number: str, year: int | None
|
||||
self,
|
||||
series_id_list: list[str],
|
||||
issue_number: str,
|
||||
year: int | None,
|
||||
*,
|
||||
on_rate_limit: RLCallBack | None = None,
|
||||
) -> list[GenericMetadata]:
|
||||
"""
|
||||
This function should return a single issue for each series id in
|
||||
|
@ -27,7 +27,6 @@ from typing import Any, Callable, Generic, TypeVar, cast
|
||||
from urllib.parse import parse_qsl, urljoin
|
||||
|
||||
import settngs
|
||||
from pyrate_limiter import Limiter, RequestRate
|
||||
from typing_extensions import Required, TypedDict
|
||||
|
||||
from comicapi import utils
|
||||
@ -36,7 +35,8 @@ from comicapi.issuestring import IssueString
|
||||
from comicapi.utils import LocationParseError, parse_url
|
||||
from comictalker import talker_utils
|
||||
from comictalker.comiccacher import ComicCacher, Issue, Series
|
||||
from comictalker.comictalker import ComicTalker, TalkerDataError, TalkerNetworkError
|
||||
from comictalker.comictalker import ComicTalker, RLCallBack, TalkerDataError, TalkerNetworkError
|
||||
from comictalker.vendor.pyrate_limiter import Limiter, RequestRate
|
||||
|
||||
try:
|
||||
import niquests as requests
|
||||
@ -101,7 +101,7 @@ class CVSeries(TypedDict, total=False):
|
||||
description: str
|
||||
id: Required[int]
|
||||
image: CVImage
|
||||
name: str
|
||||
name: Required[str]
|
||||
publisher: CVPublisher
|
||||
start_year: str
|
||||
resource_type: str
|
||||
@ -270,6 +270,8 @@ class ComicVineTalker(ComicTalker):
|
||||
refresh_cache: bool = False,
|
||||
literal: bool = False,
|
||||
series_match_thresh: int = 90,
|
||||
*,
|
||||
on_rate_limit: RLCallBack | None = None,
|
||||
) -> list[ComicSeries]:
|
||||
# Sanitize the series name for comicvine searching, comicvine search ignore symbols
|
||||
search_series_name = utils.sanitize_title(series_name, basic=literal)
|
||||
@ -301,7 +303,11 @@ class ComicVineTalker(ComicTalker):
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
cv_response: CVResult[list[CVSeries]] = self._get_cv_content(urljoin(self.api_url, "search"), params)
|
||||
cv_response: CVResult[list[CVSeries]] = self._get_cv_content(
|
||||
urljoin(self.api_url, "search"),
|
||||
params,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
|
||||
search_results: list[CVSeries] = []
|
||||
|
||||
@ -346,7 +352,11 @@ class ComicVineTalker(ComicTalker):
|
||||
page += 1
|
||||
|
||||
params["page"] = page
|
||||
cv_response = self._get_cv_content(urljoin(self.api_url, "search"), params)
|
||||
cv_response = self._get_cv_content(
|
||||
urljoin(self.api_url, "search"),
|
||||
params,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
|
||||
search_results.extend(cv_response["results"])
|
||||
current_result_count += cv_response["number_of_page_results"]
|
||||
@ -369,24 +379,56 @@ class ComicVineTalker(ComicTalker):
|
||||
return formatted_search_results
|
||||
|
||||
def fetch_comic_data(
|
||||
self, issue_id: str | None = None, series_id: str | None = None, issue_number: str = ""
|
||||
self,
|
||||
issue_id: str | None = None,
|
||||
series_id: str | None = None,
|
||||
issue_number: str = "",
|
||||
on_rate_limit: RLCallBack | None = None,
|
||||
) -> GenericMetadata:
|
||||
comic_data = GenericMetadata()
|
||||
if issue_id:
|
||||
comic_data = self._fetch_issue_data_by_issue_id(issue_id)
|
||||
comic_data = self._fetch_issue_data_by_issue_id(
|
||||
issue_id,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
elif issue_number and series_id:
|
||||
comic_data = self._fetch_issue_data(int(series_id), issue_number)
|
||||
comic_data = self._fetch_issue_data(
|
||||
int(series_id),
|
||||
issue_number,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
|
||||
return comic_data
|
||||
|
||||
def fetch_series(self, series_id: str) -> ComicSeries:
|
||||
return self._fetch_series_data(int(series_id))[0]
|
||||
def fetch_series(
|
||||
self,
|
||||
series_id: str,
|
||||
on_rate_limit: RLCallBack | None = None,
|
||||
) -> ComicSeries:
|
||||
return self._fetch_series_data(
|
||||
int(series_id),
|
||||
on_rate_limit=on_rate_limit,
|
||||
)[0]
|
||||
|
||||
def fetch_issues_in_series(self, series_id: str) -> list[GenericMetadata]:
|
||||
return [x[0] for x in self._fetch_issues_in_series(series_id)]
|
||||
def fetch_issues_in_series(
|
||||
self,
|
||||
series_id: str,
|
||||
on_rate_limit: RLCallBack | None = None,
|
||||
) -> list[GenericMetadata]:
|
||||
return [
|
||||
x[0]
|
||||
for x in self._fetch_issues_in_series(
|
||||
series_id,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
]
|
||||
|
||||
def fetch_issues_by_series_issue_num_and_year(
|
||||
self, series_id_list: list[str], issue_number: str, year: str | int | None
|
||||
self,
|
||||
series_id_list: list[str],
|
||||
issue_number: str,
|
||||
year: str | int | None,
|
||||
on_rate_limit: RLCallBack | None = None,
|
||||
) -> list[GenericMetadata]:
|
||||
logger.debug("Fetching comics by series ids: %s and number: %s", series_id_list, issue_number)
|
||||
# before we search online, look in our cache, since we might already have this info
|
||||
@ -408,7 +450,12 @@ class ComicVineTalker(ComicTalker):
|
||||
cached_results.append(
|
||||
self._map_comic_issue_to_metadata(
|
||||
cvissue,
|
||||
self._fetch_series([int(cvissue["volume"]["id"])])[0][0],
|
||||
self._fetch_series(
|
||||
[int(cvissue["volume"]["id"])],
|
||||
on_rate_limit=on_rate_limit,
|
||||
)[
|
||||
0
|
||||
][0],
|
||||
),
|
||||
)
|
||||
issue_found = True
|
||||
@ -440,7 +487,11 @@ class ComicVineTalker(ComicTalker):
|
||||
"filter": flt,
|
||||
}
|
||||
|
||||
cv_response: CVResult[list[CVIssue]] = self._get_cv_content(urljoin(self.api_url, "issues/"), params)
|
||||
cv_response: CVResult[list[CVIssue]] = self._get_cv_content(
|
||||
urljoin(self.api_url, "issues/"),
|
||||
params,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
|
||||
current_result_count = cv_response["number_of_page_results"]
|
||||
total_result_count = cv_response["number_of_total_results"]
|
||||
@ -455,7 +506,11 @@ class ComicVineTalker(ComicTalker):
|
||||
offset += cv_response["number_of_page_results"]
|
||||
|
||||
params["offset"] = offset
|
||||
cv_response = self._get_cv_content(urljoin(self.api_url, "issues/"), params)
|
||||
cv_response = self._get_cv_content(
|
||||
urljoin(self.api_url, "issues/"),
|
||||
params,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
|
||||
filtered_issues_result.extend(cv_response["results"])
|
||||
current_result_count += cv_response["number_of_page_results"]
|
||||
@ -470,14 +525,25 @@ class ComicVineTalker(ComicTalker):
|
||||
)
|
||||
|
||||
formatted_filtered_issues_result = [
|
||||
self._map_comic_issue_to_metadata(x, self._fetch_series_data(x["volume"]["id"])[0])
|
||||
self._map_comic_issue_to_metadata(
|
||||
x,
|
||||
self._fetch_series_data(
|
||||
x["volume"]["id"],
|
||||
on_rate_limit=on_rate_limit,
|
||||
)[0],
|
||||
)
|
||||
for x in filtered_issues_result
|
||||
]
|
||||
formatted_filtered_issues_result.extend(cached_results)
|
||||
|
||||
return formatted_filtered_issues_result
|
||||
|
||||
def fetch_comics(self, *, issue_ids: list[str]) -> list[GenericMetadata]:
|
||||
def fetch_comics(
|
||||
self,
|
||||
*,
|
||||
issue_ids: list[str],
|
||||
on_rate_limit: RLCallBack | None = None,
|
||||
) -> list[GenericMetadata]:
|
||||
logger.debug("Fetching comic IDs: %s", issue_ids)
|
||||
# before we search online, look in our cache, since we might already have this info
|
||||
cvc = ComicCacher(self.cache_folder, self.version)
|
||||
@ -490,7 +556,12 @@ class ComicVineTalker(ComicTalker):
|
||||
cached_results.append(
|
||||
self._map_comic_issue_to_metadata(
|
||||
json.loads(cached_issue[0].data),
|
||||
self._fetch_series([int(cached_issue[0].series_id)])[0][0],
|
||||
self._fetch_series(
|
||||
[int(cached_issue[0].series_id)],
|
||||
on_rate_limit=on_rate_limit,
|
||||
)[
|
||||
0
|
||||
][0],
|
||||
),
|
||||
)
|
||||
else:
|
||||
@ -511,7 +582,11 @@ class ComicVineTalker(ComicTalker):
|
||||
"format": "json",
|
||||
"filter": flt,
|
||||
}
|
||||
cv_response: CVResult[list[CVIssue]] = self._get_cv_content(issue_url, params)
|
||||
cv_response: CVResult[list[CVIssue]] = self._get_cv_content(
|
||||
issue_url,
|
||||
params,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
|
||||
issue_results = cv_response["results"]
|
||||
page = 1
|
||||
@ -525,12 +600,22 @@ class ComicVineTalker(ComicTalker):
|
||||
offset += cv_response["number_of_page_results"]
|
||||
|
||||
params["offset"] = offset
|
||||
cv_response = self._get_cv_content(issue_url, params)
|
||||
cv_response = self._get_cv_content(
|
||||
issue_url,
|
||||
params,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
|
||||
issue_results.extend(cv_response["results"])
|
||||
current_result_count += cv_response["number_of_page_results"]
|
||||
|
||||
series_info = {s[0].id: s[0] for s in self._fetch_series([int(i["volume"]["id"]) for i in issue_results])}
|
||||
series_info = {
|
||||
s[0].id: s[0]
|
||||
for s in self._fetch_series(
|
||||
[int(i["volume"]["id"]) for i in issue_results],
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
}
|
||||
|
||||
for issue in issue_results:
|
||||
cvc.add_issues_info(
|
||||
@ -550,7 +635,11 @@ class ComicVineTalker(ComicTalker):
|
||||
|
||||
return cached_results
|
||||
|
||||
def _fetch_series(self, series_ids: list[int]) -> list[tuple[ComicSeries, bool]]:
|
||||
def _fetch_series(
|
||||
self,
|
||||
series_ids: list[int],
|
||||
on_rate_limit: RLCallBack | None,
|
||||
) -> list[tuple[ComicSeries, bool]]:
|
||||
# before we search online, look in our cache, since we might already have this info
|
||||
cvc = ComicCacher(self.cache_folder, self.version)
|
||||
cached_results: list[tuple[ComicSeries, bool]] = []
|
||||
@ -576,7 +665,11 @@ class ComicVineTalker(ComicTalker):
|
||||
"format": "json",
|
||||
"filter": flt,
|
||||
}
|
||||
cv_response: CVResult[list[CVSeries]] = self._get_cv_content(series_url, params)
|
||||
cv_response: CVResult[list[CVSeries]] = self._get_cv_content(
|
||||
series_url,
|
||||
params,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
|
||||
series_results = cv_response["results"]
|
||||
page = 1
|
||||
@ -590,7 +683,11 @@ class ComicVineTalker(ComicTalker):
|
||||
offset += cv_response["number_of_page_results"]
|
||||
|
||||
params["offset"] = offset
|
||||
cv_response = self._get_cv_content(series_url, params)
|
||||
cv_response = self._get_cv_content(
|
||||
series_url,
|
||||
params,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
|
||||
series_results.extend(cv_response["results"])
|
||||
current_result_count += cv_response["number_of_page_results"]
|
||||
@ -606,14 +703,24 @@ class ComicVineTalker(ComicTalker):
|
||||
|
||||
return cached_results
|
||||
|
||||
def _get_cv_content(self, url: str, params: dict[str, Any]) -> CVResult[T]:
|
||||
def _get_cv_content(
|
||||
self,
|
||||
url: str,
|
||||
params: dict[str, Any],
|
||||
*,
|
||||
on_rate_limit: RLCallBack | None,
|
||||
) -> CVResult[T]:
|
||||
"""
|
||||
Get the content from the CV server.
|
||||
"""
|
||||
ratelimit_key = url
|
||||
if self.api_key == self.default_api_key:
|
||||
ratelimit_key = "cv"
|
||||
with self.limiter.ratelimit(ratelimit_key, delay=True):
|
||||
with self.limiter.ratelimit(
|
||||
ratelimit_key,
|
||||
delay=True,
|
||||
on_rate_limit=on_rate_limit,
|
||||
):
|
||||
|
||||
cv_response: CVResult[T] = self._get_url_content(url, params)
|
||||
if cv_response["status_code"] != 1:
|
||||
@ -644,7 +751,7 @@ class ComicVineTalker(ComicTalker):
|
||||
logger.debug(str(resp.status_code))
|
||||
|
||||
elif resp.status_code in (requests.status_codes.codes.TOO_MANY_REQUESTS, TWITTER_TOO_MANY_REQUESTS):
|
||||
logger.info(f"{self.name} rate limit encountered. Waiting for 10 seconds\n")
|
||||
logger.info(f"{self.name} rate limit encountered. Waiting 10 seconds\n")
|
||||
self._log_total_requests()
|
||||
time.sleep(10)
|
||||
limit_counter += 1
|
||||
@ -709,13 +816,20 @@ class ComicVineTalker(ComicTalker):
|
||||
format=None,
|
||||
)
|
||||
|
||||
def _fetch_issues_in_series(self, series_id: str) -> list[tuple[GenericMetadata, bool]]:
|
||||
def _fetch_issues_in_series(
|
||||
self,
|
||||
series_id: str,
|
||||
on_rate_limit: RLCallBack | None,
|
||||
) -> list[tuple[GenericMetadata, bool]]:
|
||||
logger.debug("Fetching all issues in series: %s", series_id)
|
||||
# before we search online, look in our cache, since we might already have this info
|
||||
cvc = ComicCacher(self.cache_folder, self.version)
|
||||
cached_results = cvc.get_series_issues_info(series_id, self.id)
|
||||
|
||||
series = self._fetch_series_data(int(series_id))[0]
|
||||
series = self._fetch_series_data(
|
||||
int(series_id),
|
||||
on_rate_limit=on_rate_limit,
|
||||
)[0]
|
||||
|
||||
logger.debug(
|
||||
"Found %d issues cached need %d issues",
|
||||
@ -731,7 +845,11 @@ class ComicVineTalker(ComicTalker):
|
||||
"format": "json",
|
||||
"offset": 0,
|
||||
}
|
||||
cv_response: CVResult[list[CVIssue]] = self._get_cv_content(urljoin(self.api_url, "issues/"), params)
|
||||
cv_response: CVResult[list[CVIssue]] = self._get_cv_content(
|
||||
urljoin(self.api_url, "issues/"),
|
||||
params,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
|
||||
current_result_count = cv_response["number_of_page_results"]
|
||||
total_result_count = cv_response["number_of_total_results"]
|
||||
@ -746,13 +864,23 @@ class ComicVineTalker(ComicTalker):
|
||||
offset += cv_response["number_of_page_results"]
|
||||
|
||||
params["offset"] = offset
|
||||
cv_response = self._get_cv_content(urljoin(self.api_url, "issues/"), params)
|
||||
cv_response = self._get_cv_content(
|
||||
urljoin(self.api_url, "issues/"),
|
||||
params,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
|
||||
series_issues_result.extend(cv_response["results"])
|
||||
current_result_count += cv_response["number_of_page_results"]
|
||||
# Format to expected output
|
||||
formatted_series_issues_result = [
|
||||
self._map_comic_issue_to_metadata(x, self._fetch_series_data(x["volume"]["id"])[0])
|
||||
self._map_comic_issue_to_metadata(
|
||||
x,
|
||||
self._fetch_series_data(
|
||||
x["volume"]["id"],
|
||||
on_rate_limit=on_rate_limit,
|
||||
)[0],
|
||||
)
|
||||
for x in series_issues_result
|
||||
]
|
||||
|
||||
@ -766,7 +894,11 @@ class ComicVineTalker(ComicTalker):
|
||||
)
|
||||
return [(x, False) for x in formatted_series_issues_result]
|
||||
|
||||
def _fetch_series_data(self, series_id: int) -> tuple[ComicSeries, bool]:
|
||||
def _fetch_series_data(
|
||||
self,
|
||||
series_id: int,
|
||||
on_rate_limit: RLCallBack | None,
|
||||
) -> tuple[ComicSeries, bool]:
|
||||
logger.debug("Fetching series info: %s", series_id)
|
||||
# before we search online, look in our cache, since we might already have this info
|
||||
cvc = ComicCacher(self.cache_folder, self.version)
|
||||
@ -782,7 +914,11 @@ class ComicVineTalker(ComicTalker):
|
||||
"api_key": self.api_key,
|
||||
"format": "json",
|
||||
}
|
||||
cv_response: CVResult[CVSeries] = self._get_cv_content(series_url, params)
|
||||
cv_response: CVResult[CVSeries] = self._get_cv_content(
|
||||
series_url,
|
||||
params,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
|
||||
series_results = cv_response["results"]
|
||||
|
||||
@ -793,9 +929,17 @@ class ComicVineTalker(ComicTalker):
|
||||
|
||||
return self._format_series(series_results), True
|
||||
|
||||
def _fetch_issue_data(self, series_id: int, issue_number: str) -> GenericMetadata:
|
||||
def _fetch_issue_data(
|
||||
self,
|
||||
series_id: int,
|
||||
issue_number: str,
|
||||
on_rate_limit: RLCallBack | None,
|
||||
) -> GenericMetadata:
|
||||
logger.debug("Fetching issue by series ID: %s and issue number: %s", series_id, issue_number)
|
||||
issues_list_results = self._fetch_issues_in_series(str(series_id))
|
||||
issues_list_results = self._fetch_issues_in_series(
|
||||
str(series_id),
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
|
||||
# Loop through issue list to find the required issue info
|
||||
f_record = (GenericMetadata(), False)
|
||||
@ -811,10 +955,17 @@ class ComicVineTalker(ComicTalker):
|
||||
return f_record[0]
|
||||
|
||||
if f_record[0].issue_id is not None:
|
||||
return self._fetch_issue_data_by_issue_id(f_record[0].issue_id)
|
||||
return self._fetch_issue_data_by_issue_id(
|
||||
f_record[0].issue_id,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
return GenericMetadata()
|
||||
|
||||
def _fetch_issue_data_by_issue_id(self, issue_id: str) -> GenericMetadata:
|
||||
def _fetch_issue_data_by_issue_id(
|
||||
self,
|
||||
issue_id: str,
|
||||
on_rate_limit: RLCallBack | None,
|
||||
) -> GenericMetadata:
|
||||
logger.debug("Fetching issue by issue ID: %s", issue_id)
|
||||
# before we search online, look in our cache, since we might already have this info
|
||||
cvc = ComicCacher(self.cache_folder, self.version)
|
||||
@ -823,12 +974,20 @@ class ComicVineTalker(ComicTalker):
|
||||
logger.debug("Issue cached: %s", bool(cached_issue and cached_issue[1]))
|
||||
if cached_issue and cached_issue[1]:
|
||||
return self._map_comic_issue_to_metadata(
|
||||
json.loads(cached_issue[0].data), self._fetch_series_data(int(cached_issue[0].series_id))[0]
|
||||
json.loads(cached_issue[0].data),
|
||||
self._fetch_series_data(
|
||||
int(cached_issue[0].series_id),
|
||||
on_rate_limit=on_rate_limit,
|
||||
)[0],
|
||||
)
|
||||
|
||||
issue_url = urljoin(self.api_url, f"issue/{CVTypeID.Issue}-{issue_id}")
|
||||
params = {"api_key": self.api_key, "format": "json"}
|
||||
cv_response: CVResult[CVIssue] = self._get_cv_content(issue_url, params)
|
||||
cv_response: CVResult[CVIssue] = self._get_cv_content(
|
||||
issue_url,
|
||||
params,
|
||||
on_rate_limit=on_rate_limit,
|
||||
)
|
||||
|
||||
issue_results = cv_response["results"]
|
||||
|
||||
@ -846,7 +1005,11 @@ class ComicVineTalker(ComicTalker):
|
||||
|
||||
# Now, map the GenericMetadata data to generic metadata
|
||||
return self._map_comic_issue_to_metadata(
|
||||
issue_results, self._fetch_series_data(int(issue_results["volume"]["id"]))[0]
|
||||
issue_results,
|
||||
self._fetch_series_data(
|
||||
int(issue_results["volume"]["id"]),
|
||||
on_rate_limit=on_rate_limit,
|
||||
)[0],
|
||||
)
|
||||
|
||||
def _map_comic_issue_to_metadata(self, issue: CVIssue, series: ComicSeries) -> GenericMetadata:
|
||||
|
1
comictalker/vendor/__init__.py
vendored
Normal file
1
comictalker/vendor/__init__.py
vendored
Normal file
@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
10
comictalker/vendor/pyrate_limiter/__init__.py
vendored
Normal file
10
comictalker/vendor/pyrate_limiter/__init__.py
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
# flake8: noqa
|
||||
"""PyrateLimiter
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .bucket import *
|
||||
from .constants import *
|
||||
from .exceptions import *
|
||||
from .limiter import *
|
||||
from .request_rate import *
|
134
comictalker/vendor/pyrate_limiter/bucket.py
vendored
Normal file
134
comictalker/vendor/pyrate_limiter/bucket.py
vendored
Normal file
@ -0,0 +1,134 @@
|
||||
""" Implement this class to create
|
||||
a workable bucket for Limiter to use
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from queue import Queue
|
||||
from threading import RLock
|
||||
|
||||
|
||||
class AbstractBucket(ABC):
|
||||
"""Base bucket interface"""
|
||||
|
||||
def __init__(self, maxsize: int = 0, **_kwargs):
|
||||
self._maxsize = maxsize
|
||||
|
||||
def maxsize(self) -> int:
|
||||
"""Return the maximum size of the bucket,
|
||||
ie the maximum number of item this bucket can hold
|
||||
"""
|
||||
return self._maxsize
|
||||
|
||||
@abstractmethod
|
||||
def size(self) -> int:
|
||||
"""Return the current size of the bucket,
|
||||
ie the count of all items currently in the bucket
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def put(self, item: float) -> int:
|
||||
"""Put an item (typically the current time) in the bucket
|
||||
Return 1 if successful, else 0
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get(self, number: int) -> int:
|
||||
"""Get items, remove them from the bucket in the FIFO order, and return the number of items
|
||||
that have been removed
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def all_items(self) -> list[float]:
|
||||
"""Return a list as copies of all items in the bucket"""
|
||||
|
||||
@abstractmethod
|
||||
def flush(self) -> None:
|
||||
"""Flush/reset bucket"""
|
||||
|
||||
def inspect_expired_items(self, time: float) -> tuple[int, float]:
|
||||
"""Find how many items in bucket that have slipped out of the time-window
|
||||
|
||||
Returns:
|
||||
The number of unexpired items, and the time until the next item will expire
|
||||
"""
|
||||
volume = self.size()
|
||||
item_count, remaining_time = 0, 0.0
|
||||
|
||||
for log_idx, log_item in enumerate(self.all_items()):
|
||||
if log_item > time:
|
||||
item_count = volume - log_idx
|
||||
remaining_time = round(log_item - time, 3)
|
||||
break
|
||||
|
||||
return item_count, remaining_time
|
||||
|
||||
def lock_acquire(self):
|
||||
"""Acquire a lock prior to beginning a new transaction, if needed"""
|
||||
|
||||
def lock_release(self):
|
||||
"""Release lock following a transaction, if needed"""
|
||||
|
||||
|
||||
class MemoryQueueBucket(AbstractBucket):
|
||||
"""A bucket that resides in memory using python's built-in Queue class"""
|
||||
|
||||
def __init__(self, maxsize: int = 0, **_kwargs):
|
||||
super().__init__()
|
||||
self._q: Queue = Queue(maxsize=maxsize)
|
||||
|
||||
def size(self) -> int:
|
||||
return self._q.qsize()
|
||||
|
||||
def put(self, item: float):
|
||||
return self._q.put(item)
|
||||
|
||||
def get(self, number: int) -> int:
|
||||
counter = 0
|
||||
for _ in range(number):
|
||||
self._q.get()
|
||||
counter += 1
|
||||
|
||||
return counter
|
||||
|
||||
def all_items(self) -> list[float]:
|
||||
return list(self._q.queue)
|
||||
|
||||
def flush(self):
|
||||
while not self._q.empty():
|
||||
self._q.get()
|
||||
|
||||
|
||||
class MemoryListBucket(AbstractBucket):
|
||||
"""A bucket that resides in memory using python's List"""
|
||||
|
||||
def __init__(self, maxsize: int = 0, **_kwargs):
|
||||
super().__init__(maxsize=maxsize)
|
||||
self._q: list[float] = []
|
||||
self._lock = RLock()
|
||||
|
||||
def size(self) -> int:
|
||||
return len(self._q)
|
||||
|
||||
def put(self, item: float):
|
||||
with self._lock:
|
||||
if self.size() < self.maxsize():
|
||||
self._q.append(item)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
def get(self, number: int) -> int:
|
||||
with self._lock:
|
||||
counter = 0
|
||||
for _ in range(number):
|
||||
self._q.pop(0)
|
||||
counter += 1
|
||||
|
||||
return counter
|
||||
|
||||
def all_items(self) -> list[float]:
|
||||
return self._q.copy()
|
||||
|
||||
def flush(self):
|
||||
self._q = list()
|
9
comictalker/vendor/pyrate_limiter/constants.py
vendored
Normal file
9
comictalker/vendor/pyrate_limiter/constants.py
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class Duration:
|
||||
SECOND = 1
|
||||
MINUTE = 60
|
||||
HOUR = 3600
|
||||
DAY = 3600 * 24
|
||||
MONTH = 3600 * 24 * 30
|
32
comictalker/vendor/pyrate_limiter/exceptions.py
vendored
Normal file
32
comictalker/vendor/pyrate_limiter/exceptions.py
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
# pylint: disable=C0114,C0115
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .request_rate import RequestRate
|
||||
|
||||
|
||||
class BucketFullException(Exception):
|
||||
def __init__(self, identity: str, rate: RequestRate, remaining_time: float):
|
||||
error = f"Bucket for {identity} with Rate {rate} is already full"
|
||||
self.meta_info: dict[str, str | float] = {
|
||||
"error": error,
|
||||
"identity": identity,
|
||||
"rate": str(rate),
|
||||
"remaining_time": remaining_time,
|
||||
}
|
||||
super().__init__(error)
|
||||
|
||||
|
||||
class InvalidParams(Exception):
|
||||
def __init__(self, param_name: str):
|
||||
self.message = f"Parameters missing or invalid:{param_name}"
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class ImmutableClassProperty(Exception):
|
||||
def __init__(self, class_instance: Any, prop: str):
|
||||
"""Mutating class property is forbidden"""
|
||||
self.message = f"{class_instance}.{prop} must not be mutated"
|
||||
super().__init__(self.message)
|
132
comictalker/vendor/pyrate_limiter/limit_context_decorator.py
vendored
Normal file
132
comictalker/vendor/pyrate_limiter/limit_context_decorator.py
vendored
Normal file
@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from functools import partial, wraps
|
||||
from inspect import iscoroutinefunction
|
||||
from logging import getLogger
|
||||
from time import sleep
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from comictalker.comictalker import RLCallBack
|
||||
|
||||
from .exceptions import BucketFullException
|
||||
|
||||
logger = getLogger("pyrate_limiter")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .limiter import Limiter
|
||||
|
||||
|
||||
class LimitContextDecorator:
|
||||
"""A class that can be used as a:
|
||||
|
||||
* decorator
|
||||
* async decorator
|
||||
* contextmanager
|
||||
* async contextmanager
|
||||
|
||||
Intended to be used via :py:meth:`.Limiter.ratelimit`. Depending on arguments, calls that exceed
|
||||
the rate limit will either raise an exception, or sleep until space is available in the bucket.
|
||||
|
||||
Args:
|
||||
limiter: Limiter object
|
||||
identities: Bucket identities
|
||||
delay: Delay until the next request instead of raising an exception
|
||||
max_delay: The maximum allowed delay time (in seconds); anything over this will raise
|
||||
an exception
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
limiter: Limiter,
|
||||
*identities: str,
|
||||
delay: bool = False,
|
||||
max_delay: int | float | None = None,
|
||||
on_rate_limit: RLCallBack | None = None,
|
||||
):
|
||||
self.delay = delay
|
||||
self.max_delay = max_delay or 0
|
||||
self.try_acquire = partial(limiter.try_acquire, *identities)
|
||||
self.on_rate_limit = on_rate_limit
|
||||
|
||||
def __call__(self, func):
|
||||
"""Allows usage as a decorator for both normal and async functions"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
self.delayed_acquire()
|
||||
return func(*args, **kwargs)
|
||||
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
await self.async_delayed_acquire()
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Return either an async or normal wrapper, depending on the type of the wrapped function
|
||||
return async_wrapper if iscoroutinefunction(func) else wrapper
|
||||
|
||||
def __enter__(self):
|
||||
"""Allows usage as a contextmanager"""
|
||||
self.delayed_acquire()
|
||||
|
||||
def __exit__(self, *exc):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Allows usage as an async contextmanager"""
|
||||
await self.async_delayed_acquire()
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
pass
|
||||
|
||||
def delayed_acquire(self):
|
||||
"""Delay and retry until we can successfully acquire an available bucket item"""
|
||||
while True:
|
||||
try:
|
||||
self.try_acquire()
|
||||
except BucketFullException as err:
|
||||
delay_time = full_delay_time = self.delay_or_reraise(err)
|
||||
|
||||
if self.on_rate_limit:
|
||||
if self.on_rate_limit.interval > 0 and delay_time > self.on_rate_limit.interval:
|
||||
delay_time = self.on_rate_limit.interval
|
||||
self.on_rate_limit.callback(full_delay_time, delay_time)
|
||||
logger.warning(
|
||||
"Rate limit reached; %.0f seconds remaining before next request. Sleeping for %.0f seconds",
|
||||
full_delay_time,
|
||||
delay_time,
|
||||
)
|
||||
sleep(delay_time)
|
||||
else:
|
||||
break
|
||||
|
||||
async def async_delayed_acquire(self):
|
||||
"""Delay and retry until we can successfully acquire an available bucket item"""
|
||||
while True:
|
||||
try:
|
||||
self.try_acquire()
|
||||
except BucketFullException as err:
|
||||
delay_time = full_delay_time = self.delay_or_reraise(err)
|
||||
|
||||
if self.on_rate_limit:
|
||||
if self.on_rate_limit.interval > 0 and delay_time > self.on_rate_limit.interval:
|
||||
delay_time = self.on_rate_limit.interval
|
||||
self.on_rate_limit.callback(full_delay_time, delay_time)
|
||||
logger.warning(
|
||||
"Rate limit reached; %.0f seconds remaining before next request. Sleeping for %.0f seconds",
|
||||
full_delay_time,
|
||||
delay_time,
|
||||
)
|
||||
await asyncio.sleep(delay_time)
|
||||
else:
|
||||
break
|
||||
|
||||
def delay_or_reraise(self, err: BucketFullException) -> float:
|
||||
"""Determine if we should delay after exceeding a rate limit. If so, return the delay time,
|
||||
otherwise re-raise the exception.
|
||||
"""
|
||||
delay_time = float(err.meta_info["remaining_time"])
|
||||
exceeded_max_delay = bool(self.max_delay) and (delay_time > self.max_delay)
|
||||
if self.delay and not exceeded_max_delay:
|
||||
return delay_time
|
||||
raise err
|
163
comictalker/vendor/pyrate_limiter/limiter.py
vendored
Normal file
163
comictalker/vendor/pyrate_limiter/limiter.py
vendored
Normal file
@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from time import monotonic
|
||||
from typing import Any, Callable
|
||||
|
||||
from comictalker.comictalker import RLCallBack
|
||||
|
||||
from .bucket import AbstractBucket, MemoryQueueBucket
|
||||
from .exceptions import BucketFullException, InvalidParams
|
||||
from .limit_context_decorator import LimitContextDecorator
|
||||
from .request_rate import RequestRate
|
||||
|
||||
|
||||
class Limiter:
|
||||
"""Main rate-limiter class
|
||||
|
||||
Args:
|
||||
rates: Request rate definitions
|
||||
bucket_class: Bucket backend to use; may be any subclass of :py:class:`.AbstractBucket`.
|
||||
See :py:mod`pyrate_limiter.bucket` for available bucket classes.
|
||||
bucket_kwargs: Extra keyword arguments to pass to the bucket class constructor.
|
||||
time_function: Time function that returns the current time as a float, in seconds
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*rates: RequestRate,
|
||||
on_rate_limit: RLCallBack | None = None,
|
||||
bucket_class: type[AbstractBucket] = MemoryQueueBucket,
|
||||
bucket_kwargs: dict[str, Any] | None = None,
|
||||
time_function: Callable[[], float] | None = None,
|
||||
):
|
||||
self._validate_rate_list(rates)
|
||||
|
||||
self.on_rate_limit = on_rate_limit
|
||||
|
||||
self._rates = rates
|
||||
self._bkclass = bucket_class
|
||||
self._bucket_args = bucket_kwargs or {}
|
||||
self._validate_bucket()
|
||||
|
||||
self.bucket_group: dict[str, AbstractBucket] = {}
|
||||
self.time_function = monotonic
|
||||
if time_function is not None:
|
||||
self.time_function = time_function
|
||||
# Call for time_function to make an anchor if required.
|
||||
self.time_function()
|
||||
|
||||
def _validate_rate_list(self, rates): # pylint: disable=no-self-use
|
||||
"""Raise exception if rates are incorrectly ordered."""
|
||||
if not rates:
|
||||
raise InvalidParams("Rate(s) must be provided")
|
||||
|
||||
for idx, rate in enumerate(rates[1:]):
|
||||
prev_rate = rates[idx]
|
||||
invalid = rate.limit <= prev_rate.limit or rate.interval <= prev_rate.interval
|
||||
if invalid:
|
||||
msg = f"{prev_rate} cannot come before {rate}"
|
||||
raise InvalidParams(msg)
|
||||
|
||||
def _validate_bucket(self):
|
||||
"""Try initialize a bucket to check if ok"""
|
||||
bucket = self._bkclass(maxsize=self._rates[-1].limit, identity="_", **self._bucket_args)
|
||||
del bucket
|
||||
|
||||
def _init_buckets(self, identities) -> None:
|
||||
"""Initialize a bucket for each identity, if needed.
|
||||
The bucket's maxsize equals the max limit of request-rates.
|
||||
"""
|
||||
maxsize = self._rates[-1].limit
|
||||
for item_id in sorted(identities):
|
||||
if not self.bucket_group.get(item_id):
|
||||
self.bucket_group[item_id] = self._bkclass(
|
||||
maxsize=maxsize,
|
||||
identity=item_id,
|
||||
**self._bucket_args,
|
||||
)
|
||||
self.bucket_group[item_id].lock_acquire()
|
||||
|
||||
def _release_buckets(self, identities) -> None:
|
||||
"""Release locks after bucket transactions, if applicable"""
|
||||
for item_id in sorted(identities):
|
||||
self.bucket_group[item_id].lock_release()
|
||||
|
||||
def try_acquire(self, *identities: str) -> None:
|
||||
"""Attempt to acquire an item, or raise an error if a rate limit has been exceeded.
|
||||
|
||||
Args:
|
||||
identities: One or more identities to acquire. Typically this is the name of a service
|
||||
or resource that is being rate-limited.
|
||||
|
||||
Raises:
|
||||
:py:exc:`BucketFullException`: If the bucket is full and the item cannot be acquired
|
||||
"""
|
||||
self._init_buckets(identities)
|
||||
now = round(self.time_function(), 3)
|
||||
|
||||
for rate in self._rates:
|
||||
for item_id in identities:
|
||||
bucket = self.bucket_group[item_id]
|
||||
volume = bucket.size()
|
||||
|
||||
if volume < rate.limit:
|
||||
continue
|
||||
|
||||
# Determine rate's starting point, and check requests made during its time window
|
||||
item_count, remaining_time = bucket.inspect_expired_items(now - rate.interval)
|
||||
if item_count >= rate.limit:
|
||||
self._release_buckets(identities)
|
||||
raise BucketFullException(item_id, rate, remaining_time)
|
||||
|
||||
# Remove expired bucket items beyond the last (maximum) rate limit,
|
||||
if rate is self._rates[-1]:
|
||||
bucket.get(volume - item_count)
|
||||
|
||||
# If no buckets are full, add another item to each bucket representing the next request
|
||||
for item_id in identities:
|
||||
self.bucket_group[item_id].put(now)
|
||||
self._release_buckets(identities)
|
||||
|
||||
def ratelimit(
|
||||
self,
|
||||
*identities: str,
|
||||
delay: bool = False,
|
||||
max_delay: int | float | None = None,
|
||||
on_rate_limit: RLCallBack | None = None,
|
||||
):
|
||||
"""A decorator and contextmanager that applies rate-limiting, with async support.
|
||||
Depending on arguments, calls that exceed the rate limit will either raise an exception, or
|
||||
sleep until space is available in the bucket.
|
||||
|
||||
Args:
|
||||
identities: One or more identities to acquire. Typically this is the name of a service
|
||||
or resource that is being rate-limited.
|
||||
delay: Delay until the next request instead of raising an exception
|
||||
max_delay: The maximum allowed delay time (in seconds); anything over this will raise
|
||||
an exception
|
||||
|
||||
Raises:
|
||||
:py:exc:`BucketFullException`: If the rate limit is reached, and ``delay=False`` or the
|
||||
delay exceeds ``max_delay``
|
||||
"""
|
||||
return LimitContextDecorator(
|
||||
self,
|
||||
*identities,
|
||||
delay=delay,
|
||||
max_delay=max_delay,
|
||||
on_rate_limit=self.on_rate_limit or on_rate_limit,
|
||||
)
|
||||
|
||||
def get_current_volume(self, identity) -> int:
|
||||
"""Get current bucket volume for a specific identity"""
|
||||
bucket = self.bucket_group[identity]
|
||||
return bucket.size()
|
||||
|
||||
def flush_all(self) -> int:
|
||||
cnt = 0
|
||||
|
||||
for _, bucket in self.bucket_group.items():
|
||||
bucket.flush()
|
||||
cnt += 1
|
||||
|
||||
return cnt
|
0
comictalker/vendor/pyrate_limiter/py.typed
vendored
Normal file
0
comictalker/vendor/pyrate_limiter/py.typed
vendored
Normal file
53
comictalker/vendor/pyrate_limiter/request_rate.py
vendored
Normal file
53
comictalker/vendor/pyrate_limiter/request_rate.py
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
"""Initialize this class to define request-rates for limiter
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from .exceptions import ImmutableClassProperty
|
||||
|
||||
|
||||
class ResetTypes(Enum):
|
||||
SCHEDULED = 1
|
||||
INTERVAL = 2
|
||||
|
||||
|
||||
class RequestRate:
|
||||
"""Request rate definition.
|
||||
|
||||
Args:
|
||||
limit: Number of requests allowed within ``interval``
|
||||
interval: Time interval, in seconds
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
limit: int,
|
||||
interval: int,
|
||||
reset: ResetTypes = ResetTypes.INTERVAL,
|
||||
):
|
||||
self._limit = limit
|
||||
self._interval = interval
|
||||
self._reset = reset
|
||||
self._log: dict[Any, Any] = {}
|
||||
|
||||
@property
|
||||
def limit(self) -> int:
|
||||
return self._limit
|
||||
|
||||
@limit.setter
|
||||
def limit(self, _):
|
||||
raise ImmutableClassProperty(self, "limit")
|
||||
|
||||
@property
|
||||
def interval(self) -> int:
|
||||
return self._interval
|
||||
|
||||
@interval.setter
|
||||
def interval(self, _):
|
||||
raise ImmutableClassProperty(self, "interval")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.limit}/{self.interval}"
|
12
setup.cfg
12
setup.cfg
@ -344,5 +344,15 @@ disallow_untyped_defs = false
|
||||
disallow_incomplete_defs = false
|
||||
check_untyped_defs = false
|
||||
|
||||
[mypy-comictaggerlib.ui.pyqttoast.tests.*]
|
||||
disallow_untyped_defs = false
|
||||
disallow_incomplete_defs = false
|
||||
check_untyped_defs = false
|
||||
|
||||
[mypy-comictaggerlib.graphics.resources]
|
||||
ignore_errors = True
|
||||
ignore_errors = true
|
||||
follow_imports = skip
|
||||
|
||||
[mypy-comictalker.vendor.*]
|
||||
ignore_errors = true
|
||||
follow_imports = skip
|
||||
|
@ -9,9 +9,12 @@ import testing.comicvine
|
||||
|
||||
|
||||
def test_search_for_series(comicvine_api, comic_cache):
|
||||
results = comicvine_api.search_for_series("cory doctorows futuristic tales of the here and now")[0]
|
||||
results = comicvine_api.search_for_series(
|
||||
"cory doctorows futuristic tales of the here and now", on_rate_limit=None
|
||||
)[0]
|
||||
cache_series = comic_cache.get_search_results(
|
||||
comicvine_api.id, "cory doctorows futuristic tales of the here and now"
|
||||
comicvine_api.id,
|
||||
"cory doctorows futuristic tales of the here and now",
|
||||
)[0][0]
|
||||
series_results = comicvine_api._format_series(json.loads(cache_series.data))
|
||||
assert results == series_results
|
||||
@ -40,7 +43,7 @@ def test_fetch_issues_in_series(comicvine_api, comic_cache):
|
||||
|
||||
|
||||
def test_fetch_issue_data_by_issue_id(comicvine_api):
|
||||
result = comicvine_api.fetch_comic_data(140529)
|
||||
result = comicvine_api.fetch_comic_data(140529, on_rate_limit=None)
|
||||
result.notes = None
|
||||
|
||||
assert result == testing.comicvine.cv_md
|
||||
@ -75,6 +78,6 @@ cv_issue = [
|
||||
|
||||
@pytest.mark.parametrize("series_id, issue_number, expected", cv_issue)
|
||||
def test_fetch_issue_data(comicvine_api, series_id, issue_number, expected):
|
||||
results = comicvine_api._fetch_issue_data(series_id, issue_number)
|
||||
results = comicvine_api._fetch_issue_data(series_id, issue_number, on_rate_limit=None)
|
||||
results.notes = None
|
||||
assert results == expected
|
||||
|
@ -12,7 +12,6 @@ from typing import Any
|
||||
import pytest
|
||||
import settngs
|
||||
from PIL import Image
|
||||
from pyrate_limiter import Limiter, RequestRate
|
||||
|
||||
import comicapi.comicarchive
|
||||
import comicapi.genericmetadata
|
||||
@ -22,6 +21,7 @@ import comictalker
|
||||
import comictalker.comiccacher
|
||||
import comictalker.talkers.comicvine
|
||||
from comicapi import utils
|
||||
from comictalker.vendor.pyrate_limiter import Limiter, RequestRate
|
||||
from testing import comicvine, filenames
|
||||
from testing.comicdata import all_seed_imprints, seed_imprints
|
||||
|
||||
|
@ -14,8 +14,17 @@ 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)
|
||||
iio = comictaggerlib.issueidentifier.IssueIdentifierOptions(
|
||||
series_match_search_thresh=config.Issue_Identifier__series_match_search_thresh,
|
||||
series_match_identify_thresh=config.Issue_Identifier__series_match_identify_thresh,
|
||||
use_publisher_filter=config.Auto_Tag__use_publisher_filter,
|
||||
publisher_filter=config.Auto_Tag__publisher_filter,
|
||||
quiet=config.Runtime_Options__quiet,
|
||||
cache_dir=config.Runtime_Options__config.user_cache_dir,
|
||||
border_crop_percent=config.Issue_Identifier__border_crop_percent,
|
||||
talker=comicvine_api,
|
||||
)
|
||||
ii = comictaggerlib.issueidentifier.IssueIdentifier(iio, None)
|
||||
|
||||
im = Image.open(io.BytesIO(cbz_double_cover.archiver.read_file("double_cover.jpg")))
|
||||
|
||||
@ -31,14 +40,34 @@ def test_crop(cbz_double_cover, config, tmp_path, comicvine_api):
|
||||
@pytest.mark.parametrize("additional_md, expected", testing.comicdata.metadata_keys)
|
||||
def test_get_search_keys(cbz, config, additional_md, expected, comicvine_api):
|
||||
config, definitions = config
|
||||
ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, config, comicvine_api)
|
||||
iio = comictaggerlib.issueidentifier.IssueIdentifierOptions(
|
||||
series_match_search_thresh=config.Issue_Identifier__series_match_search_thresh,
|
||||
series_match_identify_thresh=config.Issue_Identifier__series_match_identify_thresh,
|
||||
use_publisher_filter=config.Auto_Tag__use_publisher_filter,
|
||||
publisher_filter=config.Auto_Tag__publisher_filter,
|
||||
quiet=config.Runtime_Options__quiet,
|
||||
cache_dir=config.Runtime_Options__config.user_cache_dir,
|
||||
border_crop_percent=config.Issue_Identifier__border_crop_percent,
|
||||
talker=comicvine_api,
|
||||
)
|
||||
ii = comictaggerlib.issueidentifier.IssueIdentifier(iio, None)
|
||||
|
||||
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)
|
||||
iio = comictaggerlib.issueidentifier.IssueIdentifierOptions(
|
||||
series_match_search_thresh=config.Issue_Identifier__series_match_search_thresh,
|
||||
series_match_identify_thresh=config.Issue_Identifier__series_match_identify_thresh,
|
||||
use_publisher_filter=config.Auto_Tag__use_publisher_filter,
|
||||
publisher_filter=config.Auto_Tag__publisher_filter,
|
||||
quiet=config.Runtime_Options__quiet,
|
||||
cache_dir=config.Runtime_Options__config.user_cache_dir,
|
||||
border_crop_percent=config.Issue_Identifier__border_crop_percent,
|
||||
talker=comicvine_api,
|
||||
)
|
||||
ii = comictaggerlib.issueidentifier.IssueIdentifier(iio, None)
|
||||
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/"],
|
||||
@ -56,7 +85,17 @@ 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)
|
||||
iio = comictaggerlib.issueidentifier.IssueIdentifierOptions(
|
||||
series_match_search_thresh=config.Issue_Identifier__series_match_search_thresh,
|
||||
series_match_identify_thresh=config.Issue_Identifier__series_match_identify_thresh,
|
||||
use_publisher_filter=config.Auto_Tag__use_publisher_filter,
|
||||
publisher_filter=config.Auto_Tag__publisher_filter,
|
||||
quiet=config.Runtime_Options__quiet,
|
||||
cache_dir=config.Runtime_Options__config.user_cache_dir,
|
||||
border_crop_percent=config.Issue_Identifier__border_crop_percent,
|
||||
talker=comicvine_api,
|
||||
)
|
||||
ii = comictaggerlib.issueidentifier.IssueIdentifier(iio, None)
|
||||
result, issues = ii.identify(cbz, cbz.read_tags("cr"))
|
||||
cv_expected = IssueResult(
|
||||
series=f"{testing.comicvine.cv_volume_result['results']['name']} ({testing.comicvine.cv_volume_result['results']['start_year']})",
|
||||
@ -80,7 +119,17 @@ def test_search(cbz, config, comicvine_api):
|
||||
|
||||
def test_crop_border(cbz, config, comicvine_api):
|
||||
config, definitions = config
|
||||
ii = comictaggerlib.issueidentifier.IssueIdentifier(cbz, config, comicvine_api)
|
||||
iio = comictaggerlib.issueidentifier.IssueIdentifierOptions(
|
||||
series_match_search_thresh=config.Issue_Identifier__series_match_search_thresh,
|
||||
series_match_identify_thresh=config.Issue_Identifier__series_match_identify_thresh,
|
||||
use_publisher_filter=config.Auto_Tag__use_publisher_filter,
|
||||
publisher_filter=config.Auto_Tag__publisher_filter,
|
||||
quiet=config.Runtime_Options__quiet,
|
||||
cache_dir=config.Runtime_Options__config.user_cache_dir,
|
||||
border_crop_percent=config.Issue_Identifier__border_crop_percent,
|
||||
talker=comicvine_api,
|
||||
)
|
||||
ii = comictaggerlib.issueidentifier.IssueIdentifier(iio, None)
|
||||
|
||||
# This creates a white square centered on a black background
|
||||
bg = Image.new("RGBA", (100, 100), (0, 0, 0, 255))
|
||||
|
Loading…
Reference in New Issue
Block a user