Compare commits
14 Commits
develop
...
9864159407
Author | SHA1 | Date | |
---|---|---|---|
9864159407 | |||
194c381c7f | |||
98387cac5e | |||
02fae9fb35 | |||
aa003499f9 | |||
793f752d67 | |||
6c064c19d9 | |||
7fd86c5430 | |||
6fc4852fd0 | |||
0add577190 | |||
98ce9f9a1a | |||
818ba09fd7 | |||
e5697b3b11 | |||
c326b1c4a1 |
@ -18,17 +18,15 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Callable
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets, uic
|
||||
|
||||
from comicapi.comicarchive import ComicArchive, tags
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comictaggerlib.coverimagewidget import CoverImageWidget
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.md import prepare_metadata
|
||||
from comictaggerlib.resulttypes import IssueResult, Result
|
||||
from comictaggerlib.ui import ui_path
|
||||
from comictaggerlib.ui import qtutils, ui_path
|
||||
from comictalker.comictalker import ComicTalker, TalkerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -40,7 +38,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 +76,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
|
||||
|
||||
self.match_set_list = match_set_list
|
||||
self._tags = read_tags
|
||||
self.fetch_func = fetch_func
|
||||
self.talker = talker
|
||||
|
||||
self.current_match_set_idx = 0
|
||||
|
||||
@ -212,17 +209,15 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
|
||||
self.update_data()
|
||||
|
||||
def reject(self) -> None:
|
||||
reply = QtWidgets.QMessageBox.question(
|
||||
self,
|
||||
"Cancel Matching",
|
||||
"Are you sure you wish to cancel the matching process?",
|
||||
QtWidgets.QMessageBox.StandardButton.Yes,
|
||||
QtWidgets.QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
if reply == QtWidgets.QMessageBox.StandardButton.No:
|
||||
return
|
||||
qmsg = QtWidgets.QMessageBox(self)
|
||||
qmsg.setIcon(qmsg.Icon.Question)
|
||||
qmsg.setText("Cancel Matching")
|
||||
qmsg.setInformativeText("Are you sure you wish to cancel the matching process?")
|
||||
qmsg.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No)
|
||||
qmsg.rejected.connect(self._cancel)
|
||||
qmsg.show()
|
||||
|
||||
def _cancel(self) -> None:
|
||||
QtWidgets.QDialog.reject(self)
|
||||
|
||||
def save_match(self) -> None:
|
||||
@ -233,13 +228,13 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
|
||||
md, error = self.parent().read_selected_tags(self._tags, ca)
|
||||
if error is not None:
|
||||
logger.error("Failed to load tags for %s: %s", ca.path, error)
|
||||
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
QtWidgets.QMessageBox.critical(
|
||||
return qtutils.critical(
|
||||
self,
|
||||
"Read Failed!",
|
||||
f"One or more of the read tags failed to load for {ca.path}, check log for details",
|
||||
)
|
||||
return
|
||||
|
||||
if md.is_empty:
|
||||
md = ca.metadata_from_filename(
|
||||
@ -252,15 +247,14 @@ 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.talker.fetch_comic_data(issue_id=match.issue_id)
|
||||
except TalkerError as e:
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
QtWidgets.QMessageBox.critical(self, f"{e.source} {e.code_name} Error", f"{e}")
|
||||
qtutils.critical(self, f"{e.source} {e.code_name} Error", str(e))
|
||||
return
|
||||
|
||||
if ct_md is None or ct_md.is_empty:
|
||||
QtWidgets.QMessageBox.critical(self, "Network Issue", "Could not retrieve issue details!")
|
||||
return
|
||||
return qtutils.critical(self, "Network Issue", "Could not retrieve issue details!")
|
||||
|
||||
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
|
||||
md = prepare_metadata(md, ct_md, self.config)
|
||||
@ -268,7 +262,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
|
||||
success = ca.write_tags(md, tag_id)
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
if not success:
|
||||
QtWidgets.QMessageBox.warning(
|
||||
qtutils.warning(
|
||||
self,
|
||||
"Write Error",
|
||||
f"Saving {tags[tag_id].name()} the tags to the archive seemed to fail!",
|
||||
|
@ -17,16 +17,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
from PyQt6 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.ui import ui_path
|
||||
from comictalker.comictalker import ComicTalker
|
||||
from comictaggerlib.ctsettings.settngs_namespace import SettngsNS
|
||||
from comictaggerlib.issueidentifier import IssueIdentifierCancelled
|
||||
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 qtutils, ui_path
|
||||
from comictalker.comictalker import ComicTalker, RLCallBack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AutoTagThread(QtCore.QThread): # TODO: re-check thread semantics. Specifically with signals
|
||||
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:
|
||||
if self.canceled:
|
||||
raise IssueIdentifierCancelled
|
||||
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:
|
||||
qtutils.critical(
|
||||
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,
|
||||
)
|
||||
|
||||
try:
|
||||
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,
|
||||
)
|
||||
except IssueIdentifierCancelled:
|
||||
return (
|
||||
Result(
|
||||
Action.save,
|
||||
original_path=ca.path,
|
||||
status=Status.fetch_data_failure,
|
||||
),
|
||||
match_results,
|
||||
)
|
||||
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 +236,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()
|
||||
@ -66,6 +254,20 @@ class AutoTagProgressWindow(QtWidgets.QDialog):
|
||||
widget.set_image_data(img_data)
|
||||
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,16 +17,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import NamedTuple
|
||||
|
||||
from PyQt6 import QtCore, QtWidgets, uic
|
||||
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comictaggerlib.ctsettings import ct_ns, settngs_namespace
|
||||
from comictaggerlib.ui import ui_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AutoTagSettings(NamedTuple):
|
||||
settings: settngs_namespace.Auto_Tag
|
||||
remove_after_success: bool
|
||||
series_match_identify_thresh: bool
|
||||
split_words: bool
|
||||
search_string: str
|
||||
|
||||
|
||||
class AutoTagStartWindow(QtWidgets.QDialog):
|
||||
startAutoTag = QtCore.pyqtSignal(AutoTagSettings)
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget, config: ct_ns, msg: str) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
@ -77,6 +89,8 @@ class AutoTagStartWindow(QtWidgets.QDialog):
|
||||
self.search_string = ""
|
||||
self.name_length_match_tolerance = self.config.Issue_Identifier__series_match_search_thresh
|
||||
self.split_words = self.cbxSplitWords.isChecked()
|
||||
self.adjustSize()
|
||||
self.setModal(True)
|
||||
|
||||
def search_string_toggle(self) -> None:
|
||||
enable = self.cbxSpecifySearchString.isChecked()
|
||||
@ -85,20 +99,26 @@ class AutoTagStartWindow(QtWidgets.QDialog):
|
||||
def accept(self) -> None:
|
||||
QtWidgets.QDialog.accept(self)
|
||||
|
||||
self.auto_save_on_low = self.cbxSaveOnLowConfidence.isChecked()
|
||||
self.dont_use_year = self.cbxDontUseYear.isChecked()
|
||||
self.assume_issue_one = self.cbxAssumeIssueOne.isChecked()
|
||||
self.ignore_leading_digits_in_filename = self.cbxIgnoreLeadingDigitsInFilename.isChecked()
|
||||
self.remove_after_success = self.cbxRemoveAfterSuccess.isChecked()
|
||||
self.name_length_match_tolerance = self.sbNameMatchSearchThresh.value()
|
||||
self.split_words = self.cbxSplitWords.isChecked()
|
||||
|
||||
# persist some settings
|
||||
self.config.Auto_Tag__save_on_low_confidence = self.auto_save_on_low
|
||||
self.config.Auto_Tag__use_year_when_identifying = not self.dont_use_year
|
||||
self.config.Auto_Tag__assume_issue_one = self.assume_issue_one
|
||||
self.config.Auto_Tag__ignore_leading_numbers_in_filename = self.ignore_leading_digits_in_filename
|
||||
self.config.internal__remove_archive_after_successful_match = self.remove_after_success
|
||||
|
||||
if self.cbxSpecifySearchString.isChecked():
|
||||
self.search_string = self.leSearchString.text()
|
||||
self.startAutoTag.emit(
|
||||
AutoTagSettings(
|
||||
settings=settngs_namespace.Auto_Tag(
|
||||
online=self.config.Auto_Tag__online,
|
||||
save_on_low_confidence=self.cbxSaveOnLowConfidence.isChecked(),
|
||||
use_year_when_identifying=not self.cbxDontUseYear.isChecked(),
|
||||
assume_issue_one=self.cbxAssumeIssueOne.isChecked(),
|
||||
ignore_leading_numbers_in_filename=self.cbxIgnoreLeadingDigitsInFilename.isChecked(),
|
||||
parse_filename=self.config.Auto_Tag__parse_filename,
|
||||
prefer_filename=self.config.Auto_Tag__prefer_filename,
|
||||
issue_id=None,
|
||||
metadata=GenericMetadata(),
|
||||
clear_tags=self.config.Auto_Tag__clear_tags,
|
||||
publisher_filter=self.config.Auto_Tag__publisher_filter,
|
||||
use_publisher_filter=self.config.Auto_Tag__use_publisher_filter,
|
||||
auto_imprint=self.cbxAutoImprint.isChecked(),
|
||||
),
|
||||
remove_after_success=self.cbxRemoveAfterSuccess.isChecked(),
|
||||
series_match_identify_thresh=self.sbNameMatchSearchThresh.value(),
|
||||
split_words=self.cbxSplitWords.isChecked(),
|
||||
search_string=self.leSearchString.text(),
|
||||
)
|
||||
)
|
||||
|
@ -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.resulttypes import Action, MatchStatus, OnlineMatchResults, Result, Status
|
||||
from comictaggerlib.tag import identify_comic
|
||||
from comictalker.comictalker import ComicTalker, TalkerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -133,7 +132,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 Exception as e:
|
||||
logger.error("Error retrieving issue details '%s'. Save aborted.", e)
|
||||
return GenericMetadata()
|
||||
@ -458,123 +457,6 @@ class CLI:
|
||||
logger.debug("", exc_info=True)
|
||||
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:
|
||||
@ -585,7 +467,6 @@ class CLI:
|
||||
Action.save,
|
||||
original_path=ca.path,
|
||||
status=Status.existing_tags,
|
||||
tags_written=self.config.Runtime_Options__tags_write,
|
||||
),
|
||||
match_results,
|
||||
)
|
||||
@ -595,22 +476,30 @@ class CLI:
|
||||
|
||||
md, tags_read = self.create_local_metadata(ca, self.config.Runtime_Options__tags_read)
|
||||
|
||||
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.error("Error retrieving issue details. Save aborted. %s", 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)
|
||||
@ -623,11 +512,18 @@ 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:
|
||||
query_md = md.copy()
|
||||
@ -638,42 +534,24 @@ class CLI:
|
||||
if qt_md is None or qt_md.is_empty:
|
||||
if qt_md is not None:
|
||||
self.output("Failed to find match via quick tag")
|
||||
ct_md, matches, res, match_results = self.normal_tag(ca, tags_read, query_md, match_results) # type: ignore[assignment]
|
||||
if res is not None:
|
||||
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
|
||||
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.series_id or "",
|
||||
month=ct_md.month,
|
||||
year=ct_md.year,
|
||||
publisher=None,
|
||||
image_url=str(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,
|
||||
)
|
||||
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)
|
||||
|
@ -249,12 +249,13 @@ class CoverImageWidget(QtWidgets.QWidget):
|
||||
self.set_display_pixmap()
|
||||
|
||||
def load_page(self) -> None:
|
||||
if self.comic_archive is not None:
|
||||
if self.page_loader is not None:
|
||||
self.page_loader.abandoned = True
|
||||
self.page_loader = PageLoader(self.comic_archive, self.imageIndex)
|
||||
self.page_loader.loadComplete.connect(self.page_load_complete)
|
||||
self.page_loader.start()
|
||||
if self.comic_archive is None:
|
||||
return
|
||||
if self.page_loader is not None:
|
||||
self.page_loader.abandoned = True
|
||||
self.page_loader = PageLoader(self.comic_archive, self.imageIndex)
|
||||
self.page_loader.loadComplete.connect(self.page_load_complete)
|
||||
self.page_loader.start()
|
||||
|
||||
def page_load_complete(self, image_data: bytes) -> None:
|
||||
img = get_qimage_from_data(image_data)
|
||||
|
@ -18,33 +18,51 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import operator
|
||||
from enum import Enum, auto
|
||||
|
||||
import natsort
|
||||
from PyQt6 import QtCore, QtWidgets, uic
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi.comicarchive import tags
|
||||
from comicapi.genericmetadata import Credit
|
||||
from comictaggerlib.ui import ui_path
|
||||
from comictaggerlib.ui import qtutils, ui_path
|
||||
from comictaggerlib.ui.qtutils import enable_widget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreditEditorWindow(QtWidgets.QDialog):
|
||||
ModeEdit = 0
|
||||
ModeNew = 1
|
||||
class EditMode(Enum):
|
||||
EDIT = auto()
|
||||
NEW = auto()
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget, mode: int, credit: Credit) -> None:
|
||||
|
||||
class CreditEditorWindow(QtWidgets.QDialog):
|
||||
creditChanged = QtCore.pyqtSignal(Credit, int, EditMode)
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget, tags: list[str], row: int, mode: EditMode, credit: Credit) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
with (ui_path / "crediteditorwindow.ui").open(encoding="utf-8") as uifile:
|
||||
uic.loadUi(uifile, self)
|
||||
|
||||
self.mode = mode
|
||||
self.md_attributes = {
|
||||
"credits.person": self.leName,
|
||||
"credits.language": self.cbLanguage,
|
||||
"credits.role": self.cbRole,
|
||||
"credits.primary": self.cbPrimary,
|
||||
}
|
||||
|
||||
if self.mode == self.ModeEdit:
|
||||
self.mode = mode
|
||||
self.credit = credit
|
||||
self.row = row
|
||||
self.tags = tags
|
||||
|
||||
if self.mode == EditMode.EDIT:
|
||||
self.setWindowTitle("Edit Credit")
|
||||
else:
|
||||
self.setWindowTitle("New Credit")
|
||||
self.setModal(True)
|
||||
|
||||
# Add the entries to the role combobox
|
||||
self.cbRole.addItem("")
|
||||
@ -86,13 +104,29 @@ class CreditEditorWindow(QtWidgets.QDialog):
|
||||
self.cbLanguage.setCurrentIndex(i)
|
||||
|
||||
self.cbPrimary.setChecked(credit.primary)
|
||||
self.update_tag_tweaks()
|
||||
|
||||
def get_credit(self) -> Credit:
|
||||
lang = self.cbLanguage.currentData() or self.cbLanguage.currentText()
|
||||
return Credit(self.leName.text(), self.cbRole.currentText(), self.cbPrimary.isChecked(), lang)
|
||||
|
||||
def update_tag_tweaks(self) -> None:
|
||||
# depending on the current data tag, certain fields are disabled
|
||||
enabled_widgets = set()
|
||||
for tag_id in self.tags:
|
||||
if not tags[tag_id].enabled:
|
||||
continue
|
||||
enabled_widgets.update(tags[tag_id].supported_attributes)
|
||||
|
||||
for md_field, widget in self.md_attributes.items():
|
||||
if widget is not None and not isinstance(widget, (int)):
|
||||
enable_widget(widget, md_field in enabled_widgets)
|
||||
|
||||
def accept(self) -> None:
|
||||
if self.leName.text() == "":
|
||||
QtWidgets.QMessageBox.warning(self, "Whoops", "You need to enter a name for a credit.")
|
||||
else:
|
||||
QtWidgets.QDialog.accept(self)
|
||||
return qtutils.warning(self, "Whoops", "You need to enter a name for a credit.")
|
||||
|
||||
QtWidgets.QDialog.accept(self)
|
||||
new = self.get_credit()
|
||||
if self.credit != new:
|
||||
self.creditChanged.emit(new, self.row, self.mode)
|
||||
|
@ -17,6 +17,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from enum import Enum, auto
|
||||
from typing import NamedTuple
|
||||
|
||||
from PyQt6 import QtCore, QtWidgets, uic
|
||||
|
||||
@ -25,19 +27,35 @@ from comictaggerlib.ui import ui_path
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExportConflictOpts:
|
||||
dontCreate = 1
|
||||
overwrite = 2
|
||||
createUnique = 3
|
||||
class ExportConflictOpts(Enum):
|
||||
DONT_CREATE = auto()
|
||||
OVERWRITE = auto()
|
||||
CREATE_UNIQUE = auto()
|
||||
|
||||
|
||||
class ExportConfig(NamedTuple):
|
||||
conflict: ExportConflictOpts
|
||||
add_to_list: bool
|
||||
delete_original: bool
|
||||
|
||||
|
||||
class ExportWindow(QtWidgets.QDialog):
|
||||
def __init__(self, parent: QtWidgets.QWidget, msg: str) -> None:
|
||||
export = QtCore.pyqtSignal(ExportConfig)
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
with (ui_path / "exportwindow.ui").open(encoding="utf-8") as uifile:
|
||||
uic.loadUi(uifile, self)
|
||||
self.label.setText(msg)
|
||||
self.label: QtWidgets.QLabel
|
||||
self.cbxDeleteOriginal: QtWidgets.QCheckBox
|
||||
self.cbxAddToList: QtWidgets.QCheckBox
|
||||
self.radioDontCreate: QtWidgets.QRadioButton
|
||||
self.radioCreateNew: QtWidgets.QRadioButton
|
||||
self.msg = """You have selected {count} archive(s) to export to Zip format. New archives will be created in the same folder as the original.
|
||||
|
||||
Please choose config below, and select OK.
|
||||
"""
|
||||
|
||||
self.setWindowFlags(
|
||||
QtCore.Qt.WindowType(self.windowFlags() & ~QtCore.Qt.WindowType.WindowContextHelpButtonHint)
|
||||
@ -46,17 +64,20 @@ class ExportWindow(QtWidgets.QDialog):
|
||||
self.cbxDeleteOriginal.setChecked(False)
|
||||
self.cbxAddToList.setChecked(True)
|
||||
self.radioDontCreate.setChecked(True)
|
||||
self.setModal(True)
|
||||
|
||||
self.deleteOriginal = False
|
||||
self.addToList = True
|
||||
self.fileConflictBehavior = ExportConflictOpts.dontCreate
|
||||
def show(self, count: int) -> None:
|
||||
self.label.setText(self.msg.format(count=count))
|
||||
self.adjustSize()
|
||||
QtWidgets.QDialog.show(self)
|
||||
|
||||
def accept(self) -> None:
|
||||
QtWidgets.QDialog.accept(self)
|
||||
|
||||
self.deleteOriginal = self.cbxDeleteOriginal.isChecked()
|
||||
self.addToList = self.cbxAddToList.isChecked()
|
||||
conflict = ExportConflictOpts.DONT_CREATE
|
||||
if self.radioDontCreate.isChecked():
|
||||
self.fileConflictBehavior = ExportConflictOpts.dontCreate
|
||||
conflict = ExportConflictOpts.DONT_CREATE
|
||||
elif self.radioCreateNew.isChecked():
|
||||
self.fileConflictBehavior = ExportConflictOpts.createUnique
|
||||
conflict = ExportConflictOpts.CREATE_UNIQUE
|
||||
|
||||
QtWidgets.QDialog.accept(self)
|
||||
self.export.emit(ExportConfig(conflict, self.cbxAddToList.isChecked(), self.cbxDeleteOriginal.isChecked()))
|
||||
|
@ -30,7 +30,7 @@ from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.graphics import graphics_path
|
||||
from comictaggerlib.optionalmsgdialog import OptionalMessageDialog
|
||||
from comictaggerlib.settingswindow import linuxRarHelp, macRarHelp, windowsRarHelp
|
||||
from comictaggerlib.ui import ui_path
|
||||
from comictaggerlib.ui import qtutils, ui_path
|
||||
from comictaggerlib.ui.qtutils import center_window_on_parent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -56,7 +56,7 @@ class FileSelectionList(QtWidgets.QWidget):
|
||||
uic.loadUi(uifile, self)
|
||||
|
||||
self.config = config
|
||||
|
||||
self.twList: QtWidgets.QTableWidget
|
||||
self.twList.horizontalHeader().setMinimumSectionSize(50)
|
||||
self.twList.currentItemChanged.connect(self.current_item_changed_cb)
|
||||
|
||||
@ -108,6 +108,17 @@ class FileSelectionList(QtWidgets.QWidget):
|
||||
QtWidgets.QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, self.twList.columnCount() - 1), False
|
||||
)
|
||||
|
||||
def remove_deleted(self) -> None:
|
||||
deleted = []
|
||||
for row in range(self.twList.rowCount()):
|
||||
row_ca = self.get_archive_by_row(row)
|
||||
if not row_ca:
|
||||
continue
|
||||
if not row_ca.path.exists():
|
||||
deleted.append(row_ca)
|
||||
|
||||
self.remove_archive_list(deleted)
|
||||
|
||||
def remove_archive_list(self, ca_list: list[ComicArchive]) -> None:
|
||||
self.twList.setSortingEnabled(False)
|
||||
current_removed = False
|
||||
@ -219,11 +230,8 @@ class FileSelectionList(QtWidgets.QWidget):
|
||||
self.twList.selectRow(first_added)
|
||||
else:
|
||||
if len(pathlist) == 1 and os.path.isfile(pathlist[0]):
|
||||
QtWidgets.QMessageBox.information(
|
||||
self, "File Open", "Selected file doesn't seem to be a comic archive."
|
||||
)
|
||||
else:
|
||||
QtWidgets.QMessageBox.information(self, "File/Folder Open", "No readable comic archives were found.")
|
||||
return qtutils.information(self, "File Open", "Selected file doesn't seem to be a comic archive.")
|
||||
return qtutils.information(self, "File/Folder Open", "No readable comic archives were found.")
|
||||
|
||||
if rar_added_ro:
|
||||
self.rar_ro_message()
|
||||
@ -339,10 +347,15 @@ class FileSelectionList(QtWidgets.QWidget):
|
||||
ca: ComicArchive = self.twList.item(row, FileSelectionList.dataColNum).data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
|
||||
filename_item = self.twList.item(row, FileSelectionList.fileColNum)
|
||||
assert filename_item
|
||||
folder_item = self.twList.item(row, FileSelectionList.folderColNum)
|
||||
assert folder_item
|
||||
md_item = self.twList.item(row, FileSelectionList.MDFlagColNum)
|
||||
assert md_item
|
||||
type_item = self.twList.item(row, FileSelectionList.typeColNum)
|
||||
assert type_item
|
||||
readonly_item = self.twList.item(row, FileSelectionList.readonlyColNum)
|
||||
assert readonly_item
|
||||
|
||||
item_text = os.path.split(ca.path)[1]
|
||||
filename_item.setText(item_text)
|
||||
@ -398,6 +411,10 @@ class FileSelectionList(QtWidgets.QWidget):
|
||||
|
||||
if old_idx == new_idx:
|
||||
return
|
||||
ca = self.get_archive_by_row(new_idx)
|
||||
if not ca or not ca.path.exists():
|
||||
self.remove_deleted()
|
||||
return
|
||||
|
||||
# don't allow change if modified
|
||||
if prev is not None and new_idx != old_idx:
|
||||
|
@ -23,15 +23,14 @@ try:
|
||||
If unavailable (non-console application), log an additional notice.
|
||||
"""
|
||||
if QtWidgets.QApplication.instance() is not None:
|
||||
errorbox = QtWidgets.QMessageBox()
|
||||
errorbox = QtWidgets.QMessageBox(QtWidgets.QApplication.activeWindow())
|
||||
errorbox.setStandardButtons(
|
||||
QtWidgets.QMessageBox.StandardButton.Abort | QtWidgets.QMessageBox.StandardButton.Ignore
|
||||
)
|
||||
errorbox.setText(log_msg)
|
||||
if errorbox.exec() == QtWidgets.QMessageBox.StandardButton.Abort:
|
||||
QtWidgets.QApplication.exit(1)
|
||||
else:
|
||||
logger.warning("Exception ignored")
|
||||
errorbox.rejected.connect(lambda: QtWidgets.QApplication.exit(1))
|
||||
errorbox.accepted.connect(lambda: logger.warning("Exception ignored"))
|
||||
errorbox.show()
|
||||
else:
|
||||
logger.debug("No QApplication instance available.")
|
||||
|
||||
@ -128,7 +127,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()
|
||||
|
@ -17,8 +17,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import dataclasses
|
||||
import functools
|
||||
import io
|
||||
import logging
|
||||
import pathlib
|
||||
from enum import Enum, auto
|
||||
from operator import attrgetter
|
||||
from typing import Any, Callable
|
||||
|
||||
@ -28,11 +32,10 @@ from comicapi import utils
|
||||
from comicapi.comicarchive import ComicArchive
|
||||
from comicapi.genericmetadata import ComicSeries, GenericMetadata, ImageHash
|
||||
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__)
|
||||
|
||||
@ -70,25 +73,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
|
||||
@ -109,30 +123,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 = b"", image: Image.Image | None = None) -> int:
|
||||
if self.image_hasher == 3:
|
||||
return ImageHasher(data=image_data, image=image).perception_hash()
|
||||
@ -162,23 +170,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 not final_cover_matching:
|
||||
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
|
||||
final_cover_matching = full # display more options for the user to pick
|
||||
self.log_msg("--------------------------------------------------------------------------")
|
||||
for match_item in final_cover_matching:
|
||||
@ -290,14 +298,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)))
|
||||
|
||||
@ -319,7 +329,7 @@ class IssueIdentifier:
|
||||
if primary_img_url is None or (not primary_img_url.Kind and not primary_img_url.URL and not use_alt_urls):
|
||||
return Score(score=100, url="", remote_hash=0, local_hash=0, local_hash_name="0")
|
||||
|
||||
self._user_canceled()
|
||||
# self._user_canceled()
|
||||
|
||||
remote_hashes = []
|
||||
|
||||
@ -398,7 +408,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))
|
||||
|
||||
@ -438,11 +448,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"]
|
||||
@ -525,7 +535,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(
|
||||
@ -586,8 +597,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}")
|
||||
@ -604,13 +616,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}")
|
||||
@ -621,7 +636,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 PyQt6 import QtCore, QtGui, QtWidgets, uic
|
||||
from PyQt6 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 SelectionWindow
|
||||
from comictaggerlib.ui import ui_path
|
||||
from comictalker.comictalker import ComicTalker, RLCallBack, TalkerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -39,117 +39,78 @@ class IssueNumberTableWidgetItem(QtWidgets.QTableWidgetItem):
|
||||
return (IssueString(self_str).as_float() or 0) < (IssueString(other_str).as_float() or 0)
|
||||
|
||||
|
||||
class IssueSelectionWindow(QtWidgets.QDialog):
|
||||
class QueryThread(QtCore.QThread): # TODO: Evaluate thread semantics. Specifically with signals
|
||||
def __init__(
|
||||
self,
|
||||
talker: ComicTalker,
|
||||
series_id: str,
|
||||
finish: QtCore.pyqtSignal,
|
||||
on_ratelimit: QtCore.pyqtSignal,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.series_id = series_id
|
||||
self.talker = talker
|
||||
self.finish = finish
|
||||
self.on_ratelimit = on_ratelimit
|
||||
|
||||
def run(self) -> None:
|
||||
|
||||
try:
|
||||
issue_list = [
|
||||
x
|
||||
for x in self.talker.fetch_issues_in_series(
|
||||
self.series_id, on_rate_limit=RLCallBack(lambda x, y: self.on_ratelimit.emit(x, y), 10)
|
||||
)
|
||||
if x.issue_id is not None
|
||||
]
|
||||
except TalkerError as e:
|
||||
logger.exception("Failed to retrieve issue list: %s", e)
|
||||
return
|
||||
|
||||
self.finish.emit(issue_list)
|
||||
|
||||
|
||||
class IssueSelectionWindow(SelectionWindow):
|
||||
ui_file = ui_path / "issueselectionwindow.ui"
|
||||
CoverImageMode = CoverImageWidget.AltCoverMode
|
||||
finish = QtCore.pyqtSignal(list)
|
||||
|
||||
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:
|
||||
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)
|
||||
self.finish.connect(self.query_finished)
|
||||
|
||||
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 perform_query(self) -> None:
|
||||
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
|
||||
}
|
||||
except TalkerError as e:
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
QtWidgets.QMessageBox.critical(self, f"{e.source} {e.code_name} Error", f"{e}")
|
||||
return
|
||||
def perform_query(self) -> None: # type: ignore[override]
|
||||
self.querythread = QueryThread(
|
||||
self.talker,
|
||||
self.series_id,
|
||||
self.finish,
|
||||
self.ratelimit,
|
||||
)
|
||||
self.querythread.start()
|
||||
|
||||
def query_finished(self, issues: list[GenericMetadata]) -> None:
|
||||
self.twList.setRowCount(0)
|
||||
|
||||
self.twList.setSortingEnabled(False)
|
||||
|
||||
for row, issue in enumerate(self.issue_list.values()):
|
||||
self.issue_list = {i.issue_id: i for i in issues if i.issue_id is not None}
|
||||
self.twList.clear()
|
||||
for row, issue in enumerate(issues):
|
||||
self.twList.insertRow(row)
|
||||
self.twList.setItem(row, 0, IssueNumberTableWidgetItem())
|
||||
self.twList.setItem(row, 1, QtWidgets.QTableWidgetItem())
|
||||
@ -162,20 +123,22 @@ class IssueSelectionWindow(QtWidgets.QDialog):
|
||||
|
||||
self.twList.setSortingEnabled(True)
|
||||
self.twList.sortItems(0, QtCore.Qt.SortOrder.AscendingOrder)
|
||||
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
self.twList: QtWidgets.QTableWidget
|
||||
if self.initial_id:
|
||||
for r in range(0, self.twList.rowCount()):
|
||||
item = self.twList.item(r, 0)
|
||||
issue_id = item.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
if issue_id == self.initial_id:
|
||||
self.twList.selectRow(r)
|
||||
self.twList.scrollToItem(item, QtWidgets.QAbstractItemView.ScrollHint.EnsureVisible)
|
||||
break
|
||||
self.show()
|
||||
|
||||
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]
|
||||
self.twList.setStyleSheet(self.twList.styleSheet())
|
||||
item_text = issue.issue or ""
|
||||
item = self.twList.item(row, 0)
|
||||
item.setText(item_text)
|
||||
@ -201,35 +164,23 @@ 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.on_ratelimit, 10)
|
||||
)
|
||||
except TalkerError:
|
||||
pass
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
self.issue_number = issue.issue or ""
|
||||
# We don't currently have a way to display hashes to the user
|
||||
# TODO: display the hash to the user so they know it will be used for cover matching
|
||||
alt_images = [url.URL for url in issue._alternate_images]
|
||||
cover = issue._cover_image.URL if issue._cover_image else ""
|
||||
self.coverWidget.set_issue_details(self.issue_id, [cover, *alt_images])
|
||||
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, [cover, *alt_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)
|
||||
|
@ -32,6 +32,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MatchSelectionWindow(QtWidgets.QDialog):
|
||||
match_selected = QtCore.pyqtSignal(IssueResult)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget,
|
||||
@ -70,6 +72,7 @@ class MatchSelectionWindow(QtWidgets.QDialog):
|
||||
|
||||
self.twList.currentItemChanged.connect(self.current_item_changed)
|
||||
self.twList.cellDoubleClicked.connect(self.cell_double_clicked)
|
||||
self.accepted.connect(self.select)
|
||||
|
||||
self.update_data()
|
||||
|
||||
@ -160,3 +163,6 @@ class MatchSelectionWindow(QtWidgets.QDialog):
|
||||
row = self.twList.currentRow()
|
||||
match: IssueResult = self.twList.item(row, 0).data(QtCore.Qt.ItemDataRole.UserRole)[0]
|
||||
return match
|
||||
|
||||
def selected(self) -> None:
|
||||
self.match_selected.emit(self.current_match())
|
||||
|
@ -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
|
||||
|
@ -1,15 +1,4 @@
|
||||
"""A PyQt6 dialog to show a message and let the user check a box
|
||||
|
||||
Example usage:
|
||||
|
||||
checked = OptionalMessageDialog.msg(self, "Disclaimer",
|
||||
"This is beta software, and you are using it at your own risk!",
|
||||
)
|
||||
|
||||
said_yes, checked = OptionalMessageDialog.question(self, "QtWidgets.Question",
|
||||
"Are you sure you wish to do this?",
|
||||
)
|
||||
"""
|
||||
"""A PyQt6 dialog to show a message and let the user check a box"""
|
||||
|
||||
#
|
||||
# Copyright 2012-2014 ComicTagger Authors
|
||||
@ -28,6 +17,7 @@ said_yes, checked = OptionalMessageDialog.question(self, "QtWidgets.Question",
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from PyQt6 import QtCore, QtWidgets
|
||||
|
||||
@ -39,7 +29,14 @@ StyleQuestion = 1
|
||||
|
||||
class OptionalMessageDialog(QtWidgets.QDialog):
|
||||
def __init__(
|
||||
self, parent: QtWidgets.QWidget, style: int, title: str, msg: str, checked: bool = False, check_text: str = ""
|
||||
self,
|
||||
parent: QtWidgets.QWidget,
|
||||
style: int,
|
||||
title: str,
|
||||
msg: str,
|
||||
*,
|
||||
checked: bool = False,
|
||||
check_text: str = "",
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
@ -85,36 +82,54 @@ class OptionalMessageDialog(QtWidgets.QDialog):
|
||||
layout.addWidget(self.theButtonBox)
|
||||
|
||||
def accept(self) -> None:
|
||||
self.was_accepted = True
|
||||
QtWidgets.QDialog.accept(self)
|
||||
|
||||
def reject(self) -> None:
|
||||
self.was_accepted = False
|
||||
QtWidgets.QDialog.reject(self)
|
||||
|
||||
@staticmethod
|
||||
def msg(parent: QtWidgets.QWidget, title: str, msg: str, checked: bool = False, check_text: str = "") -> bool:
|
||||
def msg(
|
||||
parent: QtWidgets.QWidget,
|
||||
title: str,
|
||||
msg: str,
|
||||
*,
|
||||
callback: Callable[[bool], None],
|
||||
checked: bool = False,
|
||||
check_text: str = "",
|
||||
) -> None:
|
||||
d = OptionalMessageDialog(parent, StyleMessage, title, msg, checked=checked, check_text=check_text)
|
||||
|
||||
d.exec()
|
||||
return d.theCheckBox.isChecked()
|
||||
def finished(i: int) -> None:
|
||||
callback(d.theCheckBox.isChecked())
|
||||
|
||||
d.finished.connect(finished)
|
||||
|
||||
d.show()
|
||||
|
||||
@staticmethod
|
||||
def question(
|
||||
parent: QtWidgets.QWidget, title: str, msg: str, checked: bool = False, check_text: str = ""
|
||||
) -> tuple[bool, bool]:
|
||||
parent: QtWidgets.QWidget,
|
||||
title: str,
|
||||
msg: str,
|
||||
*,
|
||||
callback: Callable[[bool, bool], None],
|
||||
checked: bool = False,
|
||||
check_text: str = "",
|
||||
) -> None:
|
||||
d = OptionalMessageDialog(parent, StyleQuestion, title, msg, checked=checked, check_text=check_text)
|
||||
|
||||
d.exec()
|
||||
def finished(i: int) -> None:
|
||||
callback(i == QtWidgets.QDialog.DialogCode.Accepted, d.theCheckBox.isChecked())
|
||||
|
||||
return d.was_accepted, d.theCheckBox.isChecked()
|
||||
d.finished.connect(finished)
|
||||
|
||||
d.show()
|
||||
|
||||
@staticmethod
|
||||
def msg_no_checkbox(
|
||||
parent: QtWidgets.QWidget, title: str, msg: str, checked: bool = False, check_text: str = ""
|
||||
) -> bool:
|
||||
parent: QtWidgets.QWidget, title: str, msg: str, *, checked: bool = False, check_text: str = ""
|
||||
) -> None:
|
||||
d = OptionalMessageDialog(parent, StyleMessage, title, msg, checked=checked, check_text=check_text)
|
||||
d.theCheckBox.hide()
|
||||
|
||||
d.exec()
|
||||
return d.theCheckBox.isChecked()
|
||||
d.show()
|
||||
|
@ -351,7 +351,7 @@ class PageListEditor(QtWidgets.QWidget):
|
||||
self.comic_archive = comic_archive
|
||||
self.pages_list = pages_list
|
||||
if pages_list:
|
||||
self.select_read_tags(self.tag_ids)
|
||||
self.select_write_tags(self.tag_ids)
|
||||
else:
|
||||
self.cbPageType.setEnabled(False)
|
||||
self.chkDoublePage.setEnabled(False)
|
||||
@ -396,7 +396,7 @@ class PageListEditor(QtWidgets.QWidget):
|
||||
self.first_front_page = self.get_first_front_cover()
|
||||
self.firstFrontCoverChanged.emit(self.first_front_page)
|
||||
|
||||
def select_read_tags(self, tag_ids: list[str]) -> None:
|
||||
def select_write_tags(self, tag_ids: list[str]) -> None:
|
||||
# depending on the current tags, certain fields are disabled
|
||||
if not tag_ids:
|
||||
return
|
||||
|
@ -25,7 +25,7 @@ from comicapi.comicarchive import ComicArchive
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PageLoader(QtCore.QThread):
|
||||
class PageLoader(QtCore.QThread): # TODO: Evaluate thread semantics. Specifically with signals
|
||||
"""
|
||||
This class holds onto a reference of each instance in a list since
|
||||
problems occur if the ref count goes to zero and the GC tries to reap
|
||||
|
@ -381,7 +381,7 @@ class QuickTag:
|
||||
aggressive_results, display_results, ca, tags, interactive, aggressive_filtering
|
||||
)
|
||||
if chosen_result:
|
||||
return self.talker.fetch_comic_data(issue_id=chosen_result.ID)
|
||||
return self.talker.fetch_comic_data(issue_id=chosen_result.ID, on_rate_limit=None)
|
||||
return None
|
||||
|
||||
def SearchHashes(
|
||||
@ -421,10 +421,10 @@ class QuickTag:
|
||||
self.output(f"Retrieving basic {self.talker.name} data for {len(relevant_ids)} results")
|
||||
# Try to do a bulk fetch of basic issue data, if we have more than 1 id
|
||||
if hasattr(self.talker, "fetch_comics") and len(all_ids) > 1:
|
||||
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))
|
||||
|
||||
retrieved_ids = {ID(self.domain, md.issue_id) for md in md_results} # type: ignore[arg-type]
|
||||
bad_ids = relevant_ids - retrieved_ids
|
||||
|
@ -20,6 +20,7 @@ import logging
|
||||
|
||||
import settngs
|
||||
from PyQt6 import QtCore, QtWidgets, uic
|
||||
from PyQt6.QtGui import QColorConstants
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi.comicarchive import ComicArchive, tags
|
||||
@ -27,7 +28,7 @@ from comicapi.genericmetadata import GenericMetadata
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.filerenamer import FileRenamer, get_rename_dir
|
||||
from comictaggerlib.settingswindow import SettingsWindow
|
||||
from comictaggerlib.ui import ui_path
|
||||
from comictaggerlib.ui import qtutils, ui_path
|
||||
from comictaggerlib.ui.qtutils import center_window_on_parent
|
||||
from comictalker.comictalker import ComicTalker
|
||||
|
||||
@ -70,26 +71,22 @@ class RenameWindow(QtWidgets.QDialog):
|
||||
|
||||
self.do_preview()
|
||||
|
||||
def config_renamer(self, ca: ComicArchive, md: GenericMetadata = GenericMetadata()) -> str:
|
||||
def config_renamer(self, ca: ComicArchive, md: GenericMetadata = GenericMetadata()) -> tuple[str, Exception | None]:
|
||||
self.renamer.set_template(self.config[0].File_Rename__template)
|
||||
self.renamer.set_issue_zero_padding(self.config[0].File_Rename__issue_number_padding)
|
||||
self.renamer.set_smart_cleanup(self.config[0].File_Rename__use_smart_string_cleanup)
|
||||
self.renamer.replacements = self.config[0].File_Rename__replacements
|
||||
self.renamer.move_only = self.config[0].File_Rename__only_move
|
||||
error = None
|
||||
|
||||
new_ext = ca.path.suffix # default
|
||||
if self.config[0].File_Rename__auto_extension:
|
||||
new_ext = ca.extension()
|
||||
|
||||
if md is None or md.is_empty:
|
||||
md, error = self.parent().read_selected_tags(self.read_tag_ids, ca)
|
||||
md, _, error = self.parent().read_selected_tags(self.read_tag_ids, ca)
|
||||
if error is not None:
|
||||
logger.error("Failed to load tags from %s: %s", ca.path, error)
|
||||
QtWidgets.QMessageBox.warning(
|
||||
self,
|
||||
"Read Failed!",
|
||||
f"One or more of the read tags failed to load for {ca.path}, check log for details",
|
||||
)
|
||||
|
||||
if md.is_empty:
|
||||
md = ca.metadata_from_filename(
|
||||
@ -100,20 +97,22 @@ class RenameWindow(QtWidgets.QDialog):
|
||||
)
|
||||
self.renamer.set_metadata(md, ca.path.name)
|
||||
self.renamer.move = self.config[0].File_Rename__move
|
||||
return new_ext
|
||||
return new_ext, error
|
||||
|
||||
def do_preview(self) -> None:
|
||||
self.twList.setRowCount(0)
|
||||
|
||||
self.twList.setSortingEnabled(False)
|
||||
|
||||
errors = False
|
||||
for ca in self.comic_archive_list:
|
||||
new_ext = self.config_renamer(ca)
|
||||
new_ext, error = self.config_renamer(ca)
|
||||
errors = errors or error is not None
|
||||
try:
|
||||
new_name = self.renamer.determine_name(new_ext)
|
||||
except ValueError as e:
|
||||
logger.exception("Invalid format string: %s", self.config[0].File_Rename__template)
|
||||
QtWidgets.QMessageBox.critical(
|
||||
qtutils.critical(
|
||||
self,
|
||||
"Invalid format string!",
|
||||
"Your rename template is invalid!"
|
||||
@ -128,7 +127,7 @@ class RenameWindow(QtWidgets.QDialog):
|
||||
logger.exception(
|
||||
"Formatter failure: %s metadata: %s", self.config[0].File_Rename__template, self.renamer.metadata
|
||||
)
|
||||
QtWidgets.QMessageBox.critical(
|
||||
qtutils.critical(
|
||||
self,
|
||||
"The formatter had an issue!",
|
||||
"The formatter has experienced an unexpected error!"
|
||||
@ -164,7 +163,11 @@ class RenameWindow(QtWidgets.QDialog):
|
||||
|
||||
new_name_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
self.twList.setItem(row, 2, new_name_item)
|
||||
new_name_item.setText(new_name)
|
||||
if error is not None:
|
||||
new_name_item.setText(f"Error reading tags: {error}")
|
||||
new_name_item.setBackground(QColorConstants.Red)
|
||||
else:
|
||||
new_name_item.setText(new_name)
|
||||
new_name_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, new_name)
|
||||
|
||||
self.rename_list.append(new_name)
|
||||
@ -177,14 +180,18 @@ class RenameWindow(QtWidgets.QDialog):
|
||||
self.twList.setColumnWidth(0, 200)
|
||||
|
||||
self.twList.setSortingEnabled(True)
|
||||
if errors:
|
||||
qtutils.warning(self, "Read Failed!", "One or more of the read tags failed to load, check log for details")
|
||||
|
||||
def modify_settings(self) -> None:
|
||||
settingswin = SettingsWindow(self, self.config, self.talkers)
|
||||
settingswin.setModal(True)
|
||||
settingswin.show_rename_tab()
|
||||
settingswin.exec()
|
||||
if settingswin.result():
|
||||
self.do_preview()
|
||||
settingswin.accepted.connect(self.settings_closed)
|
||||
settingswin.show()
|
||||
|
||||
def settings_closed(self) -> None:
|
||||
self.do_preview()
|
||||
|
||||
def accept(self) -> None:
|
||||
prog_dialog = QtWidgets.QProgressDialog("", "Cancel", 0, len(self.rename_list), self)
|
||||
@ -234,22 +241,23 @@ class RenameWindow(QtWidgets.QDialog):
|
||||
except Exception as e:
|
||||
assert comic
|
||||
logger.exception("Failed to rename comic archive: %s", comic[0].path)
|
||||
QtWidgets.QMessageBox.critical(
|
||||
qtutils.critical(
|
||||
self,
|
||||
"There was an issue when renaming!",
|
||||
f"Renaming failed!<br/><br/>{type(e).__name__}: {e}<br/><br/>",
|
||||
)
|
||||
|
||||
if failed_renames:
|
||||
QtWidgets.QMessageBox.critical(
|
||||
qtutils.critical(
|
||||
self,
|
||||
f"Failed to rename {len(failed_renames)} files!",
|
||||
f"Renaming failed for {len(failed_renames)} files!<br/><br/>"
|
||||
+ "<br/>".join([f"{x[0]!r} -> {x[1]!r}: {x[2]}" for x in failed_renames])
|
||||
+ "<br/><br/>",
|
||||
"Renaming failed for {} files!<br/><br/>{}<br/><br/>".format(
|
||||
len(failed_renames),
|
||||
"<br/>".join([f"{x[0]!r} -> {x[1]!r}: {x[2]}" for x in failed_renames]),
|
||||
),
|
||||
)
|
||||
|
||||
prog_dialog.hide()
|
||||
prog_dialog.close()
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
|
||||
QtWidgets.QDialog.accept(self)
|
||||
|
@ -19,30 +19,33 @@ from __future__ import annotations
|
||||
import difflib
|
||||
import itertools
|
||||
import logging
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
import natsort
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets, uic
|
||||
from PyQt6.QtCore import QUrl, pyqtSignal
|
||||
from PyQt6.QtCore import Qt, QUrl, pyqtSignal
|
||||
|
||||
from comicapi import utils
|
||||
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__)
|
||||
|
||||
|
||||
class SearchThread(QtCore.QThread):
|
||||
class SearchThread(QtCore.QThread): # TODO: Evaluate thread semantics. Specifically with signals
|
||||
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 +64,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 +82,94 @@ 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))
|
||||
|
||||
class IdentifyThread(QtCore.QThread): # TODO: Evaluate thread semantics. Specifically with signals
|
||||
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 SelectionWindow(QtWidgets.QDialog):
|
||||
__metaclass__ = ABCMeta
|
||||
ui_file = ui_path / "seriesselectionwindow.ui"
|
||||
CoverImageMode = CoverImageWidget.URLMode
|
||||
ratelimit = pyqtSignal(float, float)
|
||||
|
||||
class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
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)
|
||||
self.setWindowModality(Qt.WindowModality.WindowModal)
|
||||
|
||||
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 +180,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 +201,27 @@ 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)
|
||||
@abstractmethod
|
||||
def perform_query(self, refresh: bool = False) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def cell_double_clicked(self, r: int, c: int) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def update_row(self, row: int, series: ComicSeries) -> None: ...
|
||||
|
||||
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 +232,182 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
for r in rows - shown_rows:
|
||||
self.twList.hideRow(r)
|
||||
|
||||
@abstractmethod
|
||||
def _fetch(self, row: int) -> ComicSeries: ...
|
||||
|
||||
def on_ratelimit(self, full_time: float, sleep_time: float) -> None:
|
||||
self.ratelimit.emit(full_time, sleep_time)
|
||||
|
||||
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()
|
||||
|
||||
item = self._fetch(row)
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
# Update current record information
|
||||
self.update_row(row, item)
|
||||
|
||||
|
||||
class SeriesSelectionWindow(SelectionWindow):
|
||||
ui_file = ui_path / "seriesselectionwindow.ui"
|
||||
CoverImageMode = CoverImageWidget.URLMode
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget,
|
||||
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:
|
||||
from comictaggerlib.issueselectionwindow import IssueSelectionWindow
|
||||
|
||||
super().__init__(
|
||||
parent,
|
||||
config,
|
||||
talker,
|
||||
series_name,
|
||||
issue_number,
|
||||
comic_archive,
|
||||
year,
|
||||
issue_count,
|
||||
autoselect,
|
||||
literal,
|
||||
)
|
||||
self.count = 0
|
||||
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.ratelimit.connect(self.ratelimit_message)
|
||||
|
||||
self.update_buttons()
|
||||
|
||||
self.selector = IssueSelectionWindow(self, self.config, self.talker, self.series_id, self.issue_number)
|
||||
self.selector.ratelimit.connect(self.ratelimit)
|
||||
self.selector.finished.connect(self.issue_selected)
|
||||
|
||||
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.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.open()
|
||||
else:
|
||||
self.progdialog = None
|
||||
|
||||
def cell_double_clicked(self, r: int, c: int) -> None:
|
||||
self.show_issues()
|
||||
|
||||
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()))
|
||||
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 _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.on_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,25 +422,19 @@ 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:
|
||||
if self.comic_archive is None:
|
||||
QtWidgets.QMessageBox.information(self, "Auto-Select", "You need to load a comic first!")
|
||||
qtutils.information(self, "Auto-Select", "You need to load a comic first!")
|
||||
return
|
||||
|
||||
if self.issue_number is None or self.issue_number == "":
|
||||
QtWidgets.QMessageBox.information(self, "Auto-Select", "Can't auto-select without an issue number (yet!)")
|
||||
qtutils.information(self, "Auto-Select", "Can't auto-select without an issue number (yet!)")
|
||||
return
|
||||
|
||||
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
|
||||
@ -240,98 +442,91 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
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.ratelimit)
|
||||
self.iddialog.rejected.connect(self.id_thread.cancel)
|
||||
|
||||
self.id_thread.start()
|
||||
|
||||
self.iddialog.exec()
|
||||
self.iddialog.open()
|
||||
|
||||
def log_id_output(self, text: str) -> None:
|
||||
if self.iddialog is not None:
|
||||
self.iddialog.textEdit.append(text.rstrip())
|
||||
self.iddialog.textEdit.ensureCursorVisible()
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
def log_output(self, text: str) -> None:
|
||||
if self.iddialog is None:
|
||||
return
|
||||
self.iddialog.textEdit.append(text.rstrip())
|
||||
self.iddialog.textEdit.ensureCursorVisible()
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
|
||||
def identify_progress(self, cur: int, total: int) -> None:
|
||||
if self.iddialog is None:
|
||||
return
|
||||
self.iddialog.progressBar.setMaximum(total)
|
||||
self.iddialog.progressBar.setValue(cur)
|
||||
|
||||
def identify_complete(self, result: IIResult, issues: list[IssueResult]) -> None:
|
||||
if self.iddialog is None or self.comic_archive is None:
|
||||
return
|
||||
|
||||
if result == IIResult.single_good_match:
|
||||
return self.update_match(issues[0])
|
||||
|
||||
qmsg = QtWidgets.QMessageBox(parent=self)
|
||||
qmsg.setIcon(qmsg.Icon.Information)
|
||||
qmsg.setText("Auto-Select Result")
|
||||
qmsg.setInformativeText(" Manual interaction needed :-(")
|
||||
qmsg.finished.connect(self.iddialog.close)
|
||||
|
||||
if result == IIResult.no_matches:
|
||||
qmsg.setInformativeText(" No matches found :-(")
|
||||
return qmsg.show()
|
||||
|
||||
if result == IIResult.single_bad_cover_score:
|
||||
qmsg.setInformativeText(" Found a match, but cover doesn't seem the same. Verify before committing!")
|
||||
qmsg.finished.connect(lambda: self.update_match(issues[0]))
|
||||
return qmsg.show()
|
||||
|
||||
selector = MatchSelectionWindow(self, issues, self.comic_archive, talker=self.talker, config=self.config)
|
||||
selector.match_selected.connect(self.update_match)
|
||||
qmsg.finished.connect(selector.open)
|
||||
|
||||
if result == IIResult.multiple_bad_cover_scores:
|
||||
qmsg.setInformativeText(" Found some possibilities, but no confidence. Proceed manually.")
|
||||
elif result == IIResult.multiple_good_matches:
|
||||
qmsg.setInformativeText(" Found multiple likely matches. Please select.")
|
||||
|
||||
qmsg.show()
|
||||
|
||||
def update_match(self, match: IssueResult) -> None:
|
||||
if self.iddialog is not None:
|
||||
self.iddialog.progressBar.setMaximum(total)
|
||||
self.iddialog.progressBar.setValue(cur)
|
||||
self.iddialog.close()
|
||||
|
||||
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:
|
||||
if self.iddialog is not None and self.comic_archive is not None:
|
||||
|
||||
found_match = None
|
||||
choices = False
|
||||
if result == IssueIdentifier.result_no_matches:
|
||||
QtWidgets.QMessageBox.information(self, "Auto-Select Result", " No issues found :-(")
|
||||
elif result == IssueIdentifier.result_found_match_but_bad_cover_score:
|
||||
QtWidgets.QMessageBox.information(
|
||||
self,
|
||||
"Auto-Select Result",
|
||||
" Found a match, but cover doesn't seem the same. Verify before committing!",
|
||||
)
|
||||
found_match = 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:
|
||||
QtWidgets.QMessageBox.information(
|
||||
self, "Auto-Select Result", " Found some possibilities, but no confidence. Proceed manually."
|
||||
)
|
||||
choices = True
|
||||
elif result == IssueIdentifier.result_one_good_match:
|
||||
found_match = issues[0]
|
||||
elif result == IssueIdentifier.result_multiple_good_matches:
|
||||
QtWidgets.QMessageBox.information(
|
||||
self, "Auto-Select Result", " Found multiple likely matches. Please select."
|
||||
)
|
||||
choices = True
|
||||
|
||||
if choices:
|
||||
selector = MatchSelectionWindow(
|
||||
self, issues, self.comic_archive, talker=self.talker, config=self.config
|
||||
)
|
||||
selector.setModal(True)
|
||||
selector.exec()
|
||||
if selector.result():
|
||||
# we should now have a list index
|
||||
found_match = selector.current_match()
|
||||
|
||||
if found_match is not None:
|
||||
self.iddialog.accept()
|
||||
|
||||
self.series_id = utils.xlate(found_match.series_id) or ""
|
||||
self.issue_number = found_match.issue_number
|
||||
self.select_by_id()
|
||||
self.show_issues()
|
||||
self.series_id = utils.xlate(match.series_id) or ""
|
||||
self.issue_number = match.issue_number
|
||||
self.select_by_id()
|
||||
self.show_issues()
|
||||
|
||||
def show_issues(self) -> None:
|
||||
selector = IssueSelectionWindow(self, self.config, self.talker, self.series_id, self.issue_number)
|
||||
title = ""
|
||||
for series in self.series_list.values():
|
||||
if series.id == self.series_id:
|
||||
title = f"{series.name} ({series.start_year:04}) - " if series.start_year else f"{series.name} - "
|
||||
break
|
||||
self.selector.setWindowTitle(title + "Select Issue")
|
||||
self.selector.series_id = self.series_id
|
||||
|
||||
selector.setWindowTitle(title + "Select Issue")
|
||||
selector.setModal(True)
|
||||
selector.exec()
|
||||
if selector.result():
|
||||
self.selector.perform_query()
|
||||
|
||||
def issue_selected(self, result: list[GenericMetadata]) -> None:
|
||||
if result and self.selector:
|
||||
# we should now have a series ID
|
||||
self.issue_number = selector.issue_number
|
||||
self.issue_id = selector.issue_id
|
||||
self.issue_number = self.selector.issue_number
|
||||
self.issue_id = self.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()):
|
||||
@ -339,64 +534,49 @@ 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")
|
||||
if self.search_thread is not None:
|
||||
self.search_thread.searchComplete.disconnect()
|
||||
self.search_thread.progressUpdate.disconnect()
|
||||
self.progdialog.canceled.disconnect()
|
||||
self.progdialog.reject()
|
||||
QtCore.QTimer.singleShot(200, self.close_me)
|
||||
if self.progdialog is None:
|
||||
return
|
||||
logger.info("query cancelled")
|
||||
if self.search_thread is not None:
|
||||
self.search_thread.searchComplete.disconnect()
|
||||
self.search_thread.progressUpdate.disconnect()
|
||||
self.progdialog.canceled.disconnect()
|
||||
self.progdialog.reject()
|
||||
QtCore.QTimer.singleShot(200, self.close_me)
|
||||
|
||||
def close_me(self) -> None:
|
||||
self.reject()
|
||||
|
||||
def search_progress_update(self, current: int, total: int) -> None:
|
||||
if self.progdialog is not None:
|
||||
if self.progdialog is None:
|
||||
return
|
||||
try:
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
self.progdialog.setMaximum(total)
|
||||
self.progdialog.setValue(current + 1)
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
except Exception:
|
||||
...
|
||||
|
||||
def search_complete(self) -> None:
|
||||
if self.progdialog is not None:
|
||||
self.progdialog.accept()
|
||||
self.progdialog = None
|
||||
if self.search_thread is not None and self.search_thread.ct_error:
|
||||
# TODO Currently still opens the window
|
||||
QtWidgets.QMessageBox.critical(
|
||||
self,
|
||||
parent = self.parent()
|
||||
if not isinstance(parent, QtWidgets.QWidget):
|
||||
parent = None
|
||||
return qtutils.critical(
|
||||
parent,
|
||||
f"{self.search_thread.error_e.source} {self.search_thread.error_e.code_name} Error",
|
||||
f"{self.search_thread.error_e}",
|
||||
)
|
||||
return
|
||||
|
||||
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
|
||||
@ -491,58 +671,20 @@ 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()
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
if not self.series_list:
|
||||
QtWidgets.QMessageBox.information(self, "Search Result", "No matches found!")
|
||||
QtCore.QTimer.singleShot(200, self.close_me)
|
||||
return qtutils.information(self, "Search Result", "No matches found!\nSeriesSelectionWindow")
|
||||
|
||||
elif self.immediate_autoselect:
|
||||
# defer the immediate autoselect so this dialog has time to pop up
|
||||
self.show()
|
||||
QtCore.QTimer.singleShot(10, self.do_immediate_autoselect)
|
||||
else:
|
||||
self.show()
|
||||
|
||||
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
|
||||
@ -550,31 +692,14 @@ 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)
|
||||
|
||||
def ratelimit_message(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"
|
||||
)
|
||||
|
@ -37,7 +37,7 @@ from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.ctsettings.plugin import group_for_plugin
|
||||
from comictaggerlib.filerenamer import FileRenamer, Replacement, Replacements
|
||||
from comictaggerlib.imagefetcher import ImageFetcher
|
||||
from comictaggerlib.ui import ui_path
|
||||
from comictaggerlib.ui import qtutils, ui_path
|
||||
from comictalker.comiccacher import ComicCacher
|
||||
from comictalker.comictalker import ComicTalker
|
||||
|
||||
@ -146,6 +146,8 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
with (ui_path / "settingswindow.ui").open(encoding="utf-8") as uifile:
|
||||
uic.loadUi(uifile, self)
|
||||
|
||||
self.leRarExePath: QtWidgets.QLineEdit
|
||||
|
||||
self.setWindowFlags(
|
||||
QtCore.Qt.WindowType(self.windowFlags() & ~QtCore.Qt.WindowType.WindowContextHelpButtonHint)
|
||||
)
|
||||
@ -154,6 +156,8 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
self.talkers = talkers
|
||||
self.name = "Settings"
|
||||
|
||||
self.setModal(True)
|
||||
|
||||
if platform.system() == "Windows":
|
||||
self.lblRarHelp.setText(windowsRarHelp)
|
||||
|
||||
@ -526,7 +530,7 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
logger.error(
|
||||
"Invalid format string: %s", self.config[0].File_Rename__template, exc_info=self.rename_error
|
||||
)
|
||||
QtWidgets.QMessageBox.critical(
|
||||
return qtutils.critical(
|
||||
self,
|
||||
"Invalid format string!",
|
||||
"Your rename template is invalid!"
|
||||
@ -536,23 +540,21 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
+ "<a href='https://docs.python.org/3/library/string.html#format-string-syntax'>"
|
||||
+ "https://docs.python.org/3/library/string.html#format-string-syntax</a>",
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.error(
|
||||
"Formatter failure: %s metadata: %s",
|
||||
self.config[0].File_Rename__template,
|
||||
self.renamer.metadata,
|
||||
exc_info=self.rename_error,
|
||||
)
|
||||
QtWidgets.QMessageBox.critical(
|
||||
self,
|
||||
"The formatter had an issue!",
|
||||
"The formatter has experienced an unexpected error!"
|
||||
+ f"<br/><br/>{type(self.rename_error).__name__}: {self.rename_error}<br/><br/>"
|
||||
+ "Please open an issue at "
|
||||
+ "<a href='https://github.com/comictagger/comictagger'>"
|
||||
+ "https://github.com/comictagger/comictagger</a>",
|
||||
)
|
||||
logger.error(
|
||||
"Formatter failure: %s metadata: %s",
|
||||
self.config[0].File_Rename__template,
|
||||
self.renamer.metadata,
|
||||
exc_info=self.rename_error,
|
||||
)
|
||||
return qtutils.critical(
|
||||
self,
|
||||
"The formatter had an issue!",
|
||||
"The formatter has experienced an unexpected error!"
|
||||
+ f"<br/><br/>{type(self.rename_error).__name__}: {self.rename_error}<br/><br/>"
|
||||
+ "Please open an issue at "
|
||||
+ "<a href='https://github.com/comictagger/comictagger'>"
|
||||
+ "https://github.com/comictagger/comictagger</a>",
|
||||
)
|
||||
|
||||
# Copy values from form to settings and save
|
||||
archive_group = group_for_plugin(Archiver)
|
||||
@ -645,12 +647,12 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
cache_folder.mkdir(parents=True, exist_ok=True)
|
||||
ComicCacher(cache_folder, "0")
|
||||
ImageFetcher(cache_folder)
|
||||
QtWidgets.QMessageBox.information(self, self.name, "Cache has been cleared.")
|
||||
qtutils.information(self, self.name, "Cache has been cleared.")
|
||||
|
||||
def reset_settings(self) -> None:
|
||||
self.config = cast(settngs.Config[ct_ns], settngs.get_namespace(settngs.defaults(self.config[1])))
|
||||
self.settings_to_form()
|
||||
QtWidgets.QMessageBox.information(self, self.name, self.name + " have been returned to default values.")
|
||||
qtutils.information(self, self.name, self.name + " have been returned to default values.")
|
||||
|
||||
def select_file(self, control: QtWidgets.QLineEdit, name: str) -> None:
|
||||
dialog = QtWidgets.QFileDialog(self)
|
||||
@ -671,16 +673,17 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
else:
|
||||
dialog.setWindowTitle(f"Find {name} library")
|
||||
|
||||
if dialog.exec():
|
||||
file_list = dialog.selectedFiles()
|
||||
control.setText(str(file_list[0]))
|
||||
dialog.fileSelected.connect(self.set_rar_path)
|
||||
dialog.open()
|
||||
|
||||
def set_rar_path(self, path: str) -> None:
|
||||
self.leRarExePath.setText(str(path))
|
||||
|
||||
def show_rename_tab(self) -> None:
|
||||
self.tabWidget.setCurrentIndex(5)
|
||||
|
||||
def show_template_help(self) -> None:
|
||||
template_help_win = TemplateHelpWindow(self)
|
||||
template_help_win.setModal(False)
|
||||
template_help_win.show()
|
||||
|
||||
|
||||
|
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("Error retrieving issue details. Save aborted. %s", 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
|
File diff suppressed because it is too large
Load Diff
@ -49,6 +49,15 @@
|
||||
<verstretch>7</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="styleSheet">
|
||||
<string notr="true">QTableWidget[rowCount="0"] {
|
||||
background-image: url(":/graphics/about.png");
|
||||
background-attachment: fixed;
|
||||
background-position: top center;
|
||||
background-repeat: no-repeat;
|
||||
background-color: white;
|
||||
}</string>
|
||||
</property>
|
||||
<property name="selectionMode">
|
||||
<enum>QAbstractItemView::SingleSelection</enum>
|
||||
</property>
|
||||
@ -153,7 +162,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
|
||||
|
||||
[](https://pypi.org/project/pyqt-toast-notification/)
|
||||
[](https://github.com/niklashenning/pyqttoast)
|
||||
[](https://github.com/niklashenning/pyqttoast)
|
||||
[](https://github.com/niklashenning/pyqttoast)
|
||||
[](https://github.com/niklashenning/pyqttoast/blob/master/LICENSE)
|
||||
|
||||
A fully customizable and modern toast notification library for PyQt and PySide
|
||||
|
||||

|
||||
|
||||
## 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",
|
||||
]
|
41
comictaggerlib/ui/pyqttoast/constants.py
Normal file
41
comictaggerlib/ui/pyqttoast/constants.py
Normal file
@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtGui import QColor
|
||||
|
||||
UPDATE_POSITION_DURATION = 200
|
||||
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",
|
||||
"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 PyQt6.QtCore import QSize
|
||||
from PyQt6.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 PyQt6.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 |
2393
comictaggerlib/ui/pyqttoast/toast.py
Normal file
2393
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
|
@ -8,21 +8,16 @@ import traceback
|
||||
import webbrowser
|
||||
from collections.abc import Collection, Sequence
|
||||
|
||||
from PyQt6.QtCore import QUrl
|
||||
from PyQt6.QtGui import QPalette
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from PyQt6 import QtGui, QtWidgets
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtCore import Qt, QUrl
|
||||
from PyQt6.QtGui import QGuiApplication, QPalette
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
|
||||
qt_available = True
|
||||
except ImportError:
|
||||
qt_available = False
|
||||
|
||||
if qt_available:
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
@ -30,6 +25,7 @@ if qt_available:
|
||||
except ImportError:
|
||||
pil_available = False
|
||||
active_palette: QPalette | None = None
|
||||
|
||||
try:
|
||||
from PyQt6.QtWebEngineCore import QWebEnginePage
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||
@ -125,6 +121,9 @@ if qt_available:
|
||||
# And the move call repositions the window
|
||||
window.move(hpos + main_window_size.left(), vpos + main_window_size.top())
|
||||
|
||||
def is_dark_mode() -> bool:
|
||||
return QGuiApplication.styleHints().colorScheme() == Qt.ColorScheme.Dark
|
||||
|
||||
def get_qimage_from_data(image_data: bytes) -> QtGui.QImage:
|
||||
img = QtGui.QImage()
|
||||
|
||||
@ -153,7 +152,7 @@ if qt_available:
|
||||
if e:
|
||||
trace = "\n".join(traceback.format_exception(type(e), e, e.__traceback__))
|
||||
|
||||
QtWidgets.QMessageBox.critical(QtWidgets.QMainWindow(), "Error", msg + trace)
|
||||
return critical(QtWidgets.QMainWindow(), "Error", msg + trace)
|
||||
|
||||
def enable_widget(widget: QtWidgets.QWidget | Collection[QtWidgets.QWidget], enable: bool) -> None:
|
||||
if isinstance(widget, Sequence):
|
||||
@ -237,3 +236,27 @@ if qt_available:
|
||||
# QSplitter has issues with replacing a widget before it's been first shown. Assume it should be visible
|
||||
new_widget.show()
|
||||
return new_widget
|
||||
|
||||
def critical(parent: QWidget | None, title: str, text: str) -> None:
|
||||
qmsg = QtWidgets.QMessageBox(parent)
|
||||
qmsg.setIcon(qmsg.Icon.Critical)
|
||||
qmsg.setText(title)
|
||||
qmsg.setInformativeText(text)
|
||||
return qmsg.show()
|
||||
|
||||
def warning(parent: QWidget | None, title: str, text: str) -> None:
|
||||
qmsg = QtWidgets.QMessageBox(parent)
|
||||
qmsg.setIcon(qmsg.Icon.Warning)
|
||||
qmsg.setText(title)
|
||||
qmsg.setInformativeText(text)
|
||||
return qmsg.show()
|
||||
|
||||
def information(parent: QWidget | None, title: str, text: str) -> None:
|
||||
qmsg = QtWidgets.QMessageBox(parent)
|
||||
qmsg.setIcon(qmsg.Icon.Information)
|
||||
qmsg.setText(title)
|
||||
qmsg.setInformativeText(text)
|
||||
return qmsg.show()
|
||||
|
||||
except ImportError:
|
||||
qt_available = False
|
||||
|
@ -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>
|
||||
|
@ -10,6 +10,7 @@ from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from comictaggerlib.coverimagewidget import CoverImageWidget
|
||||
from comictaggerlib.ctsettings import ct_ns, group_for_plugin
|
||||
from comictaggerlib.ui import qtutils
|
||||
from comictalker.comictalker import ComicTalker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -76,9 +77,9 @@ def generate_api_widgets(
|
||||
def call_check_api(*args: Any, tab: TalkerTab, talker: ComicTalker, definitions: settngs.Definitions) -> None:
|
||||
check_text, check_bool = talker.check_status(get_config_from_tab(tab, definitions[group_for_plugin(talker)]))
|
||||
if check_bool:
|
||||
QtWidgets.QMessageBox.information(None, "API Test Success", check_text)
|
||||
else:
|
||||
QtWidgets.QMessageBox.warning(None, "API Test Failed", check_text)
|
||||
return qtutils.information(TalkerTab.tab, "API Test Success", check_text)
|
||||
|
||||
qtutils.warning(TalkerTab.tab, "API Test Failed", check_text)
|
||||
|
||||
# get the actual config objects in case they have overwritten the default
|
||||
btn_test_row = None
|
||||
|
@ -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, urlencode, 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, StrEnum, parse_url
|
||||
from comictalker import talker_utils
|
||||
from comictalker.comiccacher import ComicCacher, Issue, Series
|
||||
from comictalker.comictalker import ComicTalker, TalkerDataError, TalkerError, TalkerNetworkError
|
||||
from comictalker.comictalker import ComicTalker, RLCallBack, TalkerDataError, TalkerError, 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
|
||||
@ -274,6 +274,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)
|
||||
@ -305,7 +307,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] = []
|
||||
|
||||
@ -350,7 +356,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"]
|
||||
@ -373,24 +383,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
|
||||
@ -401,7 +443,9 @@ class ComicVineTalker(ComicTalker):
|
||||
series = cvc.get_series_info(series_id, self.id, expire_stale=False)
|
||||
issues = []
|
||||
# Explicitly mark count_of_issues at an impossible value
|
||||
cvseries = CVSeries(id=int(series_id), count_of_issues=-1)
|
||||
cvseries = CVSeries(id=int(series_id), count_of_issues=-1) # type: ignore[typeddict-item]
|
||||
|
||||
# Check if we have the series cached
|
||||
if series:
|
||||
cvseries = cast(CVSeries, json.loads(series[0].data))
|
||||
issues = cvc.get_series_issues_info(series_id, self.id, expire_stale=True)
|
||||
@ -409,10 +453,11 @@ class ComicVineTalker(ComicTalker):
|
||||
for issue, _ in issues:
|
||||
cvissue = cast(CVIssue, json.loads(issue.data))
|
||||
if cvissue.get("issue_number") == issue_number:
|
||||
comicseries = self._fetch_series([int(cvissue["volume"]["id"])], on_rate_limit=on_rate_limit)[0][0]
|
||||
cached_results.append(
|
||||
self._map_comic_issue_to_metadata(
|
||||
cvissue,
|
||||
self._fetch_series([int(cvissue["volume"]["id"])])[0][0],
|
||||
comicseries,
|
||||
),
|
||||
)
|
||||
issue_found = True
|
||||
@ -444,7 +489,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"]
|
||||
@ -459,7 +508,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"]
|
||||
@ -474,7 +527,13 @@ 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)
|
||||
@ -486,7 +545,12 @@ class ComicVineTalker(ComicTalker):
|
||||
flt = "id:" + "|".join(used_issues)
|
||||
return flt, used_issues
|
||||
|
||||
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]:
|
||||
# before we search online, look in our cache, since we might already have this info
|
||||
cvc = self.cacher()
|
||||
cached_results: list[GenericMetadata] = []
|
||||
@ -524,7 +588,7 @@ class ComicVineTalker(ComicTalker):
|
||||
flt, used_issues = self._get_id_list(list(needed_issues))
|
||||
params["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.extend(cv_response["results"])
|
||||
|
||||
@ -566,7 +630,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 = self.cacher()
|
||||
cached_results: list[tuple[ComicSeries, bool]] = []
|
||||
@ -593,7 +661,9 @@ class ComicVineTalker(ComicTalker):
|
||||
flt, used_series = self._get_id_list(list(needed_series))
|
||||
params["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.extend(cv_response["results"])
|
||||
|
||||
@ -616,12 +686,18 @@ 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.
|
||||
"""
|
||||
|
||||
cv_response: CVResult[T] = self._get_url_content(url, params)
|
||||
cv_response: CVResult[T] = self._get_url_content(url, params, on_rate_limit=on_rate_limit)
|
||||
if cv_response["status_code"] != 1:
|
||||
logger.debug(
|
||||
"%s query failed with error #%s: [%s].",
|
||||
@ -633,7 +709,7 @@ class ComicVineTalker(ComicTalker):
|
||||
|
||||
return cv_response
|
||||
|
||||
def _get_url_content(self, url: str, params: dict[str, Any]) -> Any:
|
||||
def _get_url_content(self, url: str, params: dict[str, Any], on_rate_limit: RLCallBack | None = None) -> Any:
|
||||
# if there is a 500 error, try a few more times before giving up
|
||||
limit_counter = 0
|
||||
final_params = self.custom_url_parameters.copy()
|
||||
@ -642,7 +718,7 @@ class ComicVineTalker(ComicTalker):
|
||||
for tries in range(1, 5):
|
||||
try:
|
||||
ratelimit_key = self._get_ratelimit_key(url)
|
||||
with self.limiter.ratelimit(ratelimit_key, delay=True):
|
||||
with self.limiter.ratelimit(ratelimit_key, delay=True, on_rate_limit=on_rate_limit):
|
||||
logger.debug("Requesting: %s?%s", url, urlencode(final_params))
|
||||
self.total_requests_made[ratelimit_key] += 1
|
||||
resp = requests.get(
|
||||
@ -736,13 +812,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 = self.cacher()
|
||||
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",
|
||||
@ -758,7 +841,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"]
|
||||
@ -773,13 +860,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
|
||||
]
|
||||
|
||||
@ -793,7 +890,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 = self.cacher()
|
||||
@ -809,7 +910,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"]
|
||||
|
||||
@ -820,9 +925,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)
|
||||
@ -838,10 +951,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 = self.cacher()
|
||||
@ -850,12 +970,20 @@ class ComicVineTalker(ComicTalker):
|
||||
logger.debug("Issue cached: %s", bool(cached_issue and cached_issue[1]))
|
||||
if cached_issue and cached_issue.complete:
|
||||
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"]
|
||||
|
||||
@ -873,7 +1001,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
|
21
comictalker/vendor/pyrate_limiter/LICENSE
vendored
Normal file
21
comictalker/vendor/pyrate_limiter/LICENSE
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 vutran1710
|
||||
|
||||
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.
|
402
comictalker/vendor/pyrate_limiter/README.md
vendored
Normal file
402
comictalker/vendor/pyrate_limiter/README.md
vendored
Normal file
@ -0,0 +1,402 @@
|
||||
<img align="left" width="95" height="120" src="docs/_static/logo.png">
|
||||
|
||||
# PyrateLimiter
|
||||
The request rate limiter using Leaky-bucket algorithm.
|
||||
|
||||
Full project documentation can be found at [pyratelimiter.readthedocs.io](https://pyratelimiter.readthedocs.io).
|
||||
|
||||
[](https://badge.fury.io/py/pyrate-limiter)
|
||||
[](https://pypi.org/project/pyrate-limiter)
|
||||
[](https://codecov.io/gh/vutran1710/PyrateLimiter)
|
||||
[](https://github.com/vutran1710/PyrateLimiter/graphs/commit-activity)
|
||||
[](https://pypi.python.org/pypi/pyrate-limiter/)
|
||||
|
||||
<br>
|
||||
|
||||
## Contents
|
||||
- [PyrateLimiter](#pyratelimiter)
|
||||
- [Contents](#contents)
|
||||
- [Features](#features)
|
||||
- [Installation](#installation)
|
||||
- [Basic usage](#basic-usage)
|
||||
- [Defining rate limits](#defining-rate-limits)
|
||||
- [Applying rate limits](#applying-rate-limits)
|
||||
- [Identities](#identities)
|
||||
- [Handling exceeded limits](#handling-exceeded-limits)
|
||||
- [Bucket analogy](#bucket-analogy)
|
||||
- [Rate limit exceptions](#rate-limit-exceptions)
|
||||
- [Rate limit delays](#rate-limit-delays)
|
||||
- [Additional usage options](#additional-usage-options)
|
||||
- [Decorator](#decorator)
|
||||
- [Contextmanager](#contextmanager)
|
||||
- [Async decorator/contextmanager](#async-decoratorcontextmanager)
|
||||
- [Backends](#backends)
|
||||
- [Memory](#memory)
|
||||
- [SQLite](#sqlite)
|
||||
- [Redis](#redis)
|
||||
- [Custom backends](#custom-backends)
|
||||
- [Additional features](#additional-features)
|
||||
- [Time sources](#time-sources)
|
||||
- [Examples](#examples)
|
||||
|
||||
## Features
|
||||
* Tracks any number of rate limits and intervals you want to define
|
||||
* Independently tracks rate limits for multiple services or resources
|
||||
* Handles exceeded rate limits by either raising errors or adding delays
|
||||
* Several usage options including a normal function call, a decorator, or a contextmanager
|
||||
* Async support
|
||||
* Includes optional SQLite and Redis backends, which can be used to persist limit tracking across
|
||||
multiple threads, processes, or application restarts
|
||||
|
||||
## Installation
|
||||
Install using pip:
|
||||
```
|
||||
pip install pyrate-limiter
|
||||
```
|
||||
|
||||
Or using conda:
|
||||
```
|
||||
conda install --channel conda-forge pyrate-limiter
|
||||
```
|
||||
|
||||
## Basic usage
|
||||
|
||||
### Defining rate limits
|
||||
Consider some public API (like LinkedIn, GitHub, etc.) that has rate limits like the following:
|
||||
```
|
||||
- 500 requests per hour
|
||||
- 1000 requests per day
|
||||
- 10000 requests per month
|
||||
```
|
||||
|
||||
You can define these rates using the `RequestRate` class, and add them to a `Limiter`:
|
||||
``` python
|
||||
from pyrate_limiter import Duration, RequestRate, Limiter
|
||||
|
||||
hourly_rate = RequestRate(500, Duration.HOUR) # 500 requests per hour
|
||||
daily_rate = RequestRate(1000, Duration.DAY) # 1000 requests per day
|
||||
monthly_rate = RequestRate(10000, Duration.MONTH) # 10000 requests per month
|
||||
|
||||
limiter = Limiter(hourly_rate, daily_rate, monthly_rate)
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
``` python
|
||||
from pyrate_limiter import Duration, RequestRate, Limiter
|
||||
|
||||
rate_limits = (
|
||||
RequestRate(500, Duration.HOUR), # 500 requests per hour
|
||||
RequestRate(1000, Duration.DAY), # 1000 requests per day
|
||||
RequestRate(10000, Duration.MONTH), # 10000 requests per month
|
||||
)
|
||||
|
||||
limiter = Limiter(*rate_limits)
|
||||
```
|
||||
|
||||
Note that these rates need to be ordered by interval length; in other words, an hourly rate must
|
||||
come before a daily rate, etc.
|
||||
|
||||
### Applying rate limits
|
||||
Then, use `Limiter.try_acquire()` wherever you are making requests (or other rate-limited operations).
|
||||
This will raise an exception if the rate limit is exceeded.
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
def request_function():
|
||||
limiter.try_acquire('identity')
|
||||
requests.get('https://example.com')
|
||||
|
||||
while True:
|
||||
request_function()
|
||||
```
|
||||
|
||||
Alternatively, you can use `Limiter.ratelimit()` as a function decorator:
|
||||
```python
|
||||
@limiter.ratelimit('identity')
|
||||
def request_function():
|
||||
requests.get('https://example.com')
|
||||
```
|
||||
See [Additional usage options](#additional-usage-options) below for more details.
|
||||
|
||||
### Identities
|
||||
Note that both `try_acquire()` and `ratelimit()` take one or more `identity` arguments. Typically this is
|
||||
the name of the service or resource that is being rate-limited. This allows you to track rate limits
|
||||
for these resources independently. For example, if you have a service that is rate-limited by user:
|
||||
```python
|
||||
def request_function(user_ids):
|
||||
limiter.try_acquire(*user_ids)
|
||||
for user_id in user_ids:
|
||||
requests.get(f'https://example.com?user_id={user_id}')
|
||||
```
|
||||
|
||||
## Handling exceeded limits
|
||||
When a rate limit is exceeded, you have two options: raise an exception, or add delays.
|
||||
|
||||
### Bucket analogy
|
||||
<img height="300" align="right" src="https://upload.wikimedia.org/wikipedia/commons/c/c4/Leaky_bucket_analogy.JPG">
|
||||
|
||||
At this point it's useful to introduce the analogy of "buckets" used for rate-limiting. Here is a
|
||||
quick summary:
|
||||
|
||||
* This library implements the [Leaky Bucket algorithm](https://en.wikipedia.org/wiki/Leaky_bucket).
|
||||
* It is named after the idea of representing some kind of fixed capacity -- like a network or service -- as a bucket.
|
||||
* The bucket "leaks" at a constant rate. For web services, this represents the **ideal or permitted request rate**.
|
||||
* The bucket is "filled" at an intermittent, unpredicatble rate, representing the **actual rate of requests**.
|
||||
* When the bucket is "full", it will overflow, representing **canceled or delayed requests**.
|
||||
|
||||
### Rate limit exceptions
|
||||
By default, a `BucketFullException` will be raised when a rate limit is exceeded.
|
||||
The error contains a `meta_info` attribute with the following information:
|
||||
* `identity`: The identity it received
|
||||
* `rate`: The specific rate that has been exceeded
|
||||
* `remaining_time`: The remaining time until the next request can be sent
|
||||
|
||||
Here's an example that will raise an exception on the 4th request:
|
||||
```python
|
||||
from pyrate_limiter import (Duration, RequestRate,
|
||||
Limiter, BucketFullException)
|
||||
|
||||
rate = RequestRate(3, Duration.SECOND)
|
||||
limiter = Limiter(rate)
|
||||
|
||||
for _ in range(4):
|
||||
try:
|
||||
limiter.try_acquire('vutran')
|
||||
except BucketFullException as err:
|
||||
print(err)
|
||||
# Output: Bucket for vutran with Rate 3/1 is already full
|
||||
print(err.meta_info)
|
||||
# Output: {'identity': 'vutran', 'rate': '3/1', 'remaining_time': 2.9,
|
||||
# 'error': 'Bucket for vutran with Rate 3/1 is already full'}
|
||||
```
|
||||
|
||||
The rate part of the output is constructed as: `limit / interval`. On the above example, the limit
|
||||
is 3 and the interval is 1, hence the `Rate 3/1`.
|
||||
|
||||
### Rate limit delays
|
||||
You may want to simply slow down your requests to stay within the rate limits instead of canceling
|
||||
them. In that case you can use the `delay` argument. Note that this is only available for
|
||||
`Limiter.ratelimit()`:
|
||||
```python
|
||||
@limiter.ratelimit('identity', delay=True)
|
||||
def my_function():
|
||||
do_stuff()
|
||||
```
|
||||
|
||||
If you exceed a rate limit with a long interval (daily, monthly, etc.), you may not want to delay
|
||||
that long. In this case, you can set a `max_delay` (in seconds) that you are willing to wait in
|
||||
between calls:
|
||||
```python
|
||||
@limiter.ratelimit('identity', delay=True, max_delay=360)
|
||||
def my_function():
|
||||
do_stuff()
|
||||
```
|
||||
In this case, calls may be delayed by at most 360 seconds to stay within the rate limits; any longer
|
||||
than that, and a `BucketFullException` will be raised instead. Without specifying `max_delay`, calls
|
||||
will be delayed as long as necessary.
|
||||
|
||||
## Additional usage options
|
||||
Besides `Limiter.try_acquire()`, some additional usage options are available using `Limiter.ratelimit()`:
|
||||
### Decorator
|
||||
`Limiter.ratelimit()` can be used as a decorator:
|
||||
```python
|
||||
@limiter.ratelimit('identity')
|
||||
def my_function():
|
||||
do_stuff()
|
||||
```
|
||||
|
||||
As with `Limiter.try_acquire()`, if calls to the wrapped function exceed the rate limits you
|
||||
defined, a `BucketFullException` will be raised.
|
||||
|
||||
### Contextmanager
|
||||
`Limiter.ratelimit()` also works as a contextmanager:
|
||||
|
||||
```python
|
||||
def my_function():
|
||||
with limiter.ratelimit('identity', delay=True):
|
||||
do_stuff()
|
||||
```
|
||||
|
||||
### Async decorator/contextmanager
|
||||
`Limiter.ratelimit()` also support async functions, either as a decorator or contextmanager:
|
||||
```python
|
||||
@limiter.ratelimit('identity', delay=True)
|
||||
async def my_function():
|
||||
await do_stuff()
|
||||
|
||||
async def my_function():
|
||||
async with limiter.ratelimit('identity'):
|
||||
await do_stuff()
|
||||
```
|
||||
|
||||
When delays are enabled for an async function, `asyncio.sleep()` will be used instead of `time.sleep()`.
|
||||
|
||||
## Backends
|
||||
A few different bucket backends are available, which can be selected using the `bucket_class`
|
||||
argument for `Limiter`. Any additional backend-specific arguments can be passed
|
||||
via `bucket_kwargs`.
|
||||
|
||||
### Memory
|
||||
The default bucket is stored in memory, backed by a `queue.Queue`. A list implementation is also available:
|
||||
```python
|
||||
from pyrate_limiter import Limiter, MemoryListBucket
|
||||
|
||||
limiter = Limiter(bucket_class=MemoryListBucket)
|
||||
```
|
||||
|
||||
### SQLite
|
||||
If you need to persist the bucket state, a SQLite backend is available.
|
||||
|
||||
By default it will store the state in the system temp directory, and you can use
|
||||
the `path` argument to use a different location:
|
||||
```python
|
||||
from pyrate_limiter import Limiter, SQLiteBucket
|
||||
|
||||
limiter = Limiter(bucket_class=SQLiteBucket)
|
||||
```
|
||||
|
||||
By default, the database will be stored in the system temp directory. You can specify a different
|
||||
path via `bucket_kwargs`:
|
||||
```python
|
||||
limiter = Limiter(
|
||||
bucket_class=SQLiteBucket,
|
||||
bucket_kwargs={'path': '/path/to/db.sqlite'},
|
||||
)
|
||||
```
|
||||
|
||||
#### Concurrency
|
||||
This backend is thread-safe.
|
||||
|
||||
If you want to use SQLite with multiprocessing, some additional protections are needed. For
|
||||
these cases, a separate `FileLockSQLiteBucket` class is available. This requires installing the
|
||||
[py-filelock](https://py-filelock.readthedocs.io) library.
|
||||
```python
|
||||
limiter = Limiter(bucket_class=FileLockSQLiteBucket)
|
||||
```
|
||||
|
||||
### Redis
|
||||
If you have a larger, distributed application, Redis is an ideal backend. This
|
||||
option requires [redis-py](https://github.com/andymccurdy/redis-py).
|
||||
|
||||
Note that this backend requires a `bucket_name` argument, which will be used as a prefix for the
|
||||
Redis keys created. This can be used to disambiguate between multiple services using the same Redis
|
||||
instance with pyrate-limiter.
|
||||
|
||||
**Important**: you might want to consider adding `expire_time` for each buckets. In a scenario where some `identity` produces a request rate that is too sparsed, it is a good practice to expire the bucket which holds such identity's info to save memory.
|
||||
|
||||
```python
|
||||
from pyrate_limiter import Limiter, RedisBucket, Duration, RequestRate
|
||||
|
||||
rates = [
|
||||
RequestRate(5, 10 * Duration.SECOND),
|
||||
RequestRate(8, 20 * Duration.SECOND),
|
||||
]
|
||||
|
||||
limiter = Limiter(
|
||||
*rates
|
||||
bucket_class=RedisBucket,
|
||||
bucket_kwargs={
|
||||
'bucket_name':
|
||||
'my_service',
|
||||
'expire_time': rates[-1].interval,
|
||||
},
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
#### Connection settings
|
||||
If you need to pass additional connection settings, you can use the `redis_pool` bucket argument:
|
||||
```python
|
||||
from redis import ConnectionPool
|
||||
|
||||
redis_pool = ConnectionPool(host='localhost', port=6379, db=0)
|
||||
|
||||
rate = RequestRate(5, 10 * Duration.SECOND)
|
||||
|
||||
limiter = Limiter(
|
||||
rate,
|
||||
bucket_class=RedisBucket,
|
||||
bucket_kwargs={'redis_pool': redis_pool, 'bucket_name': 'my_service'},
|
||||
)
|
||||
```
|
||||
|
||||
#### Redis clusters
|
||||
Redis clusters are also supported, which requires
|
||||
[redis-py-cluster](https://github.com/Grokzen/redis-py-cluster):
|
||||
```python
|
||||
from pyrate_limiter import Limiter, RedisClusterBucket
|
||||
|
||||
limiter = Limiter(bucket_class=RedisClusterBucket)
|
||||
```
|
||||
|
||||
### Custom backends
|
||||
If these don't suit your needs, you can also create your own bucket backend by extending `pyrate_limiter.bucket.AbstractBucket`.
|
||||
|
||||
|
||||
## Additional features
|
||||
|
||||
### Time sources
|
||||
By default, monotonic time is used, to ensure requests are always logged in the correct order.
|
||||
|
||||
You can specify a custom time source with the `time_function` argument. For example, you may want to
|
||||
use the current UTC time for consistency across a distributed application using a Redis backend.
|
||||
```python
|
||||
from datetime import datetime
|
||||
from pyrate_limiter import Duration, Limiter, RequestRate
|
||||
|
||||
rate = RequestRate(5, Duration.SECOND)
|
||||
limiter_datetime = Limiter(rate, time_function=lambda: datetime.utcnow().timestamp())
|
||||
```
|
||||
|
||||
Or simply use the basic `time.time()` function:
|
||||
```python
|
||||
from time import time
|
||||
|
||||
rate = RequestRate(5, Duration.SECOND)
|
||||
limiter_time = Limiter(rate, time_function=time)
|
||||
```
|
||||
|
||||
## Examples
|
||||
To prove that pyrate-limiter is working as expected, here is a complete example to demonstrate
|
||||
rate-limiting with delays:
|
||||
```python
|
||||
from time import perf_counter as time
|
||||
from pyrate_limiter import Duration, Limiter, RequestRate
|
||||
|
||||
limiter = Limiter(RequestRate(5, Duration.SECOND))
|
||||
n_requests = 27
|
||||
|
||||
@limiter.ratelimit("test", delay=True)
|
||||
def limited_function(start_time):
|
||||
print(f"t + {(time() - start_time):.5f}")
|
||||
|
||||
start_time = time()
|
||||
for _ in range(n_requests):
|
||||
limited_function(start_time)
|
||||
|
||||
print(f"Ran {n_requests} requests in {time() - start_time:.5f} seconds")
|
||||
```
|
||||
|
||||
And an equivalent example for async usage:
|
||||
```python
|
||||
import asyncio
|
||||
from time import perf_counter as time
|
||||
from pyrate_limiter import Duration, Limiter, RequestRate
|
||||
|
||||
limiter = Limiter(RequestRate(5, Duration.SECOND))
|
||||
n_requests = 27
|
||||
|
||||
@limiter.ratelimit("test", delay=True)
|
||||
async def limited_function(start_time):
|
||||
print(f"t + {(time() - start_time):.5f}")
|
||||
|
||||
async def test_ratelimit():
|
||||
start_time = time()
|
||||
tasks = [limited_function(start_time) for _ in range(n_requests)]
|
||||
await asyncio.gather(*tasks)
|
||||
print(f"Ran {n_requests} requests in {time() - start_time:.5f} seconds")
|
||||
|
||||
asyncio.run(test_ratelimit())
|
||||
```
|
9
comictalker/vendor/pyrate_limiter/__init__.py
vendored
Normal file
9
comictalker/vendor/pyrate_limiter/__init__.py
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
# 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)
|
||||
else:
|
||||
break
|
||||
|
||||
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)
|
||||
|
||||
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
52
comictalker/vendor/pyrate_limiter/request_rate.py
vendored
Normal file
52
comictalker/vendor/pyrate_limiter/request_rate.py
vendored
Normal file
@ -0,0 +1,52 @@
|
||||
"""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}"
|
16
setup.cfg
16
setup.cfg
@ -134,6 +134,7 @@ description = run the tests with pytest
|
||||
package = wheel
|
||||
deps =
|
||||
pytest>=7
|
||||
gui,all: pytest-qt
|
||||
extras =
|
||||
7z: 7Z
|
||||
cbr: CBR
|
||||
@ -239,7 +240,7 @@ commands =
|
||||
description = Generate appimage executable
|
||||
skip_install = true
|
||||
platform = linux
|
||||
base = {env:tox_env:testenv}
|
||||
base = testenv
|
||||
labels =
|
||||
build
|
||||
depends =
|
||||
@ -297,6 +298,7 @@ per-file-ignores =
|
||||
build-tools/generate_settngs.py: T20
|
||||
build-tools/oidc-exchange.py: T20
|
||||
tests/*: L
|
||||
tests/pyqttoast_test.py: E402
|
||||
|
||||
[mypy]
|
||||
exclude = comictaggerlib/graphics/resources.py
|
||||
@ -319,5 +321,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
|
||||
|
||||
|
@ -15,8 +15,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")))
|
||||
|
||||
@ -32,7 +41,17 @@ 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)
|
||||
|
||||
@ -46,7 +65,17 @@ def test_get_issue_cover_match_score(
|
||||
expected: comictaggerlib.issueidentifier.Score,
|
||||
):
|
||||
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(
|
||||
primary_img_url=data[0],
|
||||
alt_urls=data[1],
|
||||
@ -58,7 +87,17 @@ def test_get_issue_cover_match_score(
|
||||
|
||||
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']})",
|
||||
@ -82,7 +121,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))
|
||||
|
1174
tests/pyqttoast_test.py
Normal file
1174
tests/pyqttoast_test.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user