Compare commits
59 Commits
1.6.0-alph
...
00200334fb
Author | SHA1 | Date | |
---|---|---|---|
00200334fb | |||
cde980b470 | |||
f90f373d20 | |||
c246b96845 | |||
053afaa75e | |||
3848aaeda3 | |||
16b13a6fe0 | |||
3f180612d3 | |||
37cc66cbae | |||
81b15a5877 | |||
14a4055040 | |||
2e01672e68 | |||
4a7aae4045 | |||
2187ddece8 | |||
fba5518d06 | |||
31cf687e2f | |||
526069dabf | |||
635cb037f1 | |||
861584df3a | |||
a53fda9fec | |||
af5a0e50e0 | |||
7a91acb60c | |||
3a287504ae | |||
82a22d25ea | |||
783e10a9a1 | |||
e8f13b1f9e | |||
4b415b376f | |||
122bdf7eb1 | |||
2afb604ab3 | |||
a912c7392b | |||
3b92993ef6 | |||
c3892082f5 | |||
92e2cb42e8 | |||
b8065e0f10 | |||
a395e5541f | |||
d191750231 | |||
e72347656b | |||
8e2411a086 | |||
97e64fa918 | |||
661d758315 | |||
364d870fe0 | |||
2da64fd52d | |||
057725c5da | |||
5996bd3588 | |||
fdf407898e | |||
70d544b7bd | |||
c583f63c8c | |||
d65a120eb5 | |||
60f47546c2 | |||
0b77078a93 | |||
2598fc546a | |||
ddf4407b77 | |||
6cf259191e | |||
30f1db1c73 | |||
4218e3558b | |||
271bfac834 | |||
9e86b5e331 | |||
c9638ba0d9 | |||
73738010b8 |
4
.github/workflows/build.yaml
vendored
4
.github/workflows/build.yaml
vendored
@ -48,7 +48,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
os: [ubuntu-latest, macos-10.15, windows-latest]
|
||||
os: [ubuntu-latest, macos-11, windows-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
- name: Install linux dependencies
|
||||
run: |
|
||||
sudo apt-get install pkg-config libicu-dev libqt5gui5 libfuse2
|
||||
sudo apt-get update && sudo apt-get upgrade && sudo apt-get install pkg-config libicu-dev libqt5gui5 libfuse2
|
||||
# export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
|
||||
# export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
|
||||
if: runner.os == 'Linux'
|
||||
|
4
.github/workflows/package.yaml
vendored
4
.github/workflows/package.yaml
vendored
@ -15,7 +15,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
os: [ubuntu-latest, macos-10.15, windows-latest]
|
||||
os: [ubuntu-latest, macos-11, windows-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
- name: Install linux dependencies
|
||||
run: |
|
||||
sudo apt-get install pkg-config libicu-dev libqt5gui5
|
||||
sudo apt-get update && sudo apt-get upgrade && sudo apt-get install pkg-config libicu-dev libqt5gui5 libfuse2
|
||||
# export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
|
||||
# export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
|
||||
if: runner.os == 'Linux'
|
||||
|
@ -41,6 +41,6 @@ repos:
|
||||
rev: v1.2.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-setuptools, types-requests]
|
||||
additional_dependencies: [types-setuptools, types-requests, settngs>=0.7.1]
|
||||
ci:
|
||||
skip: [mypy]
|
||||
|
202
LICENSE
Normal file
202
LICENSE
Normal file
@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
19
build-tools/generate_settngs.py
Normal file
19
build-tools/generate_settngs.py
Normal file
@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
import settngs
|
||||
|
||||
import comictaggerlib.main
|
||||
|
||||
|
||||
def generate() -> str:
|
||||
app = comictaggerlib.main.App()
|
||||
app.register_settings()
|
||||
return settngs.generate_ns(app.manager.definitions)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
src = generate()
|
||||
pathlib.Path("./comictaggerlib/ctsettings/settngs_namespace.py").write_text(src)
|
||||
print(src, end="")
|
@ -2,8 +2,6 @@ from __future__ import annotations
|
||||
|
||||
from comicapi.archivers.archiver import Archiver
|
||||
from comicapi.archivers.folder import FolderArchiver
|
||||
from comicapi.archivers.rar import RarArchiver
|
||||
from comicapi.archivers.sevenzip import SevenZipArchiver
|
||||
from comicapi.archivers.zip import ZipArchiver
|
||||
|
||||
|
||||
@ -12,4 +10,4 @@ class UnknownArchiver(Archiver):
|
||||
return "Unknown"
|
||||
|
||||
|
||||
__all__ = ["Archiver", "UnknownArchiver", "FolderArchiver", "RarArchiver", "ZipArchiver", "SevenZipArchiver"]
|
||||
__all__ = ["Archiver", "UnknownArchiver", "FolderArchiver", "ZipArchiver"]
|
||||
|
@ -22,8 +22,6 @@ import shutil
|
||||
import sys
|
||||
from typing import cast
|
||||
|
||||
import wordninja
|
||||
|
||||
from comicapi import filenamelexer, filenameparser, utils
|
||||
from comicapi.archivers import Archiver, UnknownArchiver, ZipArchiver
|
||||
from comicapi.comet import CoMet
|
||||
@ -31,28 +29,17 @@ from comicapi.comicbookinfo import ComicBookInfo
|
||||
from comicapi.comicinfoxml import ComicInfoXml
|
||||
from comicapi.genericmetadata import GenericMetadata, PageType
|
||||
|
||||
if sys.version_info < (3, 10):
|
||||
from importlib_metadata import entry_points
|
||||
else:
|
||||
from importlib.metadata import entry_points
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
pil_available = True
|
||||
except ImportError:
|
||||
pil_available = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if not pil_available:
|
||||
logger.error("PIL unavalable")
|
||||
|
||||
archivers: list[type[Archiver]] = []
|
||||
|
||||
|
||||
def load_archive_plugins() -> None:
|
||||
if not archivers:
|
||||
if sys.version_info < (3, 10):
|
||||
from importlib_metadata import entry_points
|
||||
else:
|
||||
from importlib.metadata import entry_points
|
||||
builtin: list[type[Archiver]] = []
|
||||
for arch in entry_points(group="comicapi.archiver"):
|
||||
try:
|
||||
@ -77,6 +64,7 @@ class MetaDataStyle:
|
||||
|
||||
class ComicArchive:
|
||||
logo_data = b""
|
||||
pil_available = True
|
||||
|
||||
def __init__(self, path: pathlib.Path | str, default_image_path: pathlib.Path | str | None = None) -> None:
|
||||
self.cbi_md: GenericMetadata | None = None
|
||||
@ -517,7 +505,13 @@ class ComicArchive:
|
||||
if calc_page_sizes:
|
||||
for index, p in enumerate(md.pages):
|
||||
idx = int(p["Image"])
|
||||
if pil_available:
|
||||
if self.pil_available:
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
self.pil_available = True
|
||||
except ImportError:
|
||||
self.pil_available = False
|
||||
if "ImageSize" not in p or "ImageHeight" not in p or "ImageWidth" not in p:
|
||||
data = self.get_page(idx)
|
||||
if data:
|
||||
@ -552,6 +546,8 @@ class ComicArchive:
|
||||
|
||||
filename = self.path.name
|
||||
if split_words:
|
||||
import wordninja
|
||||
|
||||
filename = " ".join(wordninja.split(self.path.stem)) + self.path.suffix
|
||||
|
||||
if complicated_parser:
|
||||
|
@ -22,7 +22,6 @@ from xml.etree.ElementTree import ElementTree
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi.genericmetadata import GenericMetadata, ImageMetadata
|
||||
from comicapi.issuestring import IssueString
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -189,11 +188,11 @@ class ComicInfoXml:
|
||||
|
||||
md.series = utils.xlate(get("Series"))
|
||||
md.title = utils.xlate(get("Title"))
|
||||
md.issue = IssueString(utils.xlate(get("Number"))).as_string()
|
||||
md.issue = utils.xlate(get("Number"))
|
||||
md.issue_count = utils.xlate_int(get("Count"))
|
||||
md.volume = utils.xlate_int(get("Volume"))
|
||||
md.alternate_series = utils.xlate(get("AlternateSeries"))
|
||||
md.alternate_number = IssueString(utils.xlate(get("AlternateNumber"))).as_string()
|
||||
md.alternate_number = utils.xlate(get("AlternateNumber"))
|
||||
md.alternate_count = utils.xlate_int(get("AlternateCount"))
|
||||
md.comments = utils.xlate(get("Summary"))
|
||||
md.notes = utils.xlate(get("Notes"))
|
||||
|
@ -25,10 +25,6 @@ from collections.abc import Iterable, Mapping
|
||||
from shutil import which # noqa: F401
|
||||
from typing import Any
|
||||
|
||||
import natsort
|
||||
import pycountry
|
||||
import rapidfuzz.fuzz
|
||||
|
||||
import comicapi.data
|
||||
|
||||
try:
|
||||
@ -43,6 +39,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _custom_key(tup):
|
||||
import natsort
|
||||
|
||||
lst = []
|
||||
for x in natsort.os_sort_keygen()(tup):
|
||||
ret = x
|
||||
@ -54,6 +52,8 @@ def _custom_key(tup):
|
||||
|
||||
|
||||
def os_sorted(lst: Iterable) -> Iterable:
|
||||
import natsort
|
||||
|
||||
key = _custom_key
|
||||
if icu_available or platform.system() == "Windows":
|
||||
key = natsort.os_sort_keygen()
|
||||
@ -115,6 +115,8 @@ def xlate_int(data: Any) -> int | None:
|
||||
|
||||
|
||||
def xlate_float(data: Any) -> float | None:
|
||||
if isinstance(data, str):
|
||||
data = data.strip()
|
||||
if data is None or data == "":
|
||||
return None
|
||||
i: str | int | float
|
||||
@ -131,7 +133,7 @@ def xlate_float(data: Any) -> float | None:
|
||||
|
||||
|
||||
def xlate(data: Any) -> str | None:
|
||||
if data is None or data == "":
|
||||
if data is None or isinstance(data, str) and data.strip() == "":
|
||||
return None
|
||||
|
||||
return str(data)
|
||||
@ -196,6 +198,8 @@ def sanitize_title(text: str, basic: bool = False) -> str:
|
||||
|
||||
|
||||
def titles_match(search_title: str, record_title: str, threshold: int = 90) -> bool:
|
||||
import rapidfuzz.fuzz
|
||||
|
||||
sanitized_search = sanitize_title(search_title)
|
||||
sanitized_record = sanitize_title(record_title)
|
||||
ratio = int(rapidfuzz.fuzz.ratio(sanitized_search, sanitized_record))
|
||||
@ -219,26 +223,41 @@ def unique_file(file_name: pathlib.Path) -> pathlib.Path:
|
||||
counter += 1
|
||||
|
||||
|
||||
languages: dict[str | None, str | None] = defaultdict(lambda: None)
|
||||
_languages: dict[str | None, str | None] = defaultdict(lambda: None)
|
||||
|
||||
countries: dict[str | None, str | None] = defaultdict(lambda: None)
|
||||
_countries: dict[str | None, str | None] = defaultdict(lambda: None)
|
||||
|
||||
for c in pycountry.countries:
|
||||
if "alpha_2" in c._fields:
|
||||
countries[c.alpha_2] = c.name
|
||||
|
||||
for lng in pycountry.languages:
|
||||
if "alpha_2" in lng._fields:
|
||||
languages[lng.alpha_2] = lng.name
|
||||
def countries() -> dict[str | None, str | None]:
|
||||
if not _countries:
|
||||
import pycountry
|
||||
|
||||
for c in pycountry.countries:
|
||||
if "alpha_2" in c._fields:
|
||||
_countries[c.alpha_2] = c.name
|
||||
return _countries
|
||||
|
||||
|
||||
def languages() -> dict[str | None, str | None]:
|
||||
if not _languages:
|
||||
import pycountry
|
||||
|
||||
for lng in pycountry.languages:
|
||||
if "alpha_2" in lng._fields:
|
||||
_languages[lng.alpha_2] = lng.name
|
||||
return _languages
|
||||
|
||||
|
||||
def get_language_from_iso(iso: str | None) -> str | None:
|
||||
return languages[iso]
|
||||
return languages()[iso]
|
||||
|
||||
|
||||
def get_language_iso(string: str | None) -> str | None:
|
||||
if string is None:
|
||||
return None
|
||||
import pycountry
|
||||
|
||||
# Return current string if all else fails
|
||||
lang = string.casefold()
|
||||
|
||||
try:
|
||||
@ -248,6 +267,10 @@ def get_language_iso(string: str | None) -> str | None:
|
||||
return lang
|
||||
|
||||
|
||||
def get_country_from_iso(iso: str | None) -> str | None:
|
||||
return countries()[iso]
|
||||
|
||||
|
||||
def get_publisher(publisher: str) -> tuple[str, str]:
|
||||
imprint = ""
|
||||
|
||||
@ -256,7 +279,7 @@ def get_publisher(publisher: str) -> tuple[str, str]:
|
||||
if ok:
|
||||
break
|
||||
|
||||
return (imprint, publisher)
|
||||
return imprint, publisher
|
||||
|
||||
|
||||
def update_publishers(new_publishers: Mapping[str, Mapping[str, str]]) -> None:
|
||||
@ -285,11 +308,11 @@ class ImprintDict(dict): # type: ignore
|
||||
def __getitem__(self, k: str) -> tuple[str, str, bool]:
|
||||
item = super().__getitem__(k.casefold())
|
||||
if k.casefold() == self.publisher.casefold():
|
||||
return ("", self.publisher, True)
|
||||
return "", self.publisher, True
|
||||
if item is None:
|
||||
return ("", k, False)
|
||||
return "", k, False
|
||||
else:
|
||||
return (item, self.publisher, True)
|
||||
return item, self.publisher, True
|
||||
|
||||
def copy(self) -> ImprintDict:
|
||||
return ImprintDict(self.publisher, super().copy())
|
||||
|
@ -19,12 +19,12 @@ import logging
|
||||
import os
|
||||
from typing import Callable
|
||||
|
||||
import settngs
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets, uic
|
||||
|
||||
from comicapi.comicarchive import MetaDataStyle
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comictaggerlib.coverimagewidget import CoverImageWidget
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.resulttypes import IssueResult, MultipleMatch
|
||||
from comictaggerlib.ui import ui_path
|
||||
from comictaggerlib.ui.qtutils import reduce_widget_font_size
|
||||
@ -40,7 +40,7 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
|
||||
match_set_list: list[MultipleMatch],
|
||||
style: int,
|
||||
fetch_func: Callable[[IssueResult], GenericMetadata],
|
||||
config: settngs.Namespace,
|
||||
config: ct_ns,
|
||||
talker: ComicTalker,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
|
@ -17,16 +17,16 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import settngs
|
||||
from PyQt5 import QtCore, QtWidgets, uic
|
||||
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.ui import ui_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AutoTagStartWindow(QtWidgets.QDialog):
|
||||
def __init__(self, parent: QtWidgets.QWidget, config: settngs.Namespace, msg: str) -> None:
|
||||
def __init__(self, parent: QtWidgets.QWidget, config: ct_ns, msg: str) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
uic.loadUi(ui_path / "autotagstartwindow.ui", self)
|
||||
@ -48,7 +48,6 @@ class AutoTagStartWindow(QtWidgets.QDialog):
|
||||
self.cbxAssumeIssueOne.setChecked(self.config.autotag_assume_1_if_no_issue_num)
|
||||
self.cbxIgnoreLeadingDigitsInFilename.setChecked(self.config.autotag_ignore_leading_numbers_in_filename)
|
||||
self.cbxRemoveAfterSuccess.setChecked(self.config.autotag_remove_archive_after_successful_match)
|
||||
self.cbxWaitForRateLimit.setChecked(self.config.autotag_wait_and_retry_on_rate_limit)
|
||||
self.cbxAutoImprint.setChecked(self.config.identifier_auto_imprint)
|
||||
|
||||
nlmt_tip = """<html>The <b>Name Match Ratio Threshold: Auto-Identify</b> is for eliminating automatic
|
||||
@ -73,7 +72,6 @@ class AutoTagStartWindow(QtWidgets.QDialog):
|
||||
self.assume_issue_one = False
|
||||
self.ignore_leading_digits_in_filename = False
|
||||
self.remove_after_success = False
|
||||
self.wait_and_retry_on_rate_limit = False
|
||||
self.search_string = ""
|
||||
self.name_length_match_tolerance = self.config.identifier_series_match_search_thresh
|
||||
self.split_words = self.cbxSplitWords.isChecked()
|
||||
@ -91,7 +89,6 @@ class AutoTagStartWindow(QtWidgets.QDialog):
|
||||
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.wait_and_retry_on_rate_limit = self.cbxWaitForRateLimit.isChecked()
|
||||
self.split_words = self.cbxSplitWords.isChecked()
|
||||
|
||||
# persist some settings
|
||||
@ -100,7 +97,6 @@ class AutoTagStartWindow(QtWidgets.QDialog):
|
||||
self.config.autotag_assume_1_if_no_issue_num = self.assume_issue_one
|
||||
self.config.autotag_ignore_leading_numbers_in_filename = self.ignore_leading_digits_in_filename
|
||||
self.config.autotag_remove_archive_after_successful_match = self.remove_after_success
|
||||
self.config.autotag_wait_and_retry_on_rate_limit = self.wait_and_retry_on_rate_limit
|
||||
|
||||
if self.cbxSpecifySearchString.isChecked():
|
||||
self.search_string = self.leSearchString.text()
|
||||
|
@ -17,15 +17,14 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import settngs
|
||||
|
||||
from comicapi.genericmetadata import CreditMetadata, GenericMetadata
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CBLTransformer:
|
||||
def __init__(self, metadata: GenericMetadata, config: settngs.Namespace) -> None:
|
||||
def __init__(self, metadata: GenericMetadata, config: ct_ns) -> None:
|
||||
self.metadata = metadata
|
||||
self.config = config
|
||||
|
||||
|
@ -23,13 +23,12 @@ import sys
|
||||
from datetime import datetime
|
||||
from pprint import pprint
|
||||
|
||||
import settngs
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi.comicarchive import ComicArchive, MetaDataStyle
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comictaggerlib import ctversion
|
||||
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
|
||||
@ -40,7 +39,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CLI:
|
||||
def __init__(self, config: settngs.Namespace, talkers: dict[str, ComicTalker]) -> None:
|
||||
def __init__(self, config: ct_ns, talkers: dict[str, ComicTalker]) -> None:
|
||||
self.config = config
|
||||
self.talkers = talkers
|
||||
self.batch_mode = False
|
||||
@ -118,7 +117,7 @@ class CLI:
|
||||
md = ct_md
|
||||
else:
|
||||
notes = (
|
||||
f"Tagged with ComicTagger {ctversion.version} using info from Comic Vine on"
|
||||
f"Tagged with ComicTagger {ctversion.version} using info from {self.current_talker().name} on"
|
||||
f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
|
||||
)
|
||||
md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger")))
|
||||
@ -171,19 +170,21 @@ class CLI:
|
||||
self.display_match_set_for_choice(label, match_set)
|
||||
|
||||
def run(self) -> None:
|
||||
if len(self.config.runtime_file_list) < 1:
|
||||
if len(self.config.runtime_files) < 1:
|
||||
logger.error("You must specify at least one filename. Use the -h option for more info")
|
||||
return
|
||||
|
||||
match_results = OnlineMatchResults()
|
||||
self.batch_mode = len(self.config.runtime_file_list) > 1
|
||||
self.batch_mode = len(self.config.runtime_files) > 1
|
||||
|
||||
for f in self.config.runtime_file_list:
|
||||
for f in self.config.runtime_files:
|
||||
self.process_file_cli(f, match_results)
|
||||
sys.stdout.flush()
|
||||
|
||||
self.post_process_matches(match_results)
|
||||
|
||||
print(f"\nFiles tagged with metadata provided by {self.current_talker().name} {self.current_talker().website}")
|
||||
|
||||
def create_local_metadata(self, ca: ComicArchive) -> GenericMetadata:
|
||||
md = GenericMetadata()
|
||||
md.set_default_page_list(ca.get_number_of_pages())
|
||||
@ -316,7 +317,7 @@ class CLI:
|
||||
md = GenericMetadata()
|
||||
logger.error("Failed to load metadata for %s: %s", ca.path, e)
|
||||
|
||||
if self.config.apply_transform_on_bulk_operation_ndetadata_style == MetaDataStyle.CBI:
|
||||
if self.config.cbl_apply_transform_on_bulk_operation == MetaDataStyle.CBI:
|
||||
md = CBLTransformer(md, self.config).apply()
|
||||
|
||||
if not ca.write_metadata(md, metadata_style):
|
||||
@ -340,7 +341,7 @@ class CLI:
|
||||
|
||||
md = self.create_local_metadata(ca)
|
||||
if md.issue is None or md.issue == "":
|
||||
if self.config.runtime_assume_issue_one:
|
||||
if self.config.autotag_assume_1_if_no_issue_num:
|
||||
md.issue = "1"
|
||||
|
||||
# now, search online
|
||||
@ -428,13 +429,13 @@ class CLI:
|
||||
return
|
||||
|
||||
if self.config.identifier_clear_metadata_on_import:
|
||||
md = ct_md
|
||||
else:
|
||||
notes = (
|
||||
f"Tagged with ComicTagger {ctversion.version} using info from Comic Vine on"
|
||||
f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
|
||||
)
|
||||
md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger")))
|
||||
md = GenericMetadata()
|
||||
|
||||
notes = (
|
||||
f"Tagged with ComicTagger {ctversion.version} using info from {self.current_talker().name} on"
|
||||
f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
|
||||
)
|
||||
md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger")))
|
||||
|
||||
if self.config.identifier_auto_imprint:
|
||||
md.fix_publisher()
|
||||
@ -486,6 +487,7 @@ class CLI:
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("Formatter failure: %s metadata: %s", self.config.rename_template, renamer.metadata)
|
||||
return
|
||||
|
||||
folder = get_rename_dir(ca, self.config.rename_dir if self.config.rename_move_to_dir else None)
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
"""A PyQt5 widget to display cover images
|
||||
|
||||
Display cover images from either a local archive, or from Comic Vine.
|
||||
Display cover images from either a local archive, or from comic source metadata.
|
||||
TODO: This should be re-factored using subclasses!
|
||||
"""
|
||||
#
|
||||
|
@ -7,6 +7,7 @@ from comictaggerlib.ctsettings.commandline import (
|
||||
)
|
||||
from comictaggerlib.ctsettings.file import register_file_settings, validate_file_settings
|
||||
from comictaggerlib.ctsettings.plugin import register_plugin_settings, validate_plugin_settings
|
||||
from comictaggerlib.ctsettings.settngs_namespace import settngs_namespace as ct_ns
|
||||
from comictaggerlib.ctsettings.types import ComicTaggerPaths
|
||||
from comictalker import ComicTalker
|
||||
|
||||
@ -21,4 +22,5 @@ __all__ = [
|
||||
"validate_file_settings",
|
||||
"validate_plugin_settings",
|
||||
"ComicTaggerPaths",
|
||||
"ct_ns",
|
||||
]
|
||||
|
@ -25,14 +25,20 @@ import settngs
|
||||
from comicapi import utils
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comictaggerlib import ctversion
|
||||
from comictaggerlib.ctsettings.types import ComicTaggerPaths, metadata_type, parse_metadata_from_string
|
||||
from comictaggerlib.ctsettings.settngs_namespace import settngs_namespace as ct_ns
|
||||
from comictaggerlib.ctsettings.types import (
|
||||
ComicTaggerPaths,
|
||||
metadata_type,
|
||||
metadata_type_single,
|
||||
parse_metadata_from_string,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def initial_commandline_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
# Ensure this stays up to date with register_settings
|
||||
# Ensure this stays up to date with register_runtime
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
help="Config directory defaults to ~/.ComicTagger\non Linux/Mac and %%APPDATA%% on Windows\n",
|
||||
@ -43,7 +49,7 @@ def initial_commandline_parser() -> argparse.ArgumentParser:
|
||||
return parser
|
||||
|
||||
|
||||
def register_settings(parser: settngs.Manager) -> None:
|
||||
def register_runtime(parser: settngs.Manager) -> None:
|
||||
parser.add_setting(
|
||||
"--config",
|
||||
help="Config directory defaults to ~/.Config/ComicTagger\non Linux, ~/Library/Application Support/ComicTagger on Mac and %%APPDATA%%\\ComicTagger on Windows\n",
|
||||
@ -83,7 +89,7 @@ def register_settings(parser: settngs.Manager) -> None:
|
||||
parser.add_setting(
|
||||
"--id",
|
||||
dest="issue_id",
|
||||
type=int,
|
||||
type=str,
|
||||
help="""Use the issue ID when searching online.\nOverrides all other metadata.\n\n""",
|
||||
file=False,
|
||||
)
|
||||
@ -177,6 +183,7 @@ def register_settings(parser: settngs.Manager) -> None:
|
||||
help="""Apply metadata to already tagged archives (relevant for -s or -c).""",
|
||||
file=False,
|
||||
)
|
||||
parser.add_setting("--no-gui", action="store_true", help="Do not open the GUI, force the commandline", file=False)
|
||||
parser.add_setting("files", nargs="*", file=False)
|
||||
|
||||
|
||||
@ -200,7 +207,7 @@ def register_commands(parser: settngs.Manager) -> None:
|
||||
parser.add_setting(
|
||||
"-c",
|
||||
"--copy",
|
||||
type=metadata_type,
|
||||
type=metadata_type_single,
|
||||
metavar="{CR,CBL,COMET}",
|
||||
help="Copy the specified source tag block to\ndestination style specified via -t\n(potentially lossy operation).\n\n",
|
||||
file=False,
|
||||
@ -236,12 +243,10 @@ def register_commands(parser: settngs.Manager) -> None:
|
||||
|
||||
def register_commandline_settings(parser: settngs.Manager) -> None:
|
||||
parser.add_group("commands", register_commands, True)
|
||||
parser.add_persistent_group("runtime", register_settings)
|
||||
parser.add_persistent_group("runtime", register_runtime)
|
||||
|
||||
|
||||
def validate_commandline_settings(
|
||||
config: settngs.Config[settngs.Namespace], parser: settngs.Manager
|
||||
) -> settngs.Config[settngs.Namespace]:
|
||||
def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs.Manager) -> settngs.Config[ct_ns]:
|
||||
if config[0].commands_version:
|
||||
parser.exit(
|
||||
status=1,
|
||||
@ -258,6 +263,7 @@ def validate_commandline_settings(
|
||||
config[0].commands_rename,
|
||||
config[0].commands_export_to_zip,
|
||||
config[0].commands_only_set_cv_key,
|
||||
config[0].runtime_no_gui,
|
||||
]
|
||||
)
|
||||
|
||||
@ -282,14 +288,9 @@ def validate_commandline_settings(
|
||||
if config[0].commands_copy:
|
||||
if not config[0].runtime_type:
|
||||
parser.exit(message="Please specify the type to copy to with -t\n", status=1)
|
||||
if len(config[0].commands_copy) > 1:
|
||||
parser.exit(message="Please specify only one type to copy to with -c\n", status=1)
|
||||
config[0].commands_copy = config[0].commands_copy[0]
|
||||
|
||||
if config[0].runtime_recursive:
|
||||
config[0].runtime_file_list = utils.get_recursive_filelist(config[0].runtime_files)
|
||||
else:
|
||||
config[0].runtime_file_list = config[0].runtime_files
|
||||
config[0].runtime_files = utils.get_recursive_filelist(config[0].runtime_files)
|
||||
|
||||
# take a crack at finding rar exe if it's not in the path
|
||||
if not utils.which("rar"):
|
||||
|
@ -5,7 +5,7 @@ import uuid
|
||||
|
||||
import settngs
|
||||
|
||||
from comictaggerlib.ctsettings.types import AppendAction
|
||||
from comictaggerlib.ctsettings.settngs_namespace import settngs_namespace as ct_ns
|
||||
from comictaggerlib.defaults import DEFAULT_REPLACEMENTS, Replacement, Replacements
|
||||
|
||||
|
||||
@ -43,8 +43,9 @@ def identifier(parser: settngs.Manager) -> None:
|
||||
parser.add_setting(
|
||||
"--publisher-filter",
|
||||
default=["Panini Comics", "Abril", "Planeta DeAgostini", "Editorial Televisa", "Dino Comics"],
|
||||
action=AppendAction,
|
||||
help="When enabled filters the listed publishers from all search results",
|
||||
action="extend",
|
||||
nargs="+",
|
||||
help="When enabled, filters the listed publishers from all search results. Ending a publisher with a '-' removes a publisher from this list",
|
||||
)
|
||||
parser.add_setting("--series-match-search-thresh", default=90, type=int)
|
||||
parser.add_setting(
|
||||
@ -206,18 +207,24 @@ def autotag(parser: settngs.Manager) -> None:
|
||||
help="When searching ignore leading numbers in the filename",
|
||||
)
|
||||
parser.add_setting("remove_archive_after_successful_match", default=False, cmdline=False)
|
||||
parser.add_setting(
|
||||
"-w",
|
||||
"--wait-on-rate-limit",
|
||||
dest="wait_and_retry_on_rate_limit",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="When encountering a Comic Vine rate limit\nerror, wait and retry query.\n\n",
|
||||
)
|
||||
|
||||
|
||||
def validate_file_settings(config: settngs.Config[settngs.Namespace]) -> settngs.Config[settngs.Namespace]:
|
||||
config[0].identifier_publisher_filter = [x.strip() for x in config[0].identifier_publisher_filter if x.strip()]
|
||||
def validate_file_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
|
||||
new_filter = []
|
||||
remove = []
|
||||
for x in config[0].identifier_publisher_filter:
|
||||
x = x.strip()
|
||||
if x: # ignore empty arguments
|
||||
if x[-1] == "-": # this publisher needs to be removed. We remove after all publishers have been enumerated
|
||||
remove.append(x.strip("-"))
|
||||
else:
|
||||
if x not in new_filter:
|
||||
new_filter.append(x)
|
||||
for x in remove: # remove publishers
|
||||
if x in new_filter:
|
||||
new_filter.remove(x)
|
||||
config[0].identifier_publisher_filter = new_filter
|
||||
|
||||
config[0].rename_replacements = Replacements(
|
||||
[Replacement(x[0], x[1], x[2]) for x in config[0].rename_replacements[0]],
|
||||
[Replacement(x[0], x[1], x[2]) for x in config[0].rename_replacements[1]],
|
||||
|
@ -2,11 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import cast
|
||||
|
||||
import settngs
|
||||
|
||||
import comicapi.comicarchive
|
||||
import comictaggerlib.ctsettings
|
||||
from comictaggerlib.ctsettings.settngs_namespace import settngs_namespace as ct_ns
|
||||
|
||||
logger = logging.getLogger("comictagger")
|
||||
|
||||
@ -27,15 +29,15 @@ def register_talker_settings(manager: settngs.Manager) -> None:
|
||||
for talker_id, talker in comictaggerlib.ctsettings.talkers.items():
|
||||
|
||||
def api_options(manager: settngs.Manager) -> None:
|
||||
# The default needs to be unset or None.
|
||||
# This allows this setting to be unset with the empty string, allowing the default to change
|
||||
manager.add_setting(
|
||||
f"--{talker_id}-key",
|
||||
default="",
|
||||
display_name="API Key",
|
||||
help=f"API Key for {talker.name} (default: {talker.default_api_key})",
|
||||
)
|
||||
manager.add_setting(
|
||||
f"--{talker_id}-url",
|
||||
default="",
|
||||
display_name="URL",
|
||||
help=f"URL for {talker.name} (default: {talker.default_api_url})",
|
||||
)
|
||||
@ -47,10 +49,10 @@ def register_talker_settings(manager: settngs.Manager) -> None:
|
||||
logger.exception("Failed to register settings for %s", talker_id)
|
||||
|
||||
|
||||
def validate_archive_settings(config: settngs.Config[settngs.Namespace]) -> settngs.Config[settngs.Namespace]:
|
||||
def validate_archive_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
|
||||
if "archiver" not in config[1]:
|
||||
return config
|
||||
cfg = settngs.normalize_config(config, file=True, cmdline=True, defaults=False)
|
||||
cfg = settngs.normalize_config(config, file=True, cmdline=True, default=False)
|
||||
for archiver in comicapi.comicarchive.archivers:
|
||||
exe_name = settngs.sanitize_name(archiver.exe)
|
||||
if exe_name in cfg[0]["archiver"] and cfg[0]["archiver"][exe_name]:
|
||||
@ -62,7 +64,7 @@ def validate_archive_settings(config: settngs.Config[settngs.Namespace]) -> sett
|
||||
return config
|
||||
|
||||
|
||||
def validate_talker_settings(config: settngs.Config[settngs.Namespace]) -> settngs.Config[settngs.Namespace]:
|
||||
def validate_talker_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
|
||||
# Apply talker settings from config file
|
||||
cfg = settngs.normalize_config(config, True, True)
|
||||
for talker_id, talker in list(comictaggerlib.ctsettings.talkers.items()):
|
||||
@ -73,10 +75,10 @@ def validate_talker_settings(config: settngs.Config[settngs.Namespace]) -> settn
|
||||
del comictaggerlib.ctsettings.talkers[talker_id]
|
||||
logger.exception("Failed to initialize talker settings: %s", e)
|
||||
|
||||
return settngs.get_namespace(cfg)
|
||||
return cast(settngs.Config[ct_ns], settngs.get_namespace(cfg, file=True, cmdline=True))
|
||||
|
||||
|
||||
def validate_plugin_settings(config: settngs.Config[settngs.Namespace]) -> settngs.Config[settngs.Namespace]:
|
||||
def validate_plugin_settings(config: settngs.Config[ct_ns]) -> settngs.Config[ct_ns]:
|
||||
config = validate_archive_settings(config)
|
||||
config = validate_talker_settings(config)
|
||||
return config
|
||||
|
104
comictaggerlib/ctsettings/settngs_namespace.py
Normal file
104
comictaggerlib/ctsettings/settngs_namespace.py
Normal file
@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import settngs
|
||||
|
||||
import comicapi.genericmetadata
|
||||
import comictaggerlib.ctsettings.types
|
||||
import comictaggerlib.defaults
|
||||
|
||||
|
||||
class settngs_namespace(settngs.TypedNS):
|
||||
commands_version: bool
|
||||
commands_print: bool
|
||||
commands_delete: bool
|
||||
commands_copy: int
|
||||
commands_save: bool
|
||||
commands_rename: bool
|
||||
commands_export_to_zip: bool
|
||||
commands_only_set_cv_key: bool
|
||||
|
||||
runtime_config: comictaggerlib.ctsettings.types.ComicTaggerPaths
|
||||
runtime_verbose: int
|
||||
runtime_abort_on_conflict: bool
|
||||
runtime_delete_after_zip_export: bool
|
||||
runtime_parse_filename: bool
|
||||
runtime_issue_id: str
|
||||
runtime_online: bool
|
||||
runtime_metadata: comicapi.genericmetadata.GenericMetadata
|
||||
runtime_interactive: bool
|
||||
runtime_abort_on_low_confidence: bool
|
||||
runtime_summary: bool
|
||||
runtime_raw: bool
|
||||
runtime_recursive: bool
|
||||
runtime_script: str
|
||||
runtime_split_words: bool
|
||||
runtime_dryrun: bool
|
||||
runtime_darkmode: bool
|
||||
runtime_glob: bool
|
||||
runtime_quiet: bool
|
||||
runtime_type: list[int]
|
||||
runtime_overwrite: bool
|
||||
runtime_no_gui: bool
|
||||
runtime_files: list[str]
|
||||
|
||||
general_check_for_new_version: bool
|
||||
|
||||
internal_install_id: str
|
||||
internal_save_data_style: int
|
||||
internal_load_data_style: int
|
||||
internal_last_opened_folder: str
|
||||
internal_window_width: int
|
||||
internal_window_height: int
|
||||
internal_window_x: int
|
||||
internal_window_y: int
|
||||
internal_form_width: int
|
||||
internal_list_width: int
|
||||
internal_sort_column: int
|
||||
internal_sort_direction: int
|
||||
|
||||
identifier_series_match_identify_thresh: int
|
||||
identifier_border_crop_percent: int
|
||||
identifier_publisher_filter: list[str]
|
||||
identifier_series_match_search_thresh: int
|
||||
identifier_clear_metadata_on_import: bool
|
||||
identifier_auto_imprint: bool
|
||||
identifier_sort_series_by_year: bool
|
||||
identifier_exact_series_matches_first: bool
|
||||
identifier_always_use_publisher_filter: bool
|
||||
identifier_clear_form_before_populating: bool
|
||||
|
||||
dialog_show_disclaimer: bool
|
||||
dialog_dont_notify_about_this_version: str
|
||||
dialog_ask_about_usage_stats: bool
|
||||
|
||||
filename_complicated_parser: bool
|
||||
filename_remove_c2c: bool
|
||||
filename_remove_fcbd: bool
|
||||
filename_remove_publisher: bool
|
||||
|
||||
talker_source: str
|
||||
|
||||
cbl_assume_lone_credit_is_primary: bool
|
||||
cbl_copy_characters_to_tags: bool
|
||||
cbl_copy_teams_to_tags: bool
|
||||
cbl_copy_locations_to_tags: bool
|
||||
cbl_copy_storyarcs_to_tags: bool
|
||||
cbl_copy_notes_to_comments: bool
|
||||
cbl_copy_weblink_to_comments: bool
|
||||
cbl_apply_transform_on_import: bool
|
||||
cbl_apply_transform_on_bulk_operation: bool
|
||||
|
||||
rename_template: str
|
||||
rename_issue_number_padding: int
|
||||
rename_use_smart_string_cleanup: bool
|
||||
rename_set_extension_based_on_archive: bool
|
||||
rename_dir: str
|
||||
rename_move_to_dir: bool
|
||||
rename_strict: bool
|
||||
rename_replacements: comictaggerlib.defaults.Replacements
|
||||
|
||||
autotag_save_on_low_confidence: bool
|
||||
autotag_dont_use_year_when_identifying: bool
|
||||
autotag_assume_1_if_no_issue_num: bool
|
||||
autotag_ignore_leading_numbers_in_filename: bool
|
||||
autotag_remove_archive_after_successful_match: bool
|
@ -2,8 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Callable
|
||||
|
||||
from appdirs import AppDirs
|
||||
|
||||
@ -59,6 +57,13 @@ class ComicTaggerPaths(AppDirs):
|
||||
return pathlib.Path(super().site_config_dir)
|
||||
|
||||
|
||||
def metadata_type_single(types: str) -> int:
|
||||
result = metadata_type(types)
|
||||
if len(result) > 1:
|
||||
raise argparse.ArgumentTypeError(f"invalid choice: {result} (only one metadata style allowed)")
|
||||
return result[0]
|
||||
|
||||
|
||||
def metadata_type(types: str) -> list[int]:
|
||||
result = []
|
||||
types = types.casefold()
|
||||
@ -71,71 +76,6 @@ def metadata_type(types: str) -> list[int]:
|
||||
return result
|
||||
|
||||
|
||||
def _copy_items(items: Sequence[Any] | None) -> Sequence[Any]:
|
||||
if items is None:
|
||||
return []
|
||||
# The copy module is used only in the 'append' and 'append_const'
|
||||
# actions, and it is needed only when the default value isn't a list.
|
||||
# Delay its import for speeding up the common case.
|
||||
if type(items) is list:
|
||||
return items[:]
|
||||
import copy
|
||||
|
||||
return copy.copy(items)
|
||||
|
||||
|
||||
class AppendAction(argparse.Action):
|
||||
def __init__(
|
||||
self,
|
||||
option_strings: list[str],
|
||||
dest: str,
|
||||
nargs: str | None = None,
|
||||
const: Any = None,
|
||||
default: Any = None,
|
||||
type: Callable[[str], Any] | None = None, # noqa: A002
|
||||
choices: list[Any] | None = None,
|
||||
required: bool = False,
|
||||
help: str | None = None, # noqa: A002
|
||||
metavar: str | None = None,
|
||||
):
|
||||
self.called = False
|
||||
if nargs == 0:
|
||||
raise ValueError(
|
||||
"nargs for append actions must be != 0; if arg "
|
||||
"strings are not supplying the value to append, "
|
||||
"the append const action may be more appropriate"
|
||||
)
|
||||
if const is not None and nargs != argparse.OPTIONAL:
|
||||
raise ValueError("nargs must be %r to supply const" % argparse.OPTIONAL)
|
||||
super().__init__(
|
||||
option_strings=option_strings,
|
||||
dest=dest,
|
||||
nargs=nargs,
|
||||
const=const,
|
||||
default=default,
|
||||
type=type,
|
||||
choices=choices,
|
||||
required=required,
|
||||
help=help,
|
||||
metavar=metavar,
|
||||
)
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
parser: argparse.ArgumentParser,
|
||||
namespace: argparse.Namespace,
|
||||
values: str | Sequence[Any] | None,
|
||||
option_string: str | None = None,
|
||||
) -> None:
|
||||
if values:
|
||||
if not self.called:
|
||||
setattr(namespace, self.dest, [])
|
||||
items = getattr(namespace, self.dest, None)
|
||||
items = _copy_items(items)
|
||||
items.append(values) # type: ignore
|
||||
setattr(namespace, self.dest, items)
|
||||
|
||||
|
||||
def parse_metadata_from_string(mdstr: str) -> GenericMetadata:
|
||||
"""The metadata string is a comma separated list of name-value pairs
|
||||
The names match the attributes of the internal metadata struct (for now)
|
||||
|
@ -209,15 +209,13 @@ class FileRenamer:
|
||||
|
||||
md = self.metadata
|
||||
|
||||
# padding for issue
|
||||
md.issue = IssueString(md.issue).as_string(pad=self.issue_zero_padding)
|
||||
|
||||
template = self.template
|
||||
|
||||
new_name = ""
|
||||
|
||||
fmt = MetadataFormatter(self.smart_cleanup, platform=self.platform, replacements=self.replacements)
|
||||
md_dict = vars(md)
|
||||
md_dict["issue"] = IssueString(md.issue).as_string(pad=self.issue_zero_padding)
|
||||
for role in ["writer", "penciller", "inker", "colorist", "letterer", "cover artist", "editor"]:
|
||||
md_dict[role] = md.get_primary_credit(role)
|
||||
|
||||
@ -238,8 +236,6 @@ class FileRenamer:
|
||||
new_name += ext
|
||||
new_basename += ext
|
||||
|
||||
# remove padding
|
||||
md.issue = IssueString(md.issue).as_string()
|
||||
if self.move:
|
||||
return new_name.strip()
|
||||
return new_basename.strip()
|
||||
|
@ -20,11 +20,11 @@ import os
|
||||
import platform
|
||||
from typing import Callable, cast
|
||||
|
||||
import settngs
|
||||
from PyQt5 import QtCore, QtWidgets, uic
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi.comicarchive import ComicArchive
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.graphics import graphics_path
|
||||
from comictaggerlib.optionalmsgdialog import OptionalMessageDialog
|
||||
from comictaggerlib.settingswindow import linuxRarHelp, macRarHelp, windowsRarHelp
|
||||
@ -57,7 +57,7 @@ class FileSelectionList(QtWidgets.QWidget):
|
||||
dataColNum = fileColNum
|
||||
|
||||
def __init__(
|
||||
self, parent: QtWidgets.QWidget, config: settngs.Namespace, dirty_flag_verification: Callable[[str, str], bool]
|
||||
self, parent: QtWidgets.QWidget, config: ct_ns, dirty_flag_verification: Callable[[str, str], bool]
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
102
comictaggerlib/graphics/eye.svg
Normal file
102
comictaggerlib/graphics/eye.svg
Normal file
@ -0,0 +1,102 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Capa_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 469.333 469.333"
|
||||
style="enable-background:new 0 0 469.333 469.333;"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="eye.svg"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs45" /><sodipodi:namedview
|
||||
id="namedview43"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
showgrid="false"
|
||||
inkscape:zoom="2.1882117"
|
||||
inkscape:cx="234.6665"
|
||||
inkscape:cy="234.6665"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1361"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="42"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="Capa_1" />
|
||||
<g
|
||||
id="g10"
|
||||
style="fill:#333333">
|
||||
<g
|
||||
id="g8"
|
||||
style="fill:#333333">
|
||||
<g
|
||||
id="g6"
|
||||
style="fill:#333333">
|
||||
<path
|
||||
d="M234.667,170.667c-35.307,0-64,28.693-64,64s28.693,64,64,64s64-28.693,64-64S269.973,170.667,234.667,170.667z"
|
||||
id="path2"
|
||||
style="fill:#333333" />
|
||||
<path
|
||||
d="M234.667,74.667C128,74.667,36.907,141.013,0,234.667c36.907,93.653,128,160,234.667,160 c106.773,0,197.76-66.347,234.667-160C432.427,141.013,341.44,74.667,234.667,74.667z M234.667,341.333 c-58.88,0-106.667-47.787-106.667-106.667S175.787,128,234.667,128s106.667,47.787,106.667,106.667 S293.547,341.333,234.667,341.333z"
|
||||
id="path4"
|
||||
style="fill:#333333" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
id="g12">
|
||||
</g>
|
||||
<g
|
||||
id="g14">
|
||||
</g>
|
||||
<g
|
||||
id="g16">
|
||||
</g>
|
||||
<g
|
||||
id="g18">
|
||||
</g>
|
||||
<g
|
||||
id="g20">
|
||||
</g>
|
||||
<g
|
||||
id="g22">
|
||||
</g>
|
||||
<g
|
||||
id="g24">
|
||||
</g>
|
||||
<g
|
||||
id="g26">
|
||||
</g>
|
||||
<g
|
||||
id="g28">
|
||||
</g>
|
||||
<g
|
||||
id="g30">
|
||||
</g>
|
||||
<g
|
||||
id="g32">
|
||||
</g>
|
||||
<g
|
||||
id="g34">
|
||||
</g>
|
||||
<g
|
||||
id="g36">
|
||||
</g>
|
||||
<g
|
||||
id="g38">
|
||||
</g>
|
||||
<g
|
||||
id="g40">
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.1 KiB |
106
comictaggerlib/graphics/hidden.svg
Normal file
106
comictaggerlib/graphics/hidden.svg
Normal file
@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Capa_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 469.44 469.44"
|
||||
style="enable-background:new 0 0 469.44 469.44;"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="hidden.svg"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs47" /><sodipodi:namedview
|
||||
id="namedview45"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
showgrid="false"
|
||||
inkscape:zoom="2.187713"
|
||||
inkscape:cx="234.72"
|
||||
inkscape:cy="234.72"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1361"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="42"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="Capa_1" />
|
||||
<g
|
||||
id="g12"
|
||||
style="fill:#333333">
|
||||
<g
|
||||
id="g10"
|
||||
style="fill:#333333">
|
||||
<g
|
||||
id="g8"
|
||||
style="fill:#333333">
|
||||
<path
|
||||
d="M231.147,160.373l67.2,67.2l0.32-3.52c0-35.307-28.693-64-64-64L231.147,160.373z"
|
||||
id="path2"
|
||||
style="fill:#333333" />
|
||||
<path
|
||||
d="M234.667,117.387c58.88,0,106.667,47.787,106.667,106.667c0,13.76-2.773,26.88-7.573,38.933l62.4,62.4 c32.213-26.88,57.6-61.653,73.28-101.333c-37.013-93.653-128-160-234.773-160c-29.867,0-58.453,5.333-85.013,14.933l46.08,45.973 C207.787,120.267,220.907,117.387,234.667,117.387z"
|
||||
id="path4"
|
||||
style="fill:#333333" />
|
||||
<path
|
||||
d="M21.333,59.253l48.64,48.64l9.707,9.707C44.48,145.12,16.64,181.707,0,224.053c36.907,93.653,128,160,234.667,160 c33.067,0,64.64-6.4,93.547-18.027l9.067,9.067l62.187,62.293l27.2-27.093L48.533,32.053L21.333,59.253z M139.307,177.12 l32.96,32.96c-0.96,4.587-1.6,9.173-1.6,13.973c0,35.307,28.693,64,64,64c4.8,0,9.387-0.64,13.867-1.6l32.96,32.96 c-14.187,7.04-29.973,11.307-46.827,11.307C175.787,330.72,128,282.933,128,224.053C128,207.2,132.267,191.413,139.307,177.12z"
|
||||
id="path6"
|
||||
style="fill:#333333" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
id="g14">
|
||||
</g>
|
||||
<g
|
||||
id="g16">
|
||||
</g>
|
||||
<g
|
||||
id="g18">
|
||||
</g>
|
||||
<g
|
||||
id="g20">
|
||||
</g>
|
||||
<g
|
||||
id="g22">
|
||||
</g>
|
||||
<g
|
||||
id="g24">
|
||||
</g>
|
||||
<g
|
||||
id="g26">
|
||||
</g>
|
||||
<g
|
||||
id="g28">
|
||||
</g>
|
||||
<g
|
||||
id="g30">
|
||||
</g>
|
||||
<g
|
||||
id="g32">
|
||||
</g>
|
||||
<g
|
||||
id="g34">
|
||||
</g>
|
||||
<g
|
||||
id="g36">
|
||||
</g>
|
||||
<g
|
||||
id="g38">
|
||||
</g>
|
||||
<g
|
||||
id="g40">
|
||||
</g>
|
||||
<g
|
||||
id="g42">
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
@ -9,6 +9,7 @@ import types
|
||||
|
||||
import settngs
|
||||
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.graphics import graphics_path
|
||||
from comictalker.comictalker import ComicTalker
|
||||
|
||||
@ -83,7 +84,7 @@ except ImportError:
|
||||
|
||||
|
||||
def open_tagger_window(
|
||||
talkers: dict[str, ComicTalker], config: settngs.Config[settngs.Namespace], error: tuple[str, bool] | None
|
||||
talkers: dict[str, ComicTalker], config: settngs.Config[ct_ns], error: tuple[str, bool] | None
|
||||
) -> None:
|
||||
os.environ["QtWidgets.QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
|
||||
args = []
|
||||
|
@ -22,18 +22,15 @@ import pathlib
|
||||
import shutil
|
||||
import sqlite3 as lite
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import requests
|
||||
|
||||
from comictaggerlib import ctversion
|
||||
|
||||
try:
|
||||
if TYPE_CHECKING:
|
||||
from PyQt5 import QtCore, QtNetwork
|
||||
|
||||
qt_available = True
|
||||
except ImportError:
|
||||
qt_available = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -47,6 +44,7 @@ def fetch_complete(url: str, image_data: bytes | QtCore.QByteArray) -> None:
|
||||
|
||||
class ImageFetcher:
|
||||
image_fetch_complete = fetch_complete
|
||||
qt_available = True
|
||||
|
||||
def __init__(self, cache_folder: pathlib.Path) -> None:
|
||||
self.db_file = cache_folder / "image_url_cache.db"
|
||||
@ -55,10 +53,17 @@ class ImageFetcher:
|
||||
self.user_data = None
|
||||
self.fetched_url = ""
|
||||
|
||||
if self.qt_available:
|
||||
try:
|
||||
from PyQt5 import QtNetwork
|
||||
|
||||
self.qt_available = True
|
||||
except ImportError:
|
||||
self.qt_available = False
|
||||
if not os.path.exists(self.db_file):
|
||||
self.create_image_db()
|
||||
|
||||
if qt_available:
|
||||
if self.qt_available:
|
||||
self.nam = QtNetwork.QNetworkAccessManager()
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
@ -79,7 +84,7 @@ class ImageFetcher:
|
||||
# first look in the DB
|
||||
image_data = self.get_image_from_cache(url)
|
||||
# Async for retrieving covers seems to work well
|
||||
if blocking or not qt_available:
|
||||
if blocking or not self.qt_available:
|
||||
if not image_data:
|
||||
try:
|
||||
image_data = requests.get(url, headers={"user-agent": "comictagger/" + ctversion.version}).content
|
||||
@ -91,7 +96,9 @@ class ImageFetcher:
|
||||
ImageFetcher.image_fetch_complete(url, image_data)
|
||||
return image_data
|
||||
|
||||
if qt_available:
|
||||
if self.qt_available:
|
||||
from PyQt5 import QtCore, QtNetwork
|
||||
|
||||
# if we found it, just emit the signal asap
|
||||
if image_data:
|
||||
ImageFetcher.image_fetch_complete(url, QtCore.QByteArray(image_data))
|
||||
|
@ -17,7 +17,9 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
from functools import reduce
|
||||
from statistics import median
|
||||
from typing import TypeVar
|
||||
|
||||
try:
|
||||
@ -90,82 +92,80 @@ class ImageHasher:
|
||||
return result
|
||||
"""
|
||||
|
||||
def dct_average_hash(self) -> None:
|
||||
def p_hash(self) -> int:
|
||||
"""
|
||||
Pure python version of Perceptual Hash computation of https://github.com/JohannesBuchner/imagehash/tree/master
|
||||
Implementation follows http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html
|
||||
"""
|
||||
# Algorithm source: http://syntaxcandy.blogspot.com/2012/08/perceptual-hash.html
|
||||
|
||||
1. Reduce size. Like Average Hash, pHash starts with a small image.
|
||||
However, the image is larger than 8x8; 32x32 is a good size. This
|
||||
is really done to simplify the DCT computation and not because it
|
||||
is needed to reduce the high frequencies.
|
||||
def generate_dct2(block, axis=0):
|
||||
def dct1(block):
|
||||
"""Perform 1D Discrete Cosine Transform (DCT) on a given block."""
|
||||
N = len(block)
|
||||
dct_block = [0.0] * N
|
||||
|
||||
2. Reduce color. The image is reduced to a grayscale just to further
|
||||
simplify the number of computations.
|
||||
for k in range(N):
|
||||
sum_val = 0.0
|
||||
for n in range(N):
|
||||
cos_val = math.cos(math.pi * k * (2 * n + 1) / (2 * N))
|
||||
sum_val += block[n] * cos_val
|
||||
dct_block[k] = sum_val
|
||||
|
||||
3. Compute the DCT. The DCT separates the image into a collection of
|
||||
frequencies and scalars. While JPEG uses an 8x8 DCT, this algorithm
|
||||
uses a 32x32 DCT.
|
||||
return dct_block
|
||||
|
||||
4. Reduce the DCT. This is the magic step. While the DCT is 32x32,
|
||||
just keep the top-left 8x8. Those represent the lowest frequencies in
|
||||
the picture.
|
||||
"""Perform 2D Discrete Cosine Transform (DCT) on a given block along the specified axis."""
|
||||
rows = len(block)
|
||||
cols = len(block[0])
|
||||
dct_block = [[0.0] * cols for _ in range(rows)]
|
||||
|
||||
5. Compute the average value. Like the Average Hash, compute the mean DCT
|
||||
value (using only the 8x8 DCT low-frequency values and excluding the first
|
||||
term since the DC coefficient can be significantly different from the other
|
||||
values and will throw off the average). Thanks to David Starkweather for the
|
||||
added information about pHash. He wrote: "the dct hash is based on the low 2D
|
||||
DCT coefficients starting at the second from lowest, leaving out the first DC
|
||||
term. This excludes completely flat image information (i.e. solid colors) from
|
||||
being included in the hash description."
|
||||
if axis == 0:
|
||||
# Apply 1D DCT on each row
|
||||
for i in range(rows):
|
||||
dct_block[i] = dct1(block[i])
|
||||
elif axis == 1:
|
||||
# Apply 1D DCT on each column
|
||||
for j in range(cols):
|
||||
column = [block[i][j] for i in range(rows)]
|
||||
dct_column = dct1(column)
|
||||
for i in range(rows):
|
||||
dct_block[i][j] = dct_column[i]
|
||||
else:
|
||||
raise ValueError("Invalid axis value. Must be either 0 or 1.")
|
||||
|
||||
6. Further reduce the DCT. This is the magic step. Set the 64 hash bits to 0 or
|
||||
1 depending on whether each of the 64 DCT values is above or below the average
|
||||
value. The result doesn't tell us the actual low frequencies; it just tells us
|
||||
the very-rough relative scale of the frequencies to the mean. The result will not
|
||||
vary as long as the overall structure of the image remains the same; this can
|
||||
survive gamma and color histogram adjustments without a problem.
|
||||
return dct_block
|
||||
|
||||
7. Construct the hash. Set the 64 bits into a 64-bit integer. The order does not
|
||||
matter, just as long as you are consistent.
|
||||
def convert_image_to_ndarray(image):
|
||||
width, height = image.size
|
||||
|
||||
pixels2 = []
|
||||
for y in range(height):
|
||||
row = []
|
||||
for x in range(width):
|
||||
pixel = image.getpixel((x, y))
|
||||
row.append(pixel)
|
||||
pixels2.append(row)
|
||||
|
||||
import numpy
|
||||
import scipy.fftpack
|
||||
numpy.set_printoptions(threshold=10000, linewidth=200, precision=2, suppress=True)
|
||||
return pixels2
|
||||
|
||||
# Step 1,2
|
||||
im = self.image.resize((32, 32), Image.ANTIALIAS).convert("L")
|
||||
in_data = numpy.asarray(im)
|
||||
highfreq_factor = 4
|
||||
img_size = 8 * highfreq_factor
|
||||
|
||||
# Step 3
|
||||
dct = scipy.fftpack.dct(in_data.astype(float))
|
||||
try:
|
||||
image = self.image.convert("L").resize((img_size, img_size), Image.Resampling.LANCZOS)
|
||||
except Exception:
|
||||
logger.exception("p_hash error converting to greyscale and resizing")
|
||||
return 0
|
||||
|
||||
# Step 4
|
||||
# Just skip the top and left rows when slicing, as suggested somewhere else...
|
||||
lofreq_dct = dct[1:9, 1:9].flatten()
|
||||
|
||||
# Step 5
|
||||
avg = (lofreq_dct.sum()) / (lofreq_dct.size)
|
||||
median = numpy.median(lofreq_dct)
|
||||
|
||||
thresh = avg
|
||||
|
||||
# Step 6
|
||||
def compare_value_to_thresh(i):
|
||||
return (1 if i > thresh else 0)
|
||||
|
||||
bitlist = map(compare_value_to_thresh, lofreq_dct)
|
||||
|
||||
#Step 7
|
||||
def set_bit(x, (idx, val)):
|
||||
return (x | (val << idx))
|
||||
|
||||
result = reduce(set_bit, enumerate(bitlist), long(0))
|
||||
pixels = convert_image_to_ndarray(image)
|
||||
dct = generate_dct2(generate_dct2(pixels, axis=0), axis=1)
|
||||
dctlowfreq = [row[:8] for row in dct[:8]]
|
||||
med = median([item for sublist in dctlowfreq for item in sublist])
|
||||
# Convert to a bit string
|
||||
diff = "".join(str(int(item > med)) for row in dctlowfreq for item in row)
|
||||
|
||||
result = int(diff, 2)
|
||||
|
||||
return result
|
||||
"""
|
||||
|
||||
# accepts 2 hashes (longs or hex strings) and returns the hamming distance
|
||||
|
||||
|
@ -20,13 +20,13 @@ import logging
|
||||
import sys
|
||||
from typing import Any, Callable
|
||||
|
||||
import settngs
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi.comicarchive import ComicArchive
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comicapi.issuestring import IssueString
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.imagefetcher import ImageFetcher, ImageFetcherException
|
||||
from comictaggerlib.imagehasher import ImageHasher
|
||||
from comictaggerlib.resulttypes import IssueResult
|
||||
@ -72,7 +72,7 @@ class IssueIdentifier:
|
||||
result_one_good_match = 4
|
||||
result_multiple_good_matches = 5
|
||||
|
||||
def __init__(self, comic_archive: ComicArchive, config: settngs.Namespace, talker: ComicTalker) -> None:
|
||||
def __init__(self, comic_archive: ComicArchive, config: ct_ns, talker: ComicTalker) -> None:
|
||||
self.config = config
|
||||
self.talker = talker
|
||||
self.comic_archive: ComicArchive = comic_archive
|
||||
@ -134,7 +134,7 @@ class IssueIdentifier:
|
||||
|
||||
def calculate_hash(self, image_data: bytes) -> int:
|
||||
if self.image_hasher == 3:
|
||||
return -1 # ImageHasher(data=image_data).dct_average_hash()
|
||||
return ImageHasher(data=image_data).p_hash()
|
||||
if self.image_hasher == 2:
|
||||
return -1 # ImageHasher(data=image_data).average_hash2()
|
||||
|
||||
@ -296,7 +296,7 @@ class IssueIdentifier:
|
||||
primary_img_url, blocking=True
|
||||
)
|
||||
except ImageFetcherException as e:
|
||||
self.log_msg("Network issue while fetching cover image from Comic Vine. Aborting...")
|
||||
self.log_msg(f"Network issue while fetching cover image from {self.talker.name}. Aborting...")
|
||||
raise IssueIdentifierNetworkError from e
|
||||
|
||||
if self.cancel:
|
||||
@ -318,7 +318,7 @@ class IssueIdentifier:
|
||||
alt_url, blocking=True
|
||||
)
|
||||
except ImageFetcherException as e:
|
||||
self.log_msg("Network issue while fetching alt. cover image from Comic Vine. Aborting...")
|
||||
self.log_msg(f"Network issue while fetching alt. cover image from {self.talker.name}. Aborting...")
|
||||
raise IssueIdentifierNetworkError from e
|
||||
|
||||
if self.cancel:
|
||||
@ -397,7 +397,7 @@ class IssueIdentifier:
|
||||
self.log_msg("Not enough info for a search!")
|
||||
return []
|
||||
|
||||
self.log_msg("Going to search for:")
|
||||
self.log_msg(f"Using {self.talker.name} to search for:")
|
||||
self.log_msg("\tSeries: " + keys["series"])
|
||||
self.log_msg("\tIssue: " + keys["issue_number"])
|
||||
if keys["issue_count"] is not None:
|
||||
|
@ -17,11 +17,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import settngs
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets, uic
|
||||
|
||||
from comicapi.issuestring import IssueString
|
||||
from comictaggerlib.coverimagewidget import CoverImageWidget
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.ui import ui_path
|
||||
from comictaggerlib.ui.qtutils import reduce_widget_font_size
|
||||
from comictalker.comictalker import ComicTalker, TalkerError
|
||||
@ -42,7 +42,7 @@ class IssueSelectionWindow(QtWidgets.QDialog):
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget,
|
||||
config: settngs.Namespace,
|
||||
config: ct_ns,
|
||||
talker: ComicTalker,
|
||||
series_id: str,
|
||||
issue_number: str,
|
||||
@ -115,6 +115,17 @@ class IssueSelectionWindow(QtWidgets.QDialog):
|
||||
self.twList.selectRow(r)
|
||||
break
|
||||
|
||||
self.leFilter.textChanged.connect(self.filter)
|
||||
|
||||
def filter(self, text: str) -> None:
|
||||
rows = set(range(self.twList.rowCount()))
|
||||
for r in rows:
|
||||
self.twList.showRow(r)
|
||||
if text.strip():
|
||||
shown_rows = {x.row() for x in self.twList.findItems(text, QtCore.Qt.MatchFlag.MatchContains)}
|
||||
for r in rows - shown_rows:
|
||||
self.twList.hideRow(r)
|
||||
|
||||
def perform_query(self) -> None:
|
||||
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
|
||||
|
||||
|
@ -24,12 +24,15 @@ import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import cast
|
||||
|
||||
import settngs
|
||||
|
||||
import comicapi
|
||||
import comicapi.comicarchive
|
||||
import comicapi.utils
|
||||
import comictalker
|
||||
from comictaggerlib import cli, ctsettings
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.ctversion import version
|
||||
from comictaggerlib.log import setup_logging
|
||||
|
||||
@ -40,14 +43,6 @@ else:
|
||||
|
||||
logger = logging.getLogger("comictagger")
|
||||
|
||||
try:
|
||||
from comictaggerlib import gui
|
||||
|
||||
qt_available = gui.qt_available
|
||||
except Exception:
|
||||
logger.exception("Qt unavailable")
|
||||
qt_available = False
|
||||
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
@ -95,7 +90,7 @@ def configure_locale() -> None:
|
||||
sys.stdin.reconfigure(encoding=sys.getdefaultencoding()) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def update_publishers(config: settngs.Config[settngs.Namespace]) -> None:
|
||||
def update_publishers(config: settngs.Config[ct_ns]) -> None:
|
||||
json_file = config[0].runtime_config.user_config_dir / "publishers.json"
|
||||
if json_file.exists():
|
||||
try:
|
||||
@ -108,7 +103,7 @@ class App:
|
||||
"""docstring for App"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.config: settngs.Config[settngs.Namespace]
|
||||
self.config: settngs.Config[ct_ns]
|
||||
self.initial_arg_parser = ctsettings.initial_commandline_parser()
|
||||
self.config_load_success = False
|
||||
|
||||
@ -141,9 +136,11 @@ class App:
|
||||
ctsettings.register_file_settings(self.manager)
|
||||
ctsettings.register_plugin_settings(self.manager)
|
||||
|
||||
def parse_settings(self, config_paths: ctsettings.ComicTaggerPaths) -> settngs.Config[settngs.Namespace]:
|
||||
cfg, self.config_load_success = self.manager.parse_config(config_paths.user_config_dir / "settings.json")
|
||||
config = self.manager.get_namespace(cfg)
|
||||
def parse_settings(self, config_paths: ctsettings.ComicTaggerPaths, *args: str) -> settngs.Config[ct_ns]:
|
||||
cfg, self.config_load_success = self.manager.parse_config(
|
||||
config_paths.user_config_dir / "settings.json", list(args) or None
|
||||
)
|
||||
config = cast(settngs.Config[ct_ns], self.manager.get_namespace(cfg, file=True, cmdline=True))
|
||||
|
||||
config = ctsettings.validate_commandline_settings(config, self.manager)
|
||||
config = ctsettings.validate_file_settings(config)
|
||||
@ -185,15 +182,11 @@ class App:
|
||||
comicapi.utils.load_publishers()
|
||||
update_publishers(self.config)
|
||||
|
||||
if not qt_available and not self.config[0].runtime_no_gui:
|
||||
self.config[0].runtime_no_gui = True
|
||||
logger.warning("PyQt5 is not available. ComicTagger is limited to command-line mode.")
|
||||
|
||||
# manage the CV API key
|
||||
# None comparison is used so that the empty string can unset the value
|
||||
if not error and (
|
||||
self.config[0].talker_comicvine_comicvine_key is not None
|
||||
or self.config[0].talker_comicvine_comicvine_url is not None
|
||||
self.config[0].talker_comicvine_comicvine_key is not None # type: ignore[attr-defined]
|
||||
or self.config[0].talker_comicvine_comicvine_url is not None # type: ignore[attr-defined]
|
||||
):
|
||||
settings_path = self.config[0].runtime_config.user_config_dir / "settings.json"
|
||||
if self.config_load_success:
|
||||
@ -210,16 +203,23 @@ class App:
|
||||
True,
|
||||
)
|
||||
|
||||
if self.config[0].runtime_no_gui:
|
||||
if error and error[1]:
|
||||
print(f"A fatal error occurred please check the log for more information: {error[0]}") # noqa: T201
|
||||
raise SystemExit(1)
|
||||
if not self.config[0].runtime_no_gui:
|
||||
try:
|
||||
cli.CLI(self.config[0], talkers).run()
|
||||
except Exception:
|
||||
logger.exception("CLI mode failed")
|
||||
else:
|
||||
gui.open_tagger_window(talkers, self.config, error)
|
||||
from comictaggerlib import gui
|
||||
|
||||
return gui.open_tagger_window(talkers, self.config, error)
|
||||
except ImportError:
|
||||
self.config[0].runtime_no_gui = True
|
||||
logger.warning("PyQt5 is not available. ComicTagger is limited to command-line mode.")
|
||||
|
||||
# GUI mode is not available or CLI mode was requested
|
||||
if error and error[1]:
|
||||
print(f"A fatal error occurred please check the log for more information: {error[0]}") # noqa: T201
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
cli.CLI(self.config[0], talkers).run()
|
||||
except Exception:
|
||||
logger.exception("CLI mode failed")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
@ -18,11 +18,11 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
|
||||
import settngs
|
||||
from PyQt5 import QtCore, QtWidgets, uic
|
||||
|
||||
from comicapi.comicarchive import ComicArchive
|
||||
from comictaggerlib.coverimagewidget import CoverImageWidget
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.resulttypes import IssueResult
|
||||
from comictaggerlib.ui import ui_path
|
||||
from comictaggerlib.ui.qtutils import reduce_widget_font_size
|
||||
@ -37,7 +37,7 @@ class MatchSelectionWindow(QtWidgets.QDialog):
|
||||
parent: QtWidgets.QWidget,
|
||||
matches: list[IssueResult],
|
||||
comic_archive: ComicArchive,
|
||||
config: settngs.Namespace,
|
||||
config: ct_ns,
|
||||
talker: ComicTalker,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
|
@ -23,6 +23,7 @@ from PyQt5 import QtCore, QtWidgets, uic
|
||||
from comicapi import utils
|
||||
from comicapi.comicarchive import ComicArchive, MetaDataStyle
|
||||
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
|
||||
@ -38,7 +39,7 @@ class RenameWindow(QtWidgets.QDialog):
|
||||
parent: QtWidgets.QWidget,
|
||||
comic_archive_list: list[ComicArchive],
|
||||
data_style: int,
|
||||
config: settngs.Config[settngs.Namespace],
|
||||
config: settngs.Config[ct_ns],
|
||||
talkers: dict[str, ComicTalker],
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
@ -124,6 +125,7 @@ class RenameWindow(QtWidgets.QDialog):
|
||||
"<a href='https://github.com/comictagger/comictagger'>"
|
||||
"https://github.com/comictagger/comictagger</a>",
|
||||
)
|
||||
return
|
||||
|
||||
row = self.twList.rowCount()
|
||||
self.twList.insertRow(row)
|
||||
|
@ -19,7 +19,6 @@ import itertools
|
||||
import logging
|
||||
from collections import deque
|
||||
|
||||
import settngs
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets, uic
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
|
||||
@ -27,6 +26,7 @@ from comicapi import utils
|
||||
from comicapi.comicarchive import ComicArchive
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comictaggerlib.coverimagewidget import CoverImageWidget
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.issueidentifier import IssueIdentifier
|
||||
from comictaggerlib.issueselectionwindow import IssueSelectionWindow
|
||||
from comictaggerlib.matchselectionwindow import MatchSelectionWindow
|
||||
@ -106,7 +106,7 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
issue_count: int | None,
|
||||
cover_index_list: list[int],
|
||||
comic_archive: ComicArchive | None,
|
||||
config: settngs.Namespace,
|
||||
config: ct_ns,
|
||||
talker: ComicTalker,
|
||||
autoselect: bool = False,
|
||||
literal: bool = False,
|
||||
@ -187,6 +187,17 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
self.update_buttons()
|
||||
self.twList.selectRow(0)
|
||||
|
||||
self.leFilter.textChanged.connect(self.filter)
|
||||
|
||||
def filter(self, text: str) -> None:
|
||||
rows = set(range(self.twList.rowCount()))
|
||||
for r in rows:
|
||||
self.twList.showRow(r)
|
||||
if text.strip():
|
||||
shown_rows = {x.row() for x in self.twList.findItems(text, QtCore.Qt.MatchFlag.MatchContains)}
|
||||
for r in rows - shown_rows:
|
||||
self.twList.hideRow(r)
|
||||
|
||||
def update_buttons(self) -> None:
|
||||
enabled = bool(self.ct_search_results)
|
||||
|
||||
@ -464,18 +475,21 @@ class SeriesSelectionWindow(QtWidgets.QDialog):
|
||||
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
self.twList.setItem(row, 0, item)
|
||||
|
||||
item_text = str(record.start_year)
|
||||
item = QtWidgets.QTableWidgetItem(item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
|
||||
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
self.twList.setItem(row, 1, item)
|
||||
if record.start_year is not None:
|
||||
item_text = f"{record.start_year:04}"
|
||||
item = QtWidgets.QTableWidgetItem(item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.DisplayRole, record.start_year)
|
||||
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
self.twList.setItem(row, 1, item)
|
||||
|
||||
item_text = str(record.count_of_issues)
|
||||
item = QtWidgets.QTableWidgetItem(item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.DisplayRole, record.count_of_issues)
|
||||
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
self.twList.setItem(row, 2, item)
|
||||
if record.count_of_issues is not None:
|
||||
item_text = f"{record.count_of_issues:04}"
|
||||
item = QtWidgets.QTableWidgetItem(item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.DisplayRole, record.count_of_issues)
|
||||
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
self.twList.setItem(row, 2, item)
|
||||
|
||||
if record.publisher is not None:
|
||||
item_text = record.publisher
|
||||
|
@ -20,7 +20,7 @@ import logging
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import settngs
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets, uic
|
||||
@ -29,6 +29,7 @@ import comictaggerlib.ui.talkeruigenerator
|
||||
from comicapi import utils
|
||||
from comicapi.genericmetadata import md_test
|
||||
from comictaggerlib import ctsettings
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.ctversion import version
|
||||
from comictaggerlib.filerenamer import FileRenamer, Replacement, Replacements
|
||||
from comictaggerlib.imagefetcher import ImageFetcher
|
||||
@ -133,7 +134,7 @@ Spider-Geddon #1 - New Players; Check In
|
||||
|
||||
class SettingsWindow(QtWidgets.QDialog):
|
||||
def __init__(
|
||||
self, parent: QtWidgets.QWidget, config: settngs.Config[settngs.Namespace], talkers: dict[str, ComicTalker]
|
||||
self, parent: QtWidgets.QWidget, config: settngs.Config[ct_ns], talkers: dict[str, ComicTalker]
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
@ -413,7 +414,7 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
setattr(self.config[0], self.config[1]["archiver"].v["rar"].internal_name, str(self.leRarExePath.text()))
|
||||
|
||||
# make sure rar program is now in the path for the rar class
|
||||
if self.config[0].archiver_rar:
|
||||
if self.config[0].archiver_rar: # type: ignore[attr-defined]
|
||||
utils.add_to_path(os.path.dirname(str(self.leRarExePath.text())))
|
||||
|
||||
if not str(self.leIssueNumPadding.text()).isdigit():
|
||||
@ -480,7 +481,7 @@ class SettingsWindow(QtWidgets.QDialog):
|
||||
QtWidgets.QMessageBox.information(self, self.name, "Cache has been cleared.")
|
||||
|
||||
def reset_settings(self) -> None:
|
||||
self.config = settngs.get_namespace(settngs.defaults(self.config[1]))
|
||||
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.")
|
||||
|
||||
|
@ -48,6 +48,7 @@ from comictaggerlib.autotagstartwindow import AutoTagStartWindow
|
||||
from comictaggerlib.cbltransformer import CBLTransformer
|
||||
from comictaggerlib.coverimagewidget import CoverImageWidget
|
||||
from comictaggerlib.crediteditorwindow import CreditEditorWindow
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.exportwindow import ExportConflictOpts, ExportWindow
|
||||
from comictaggerlib.fileselectionlist import FileInfo, FileSelectionList
|
||||
from comictaggerlib.graphics import graphics_path
|
||||
@ -79,7 +80,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
def __init__(
|
||||
self,
|
||||
file_list: list[str],
|
||||
config: settngs.Config[settngs.Namespace],
|
||||
config: settngs.Config[ct_ns],
|
||||
talkers: dict[str, ComicTalker],
|
||||
parent: QtWidgets.QWidget | None = None,
|
||||
) -> None:
|
||||
@ -256,6 +257,10 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
Also, be aware that writing tags to comic archives will change their file hashes,
|
||||
which has implications with respect to other software packages. It's best to
|
||||
use ComicTagger on local copies of your comics.<br><br>
|
||||
COMIC VINE NOTE: Using the default API key will serverly limit search and tagging
|
||||
times. A personal API key will allow for a <b>5 times increase</b> in online search speed. See the
|
||||
<a href='https://github.com/comictagger/comictagger/wiki/UserGuide#comic-vine'>Wiki page</a>
|
||||
for more information.<br><br>
|
||||
Have fun!
|
||||
""",
|
||||
)
|
||||
@ -896,8 +901,8 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
# copy the data from the form into the metadata
|
||||
md = GenericMetadata()
|
||||
md.is_empty = False
|
||||
md.alternate_number = IssueString(self.leAltIssueNum.text()).as_string()
|
||||
md.issue = IssueString(self.leIssueNum.text()).as_string()
|
||||
md.alternate_number = utils.xlate(IssueString(self.leAltIssueNum.text()).as_string())
|
||||
md.issue = utils.xlate(IssueString(self.leIssueNum.text()).as_string())
|
||||
md.issue_count = utils.xlate_int(self.leIssueCount.text())
|
||||
md.volume = utils.xlate_int(self.leVolumeNum.text())
|
||||
md.volume_count = utils.xlate_int(self.leVolumeCount.text())
|
||||
@ -906,30 +911,30 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
md.day = utils.xlate_int(self.lePubDay.text())
|
||||
md.alternate_count = utils.xlate_int(self.leAltIssueCount.text())
|
||||
|
||||
md.series = self.leSeries.text()
|
||||
md.title = self.leTitle.text()
|
||||
md.publisher = self.lePublisher.text()
|
||||
md.genre = self.leGenre.text()
|
||||
md.imprint = self.leImprint.text()
|
||||
md.comments = self.teComments.toPlainText()
|
||||
md.notes = self.teNotes.toPlainText()
|
||||
md.series = utils.xlate(self.leSeries.text())
|
||||
md.title = utils.xlate(self.leTitle.text())
|
||||
md.publisher = utils.xlate(self.lePublisher.text())
|
||||
md.genre = utils.xlate(self.leGenre.text())
|
||||
md.imprint = utils.xlate(self.leImprint.text())
|
||||
md.comments = utils.xlate(self.teComments.toPlainText())
|
||||
md.notes = utils.xlate(self.teNotes.toPlainText())
|
||||
md.maturity_rating = self.cbMaturityRating.currentText()
|
||||
|
||||
md.critical_rating = utils.xlate_float(self.dsbCriticalRating.cleanText())
|
||||
if md.critical_rating == 0.0:
|
||||
md.critical_rating = None
|
||||
|
||||
md.story_arc = self.leStoryArc.text()
|
||||
md.scan_info = self.leScanInfo.text()
|
||||
md.series_group = self.leSeriesGroup.text()
|
||||
md.alternate_series = self.leAltSeries.text()
|
||||
md.web_link = self.leWebLink.text()
|
||||
md.characters = self.teCharacters.toPlainText()
|
||||
md.teams = self.teTeams.toPlainText()
|
||||
md.locations = self.teLocations.toPlainText()
|
||||
md.story_arc = utils.xlate(self.leStoryArc.text())
|
||||
md.scan_info = utils.xlate(self.leScanInfo.text())
|
||||
md.series_group = utils.xlate(self.leSeriesGroup.text())
|
||||
md.alternate_series = utils.xlate(self.leAltSeries.text())
|
||||
md.web_link = utils.xlate(self.leWebLink.text())
|
||||
md.characters = utils.xlate(self.teCharacters.toPlainText())
|
||||
md.teams = utils.xlate(self.teTeams.toPlainText())
|
||||
md.locations = utils.xlate(self.teLocations.toPlainText())
|
||||
|
||||
md.format = self.cbFormat.currentText()
|
||||
md.country = self.cbCountry.currentText()
|
||||
md.format = utils.xlate(self.cbFormat.currentText())
|
||||
md.country = utils.xlate(self.cbCountry.currentText())
|
||||
|
||||
md.language = utils.xlate(self.cbLanguage.itemData(self.cbLanguage.currentIndex()))
|
||||
|
||||
@ -1017,7 +1022,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
# Only need this check is the source has issue level data.
|
||||
if autoselect and issue_number == "":
|
||||
QtWidgets.QMessageBox.information(
|
||||
self, "Automatic Identify Search", "Can't auto-identify without an issue number (yet!)"
|
||||
self,
|
||||
"Automatic Identify Search",
|
||||
"Can't auto-identify without an issue number. The auto-tag function has the 'If no issue number, assume \"1\"' option if desired.",
|
||||
)
|
||||
return
|
||||
|
||||
@ -1395,13 +1402,13 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
|
||||
# Add the entries to the country combobox
|
||||
self.cbCountry.addItem("", "")
|
||||
for f in natsort.humansorted(utils.countries.items(), operator.itemgetter(1)):
|
||||
for f in natsort.humansorted(utils.countries().items(), operator.itemgetter(1)):
|
||||
self.cbCountry.addItem(f[1], f[0])
|
||||
|
||||
# Add the entries to the language combobox
|
||||
self.cbLanguage.addItem("", "")
|
||||
|
||||
for f in natsort.humansorted(utils.languages.items(), operator.itemgetter(1)):
|
||||
for f in natsort.humansorted(utils.languages().items(), operator.itemgetter(1)):
|
||||
self.cbLanguage.addItem(f[1], f[0])
|
||||
|
||||
# Add the entries to the manga combobox
|
||||
@ -1780,7 +1787,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
|
||||
md = ct_md
|
||||
else:
|
||||
notes = (
|
||||
f"Tagged with ComicTagger {ctversion.version} using info from Comic Vine on"
|
||||
f"Tagged with ComicTagger {ctversion.version} using info from {self.current_talker().name} on"
|
||||
f" {datetime.now():%Y-%m-%d %H:%M:%S}. [Issue ID {ct_md.issue_id}]"
|
||||
)
|
||||
md.overlay(ct_md.replace(notes=utils.combine_notes(md.notes, notes, "Tagged with ComicTagger")))
|
||||
|
@ -10,7 +10,7 @@
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>519</width>
|
||||
<height>440</height>
|
||||
<height>448</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
@ -26,84 +26,19 @@
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
<item row="4" column="0">
|
||||
<widget class="QDialogButtonBox" name="buttonBox">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="6" column="0">
|
||||
<widget class="QCheckBox" name="cbxAutoImprint">
|
||||
<property name="toolTip">
|
||||
<string>Checks the publisher against a list of imprints.</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Auto Imprint</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="9" column="0">
|
||||
<widget class="QCheckBox" name="cbxSpecifySearchString">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Specify series search string for all selected archives:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QCheckBox" name="cbxIgnoreLeadingDigitsInFilename">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Ignore leading (sequence) numbers in filename</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QCheckBox" name="cbxSaveOnLowConfidence">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Save on low confidence match</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="10" column="0">
|
||||
<widget class="QLineEdit" name="leSearchString">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="11" column="0">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
@ -116,7 +51,7 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="8" column="0">
|
||||
<item row="7" column="0">
|
||||
<widget class="QCheckBox" name="cbxSplitWords">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
@ -129,19 +64,6 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QCheckBox" name="cbxDontUseYear">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Don't use publication year in identification process</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QCheckBox" name="cbxAssumeIssueOne">
|
||||
<property name="sizePolicy">
|
||||
@ -155,7 +77,7 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="7" column="0">
|
||||
<item row="6" column="0">
|
||||
<widget class="QCheckBox" name="cbxRemoveMetadata">
|
||||
<property name="toolTip">
|
||||
<string>Removes existing metadata before applying retrieved metadata</string>
|
||||
@ -165,7 +87,66 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="9" column="0">
|
||||
<widget class="QLineEdit" name="leSearchString">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QCheckBox" name="cbxDontUseYear">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Don't use publication year in identification process</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<widget class="QCheckBox" name="cbxAutoImprint">
|
||||
<property name="toolTip">
|
||||
<string>Checks the publisher against a list of imprints.</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Auto Imprint</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="8" column="0">
|
||||
<widget class="QCheckBox" name="cbxSpecifySearchString">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Specify series search string for all selected archives:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QCheckBox" name="cbxSaveOnLowConfidence">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Save on low confidence match</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QCheckBox" name="cbxRemoveAfterSuccess">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
@ -179,13 +160,19 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QCheckBox" name="cbxWaitForRateLimit">
|
||||
<widget class="QCheckBox" name="cbxIgnoreLeadingDigitsInFilename">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Wait and retry when Comic Vine rate limit is exceeded (experimental)</string>
|
||||
<string>Ignore leading (sequence) numbers in filename</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="12" column="0">
|
||||
<item row="11" column="0">
|
||||
<widget class="QSpinBox" name="sbNameMatchSearchThresh">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
@ -225,13 +212,19 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QDialogButtonBox" name="buttonBox">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
|
@ -7,7 +7,7 @@
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>872</width>
|
||||
<height>670</height>
|
||||
<height>673</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
@ -16,6 +16,13 @@
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QLineEdit" name="leFilter">
|
||||
<property name="placeholderText">
|
||||
<string>Filter</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<property name="spacing">
|
||||
|
@ -110,6 +110,13 @@
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QLineEdit" name="leFilter">
|
||||
<property name="placeholderText">
|
||||
<string>Filter</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
|
@ -276,7 +276,7 @@
|
||||
<item row="2" column="0">
|
||||
<widget class="QCheckBox" name="cbxClearFormBeforePopulating">
|
||||
<property name="text">
|
||||
<string>Clear Form Before Importing Comic Vine data</string>
|
||||
<string>Clear form before importing comic metadata</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
|
@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
from functools import partial
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
import settngs
|
||||
from PyQt5 import QtCore, QtWidgets
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from comictaggerlib.ctsettings import ct_ns
|
||||
from comictaggerlib.graphics import graphics_path
|
||||
from comictalker.comictalker import ComicTalker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -18,10 +19,46 @@ class TalkerTab(NamedTuple):
|
||||
widgets: dict[str, QtWidgets.QWidget]
|
||||
|
||||
|
||||
class PasswordEdit(QtWidgets.QLineEdit):
|
||||
"""
|
||||
Password LineEdit with icons to show/hide password entries.
|
||||
Taken from https://github.com/pythonguis/python-qtwidgets/tree/master/qtwidgets
|
||||
Based on this example https://kushaldas.in/posts/creating-password-input-widget-in-pyqt.html by Kushal Das.
|
||||
"""
|
||||
|
||||
def __init__(self, show_visibility: bool = True, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.visibleIcon = QtGui.QIcon(str(graphics_path / "eye.svg"))
|
||||
self.hiddenIcon = QtGui.QIcon(str(graphics_path / "hidden.svg"))
|
||||
|
||||
self.setEchoMode(QtWidgets.QLineEdit.Password)
|
||||
|
||||
if show_visibility:
|
||||
# Add the password hide/shown toggle at the end of the edit box.
|
||||
self.togglepasswordAction = self.addAction(self.visibleIcon, QtWidgets.QLineEdit.TrailingPosition)
|
||||
self.togglepasswordAction.setToolTip("Show password")
|
||||
self.togglepasswordAction.triggered.connect(self.on_toggle_password_Action)
|
||||
|
||||
self.password_shown = False
|
||||
|
||||
def on_toggle_password_Action(self) -> None:
|
||||
if not self.password_shown:
|
||||
self.setEchoMode(QtWidgets.QLineEdit.Normal)
|
||||
self.password_shown = True
|
||||
self.togglepasswordAction.setIcon(self.hiddenIcon)
|
||||
self.togglepasswordAction.setToolTip("Hide password")
|
||||
else:
|
||||
self.setEchoMode(QtWidgets.QLineEdit.Password)
|
||||
self.password_shown = False
|
||||
self.togglepasswordAction.setIcon(self.visibleIcon)
|
||||
self.togglepasswordAction.setToolTip("Show password")
|
||||
|
||||
|
||||
def generate_api_widgets(
|
||||
talker_id: str,
|
||||
sources: dict[str, QtWidgets.QWidget],
|
||||
config: settngs.Config[settngs.Namespace],
|
||||
config: settngs.Config[ct_ns],
|
||||
layout: QtWidgets.QGridLayout,
|
||||
talkers: dict[str, ComicTalker],
|
||||
) -> None:
|
||||
@ -51,7 +88,8 @@ def generate_api_widgets(
|
||||
if talker_key.file:
|
||||
# record the current row so we know where to add the button
|
||||
btn_test_row = layout.rowCount()
|
||||
le_key = generate_textbox(talker_key, layout)
|
||||
le_key = generate_password_textbox(talker_key, layout)
|
||||
|
||||
# To enable setting and getting
|
||||
sources["tabs"][talker_id].widgets[f"talker_{talker_id}_{talker_id}_key"] = le_key
|
||||
|
||||
@ -61,6 +99,9 @@ def generate_api_widgets(
|
||||
# We overwrite so that the default will be next to the url text box
|
||||
btn_test_row = layout.rowCount()
|
||||
le_url = generate_textbox(talker_url, layout)
|
||||
value, _ = settngs.get_option(config[0], talker_url)
|
||||
if not value:
|
||||
le_url.setText(talkers[talker_id].default_api_url)
|
||||
# To enable setting and getting
|
||||
sources["tabs"][talker_id].widgets[f"talker_{talker_id}_{talker_id}_url"] = le_url
|
||||
|
||||
@ -118,7 +159,19 @@ def generate_textbox(option: settngs.Setting, layout: QtWidgets.QGridLayout) ->
|
||||
return widget
|
||||
|
||||
|
||||
def settings_to_talker_form(sources: dict[str, QtWidgets.QWidget], config: settngs.Config[settngs.Namespace]) -> None:
|
||||
def generate_password_textbox(option: settngs.Setting, layout: QtWidgets.QGridLayout) -> QtWidgets.QLineEdit:
|
||||
row = layout.rowCount()
|
||||
lbl = QtWidgets.QLabel(option.display_name)
|
||||
lbl.setToolTip(option.help)
|
||||
layout.addWidget(lbl, row, 0)
|
||||
widget = PasswordEdit()
|
||||
widget.setToolTip(option.help)
|
||||
layout.addWidget(widget, row, 1)
|
||||
|
||||
return widget
|
||||
|
||||
|
||||
def settings_to_talker_form(sources: dict[str, QtWidgets.QWidget], config: settngs.Config[ct_ns]) -> None:
|
||||
# Set the active talker via id in sources combo box
|
||||
sources["cbx_select_talker"].setCurrentIndex(sources["cbx_select_talker"].findData(config[0].talker_source))
|
||||
|
||||
@ -127,7 +180,7 @@ def settings_to_talker_form(sources: dict[str, QtWidgets.QWidget], config: settn
|
||||
value = getattr(config[0], name)
|
||||
value_type = type(value)
|
||||
try:
|
||||
if value_type is str:
|
||||
if value_type is str and value:
|
||||
widget.setText(value)
|
||||
if value_type is int or value_type is float:
|
||||
widget.setValue(value)
|
||||
@ -137,7 +190,7 @@ def settings_to_talker_form(sources: dict[str, QtWidgets.QWidget], config: settn
|
||||
logger.debug("Failed to set value of %s", name)
|
||||
|
||||
|
||||
def form_settings_to_config(sources: dict[str, QtWidgets.QWidget], config: settngs.Config[settngs.Namespace]) -> None:
|
||||
def form_settings_to_config(sources: dict[str, QtWidgets.QWidget], config: settngs.Config[ct_ns]) -> None:
|
||||
# Source combo box value
|
||||
config[0].talker_source = sources["cbx_select_talker"].currentData()
|
||||
|
||||
@ -156,7 +209,7 @@ def form_settings_to_config(sources: dict[str, QtWidgets.QWidget], config: settn
|
||||
|
||||
def generate_source_option_tabs(
|
||||
comic_talker_tab: QtWidgets.QWidget,
|
||||
config: settngs.Config[settngs.Namespace],
|
||||
config: settngs.Config[ct_ns],
|
||||
talkers: dict[str, ComicTalker],
|
||||
) -> dict[str, QtWidgets.QWidget]:
|
||||
"""
|
||||
@ -202,22 +255,17 @@ def generate_source_option_tabs(
|
||||
if option.dest in (f"{talker_id}_url", f"{talker_id}_key"):
|
||||
continue
|
||||
current_widget = None
|
||||
if option.action is not None and (
|
||||
option.action is argparse.BooleanOptionalAction
|
||||
or option.type is bool
|
||||
or option.action == "store_true"
|
||||
or option.action == "store_false"
|
||||
):
|
||||
if option._guess_type() is bool:
|
||||
current_widget = generate_checkbox(option, layout_grid)
|
||||
sources["tabs"][tab_name].widgets[option.internal_name] = current_widget
|
||||
elif option.type is int:
|
||||
elif option._guess_type() is int:
|
||||
current_widget = generate_spinbox(option, layout_grid)
|
||||
sources["tabs"][tab_name].widgets[option.internal_name] = current_widget
|
||||
elif option.type is float:
|
||||
elif option._guess_type() is float:
|
||||
current_widget = generate_doublespinbox(option, layout_grid)
|
||||
sources["tabs"][tab_name].widgets[option.internal_name] = current_widget
|
||||
# option.type of None should be string
|
||||
elif (option.type is None and option.action is None) or option.type is str:
|
||||
|
||||
elif option._guess_type() is str:
|
||||
current_widget = generate_textbox(option, layout_grid)
|
||||
sources["tabs"][tab_name].widgets[option.internal_name] = current_widget
|
||||
else:
|
||||
|
@ -40,9 +40,9 @@ class VersionChecker:
|
||||
headers={"user-agent": "comictagger/" + ctversion.version},
|
||||
).json()
|
||||
except Exception:
|
||||
return ("", "")
|
||||
return "", ""
|
||||
|
||||
new_version = release["tag_name"]
|
||||
if new_version is None or new_version == "":
|
||||
return ("", "")
|
||||
return (new_version.strip(), release["name"])
|
||||
return "", ""
|
||||
return new_version.strip(), release["name"]
|
||||
|
@ -1,8 +1,8 @@
|
||||
"""A python class to manage caching of data from Comic Vine"""
|
||||
"""A python class to manage caching of metadata from comic sources"""
|
||||
#
|
||||
# Copyright 2012-2014 ComicTagger Authors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License;
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
@ -88,10 +88,13 @@ class ComicCacher:
|
||||
+ "name TEXT,"
|
||||
+ "publisher TEXT,"
|
||||
+ "count_of_issues INT,"
|
||||
+ "count_of_volumes INT,"
|
||||
+ "start_year INT,"
|
||||
+ "image_url TEXT,"
|
||||
+ "aliases TEXT," # Newline separated
|
||||
+ "description TEXT,"
|
||||
+ "genres TEXT," # Newline separated. For filtering etc.
|
||||
+ "format TEXT,"
|
||||
+ "timestamp DATE DEFAULT (datetime('now','localtime')), "
|
||||
+ "source_name TEXT NOT NULL,"
|
||||
+ "PRIMARY KEY (id, source_name))"
|
||||
@ -117,6 +120,14 @@ class ComicCacher:
|
||||
+ "credits TEXT," # JSON: "{"name": "Bob Shakespeare", "role": "Writer"}"
|
||||
+ "teams TEXT," # Newline separated
|
||||
+ "story_arcs TEXT," # Newline separated
|
||||
+ "genres TEXT," # Newline separated
|
||||
+ "tags TEXT," # Newline separated
|
||||
+ "critical_rating FLOAT,"
|
||||
+ "manga TEXT," # Yes/YesAndRightToLeft/No
|
||||
+ "maturity_rating TEXT,"
|
||||
+ "language TEXT,"
|
||||
+ "country TEXT,"
|
||||
+ "volume TEXT,"
|
||||
+ "complete BOOL," # Is the data complete? Includes characters, locations, credits.
|
||||
+ "PRIMARY KEY (id, source_name))"
|
||||
)
|
||||
@ -147,9 +158,12 @@ class ComicCacher:
|
||||
"name": record.name,
|
||||
"publisher": record.publisher,
|
||||
"count_of_issues": record.count_of_issues,
|
||||
"count_of_volumes": record.count_of_volumes,
|
||||
"start_year": record.start_year,
|
||||
"image_url": record.image_url,
|
||||
"description": record.description,
|
||||
"genres": "\n".join(record.genres),
|
||||
"format": record.format,
|
||||
"timestamp": datetime.datetime.now(),
|
||||
"aliases": "\n".join(record.aliases),
|
||||
}
|
||||
@ -177,10 +191,13 @@ class ComicCacher:
|
||||
name=record[5],
|
||||
publisher=record[6],
|
||||
count_of_issues=record[7],
|
||||
start_year=record[8],
|
||||
image_url=record[9],
|
||||
aliases=record[10].strip().splitlines(),
|
||||
description=record[11],
|
||||
count_of_volumes=record[8],
|
||||
start_year=record[9],
|
||||
image_url=record[10],
|
||||
aliases=record[11].strip().splitlines(),
|
||||
description=record[12],
|
||||
genres=record[13].strip().splitlines(),
|
||||
format=record[14],
|
||||
)
|
||||
|
||||
results.append(result)
|
||||
@ -201,9 +218,12 @@ class ComicCacher:
|
||||
"name": series_record.name,
|
||||
"publisher": series_record.publisher,
|
||||
"count_of_issues": series_record.count_of_issues,
|
||||
"count_of_volumes": series_record.count_of_volumes,
|
||||
"start_year": series_record.start_year,
|
||||
"image_url": series_record.image_url,
|
||||
"description": series_record.description,
|
||||
"genres": "\n".join(series_record.genres),
|
||||
"format": series_record.format,
|
||||
"timestamp": timestamp,
|
||||
"aliases": "\n".join(series_record.aliases),
|
||||
}
|
||||
@ -226,6 +246,7 @@ class ComicCacher:
|
||||
"source_name": source_name,
|
||||
"name": issue.name,
|
||||
"issue_number": issue.issue_number,
|
||||
"volume": issue.volume,
|
||||
"site_detail_url": issue.site_detail_url,
|
||||
"cover_date": issue.cover_date,
|
||||
"image_url": issue.image_url,
|
||||
@ -237,6 +258,13 @@ class ComicCacher:
|
||||
"locations": "\n".join(issue.locations),
|
||||
"teams": "\n".join(issue.teams),
|
||||
"story_arcs": "\n".join(issue.story_arcs),
|
||||
"genres": "\n".join(issue.genres),
|
||||
"tags": "\n".join(issue.tags),
|
||||
"critical_rating": issue.critical_rating,
|
||||
"manga": issue.manga,
|
||||
"maturity_rating": issue.maturity_rating,
|
||||
"language": issue.language,
|
||||
"country": issue.country,
|
||||
"credits": json.dumps([dataclasses.asdict(x) for x in issue.credits]),
|
||||
"complete": issue.complete,
|
||||
}
|
||||
@ -269,10 +297,13 @@ class ComicCacher:
|
||||
name=row[1],
|
||||
publisher=row[2],
|
||||
count_of_issues=row[3],
|
||||
start_year=row[4],
|
||||
image_url=row[5],
|
||||
aliases=row[6].strip().splitlines(),
|
||||
description=row[7],
|
||||
count_of_volumes=row[4],
|
||||
start_year=row[5],
|
||||
image_url=row[6],
|
||||
aliases=row[7].strip().splitlines(),
|
||||
description=row[8],
|
||||
genres=row[9].strip().splitlines(),
|
||||
format=row[10],
|
||||
)
|
||||
|
||||
return result
|
||||
@ -283,11 +314,14 @@ class ComicCacher:
|
||||
id=series_id,
|
||||
name="",
|
||||
description="",
|
||||
genres=[],
|
||||
image_url="",
|
||||
publisher="",
|
||||
start_year=None,
|
||||
aliases=[],
|
||||
count_of_issues=None,
|
||||
count_of_volumes=None,
|
||||
format=None,
|
||||
)
|
||||
con = lite.connect(self.db_file)
|
||||
with con:
|
||||
@ -302,40 +336,42 @@ class ComicCacher:
|
||||
# fetch
|
||||
results: list[ComicIssue] = []
|
||||
|
||||
cur.execute(
|
||||
(
|
||||
"SELECT source_name,id,name,issue_number,site_detail_url,cover_date,image_url,thumb_url,description,aliases,alt_image_urls,characters,locations,credits,teams,story_arcs,complete"
|
||||
" FROM Issues WHERE series_id=? AND source_name=?"
|
||||
),
|
||||
[series_id, source_name],
|
||||
)
|
||||
cur.execute("SELECT * FROM Issues WHERE series_id=? AND source_name=?", [series_id, source_name])
|
||||
rows = cur.fetchall()
|
||||
|
||||
# now process the results
|
||||
for row in rows:
|
||||
credits = []
|
||||
try:
|
||||
for credit in json.loads(row[13]):
|
||||
for credit in json.loads(row[15]):
|
||||
credits.append(Credit(**credit))
|
||||
except Exception:
|
||||
logger.exception("credits failed")
|
||||
record = ComicIssue(
|
||||
id=row[1],
|
||||
id=row[0],
|
||||
name=row[2],
|
||||
issue_number=row[3],
|
||||
site_detail_url=row[4],
|
||||
cover_date=row[5],
|
||||
image_url=row[6],
|
||||
volume=row[25],
|
||||
site_detail_url=row[7],
|
||||
cover_date=row[6],
|
||||
image_url=row[4],
|
||||
description=row[8],
|
||||
series=series,
|
||||
aliases=row[9].strip().splitlines(),
|
||||
alt_image_urls=row[10].strip().splitlines(),
|
||||
characters=row[11].strip().splitlines(),
|
||||
locations=row[12].strip().splitlines(),
|
||||
aliases=row[11].strip().splitlines(),
|
||||
alt_image_urls=row[12].strip().splitlines(),
|
||||
characters=row[13].strip().splitlines(),
|
||||
locations=row[14].strip().splitlines(),
|
||||
credits=credits,
|
||||
teams=row[14].strip().splitlines(),
|
||||
story_arcs=row[15].strip().splitlines(),
|
||||
complete=bool(row[16]),
|
||||
teams=row[16].strip().splitlines(),
|
||||
story_arcs=row[17].strip().splitlines(),
|
||||
genres=row[18].strip().splitlines(),
|
||||
tags=row[19].strip().splitlines(),
|
||||
critical_rating=row[20],
|
||||
manga=row[21],
|
||||
maturity_rating=row[22],
|
||||
language=row[23],
|
||||
country=row[24],
|
||||
complete=bool(row[26]),
|
||||
)
|
||||
|
||||
results.append(record)
|
||||
@ -353,54 +389,59 @@ class ComicCacher:
|
||||
a_week_ago = datetime.datetime.today() - datetime.timedelta(days=7)
|
||||
cur.execute("DELETE FROM Issues WHERE timestamp < ?", [str(a_week_ago)])
|
||||
|
||||
cur.execute(
|
||||
(
|
||||
"SELECT source_name,id,name,issue_number,site_detail_url,cover_date,image_url,description,aliases,series_id,alt_image_urls,characters,locations,credits,teams,story_arcs,complete"
|
||||
" FROM Issues WHERE id=? AND source_name=?"
|
||||
),
|
||||
[issue_id, source_name],
|
||||
)
|
||||
cur.execute("SELECT * FROM Issues WHERE id=? AND source_name=?", [issue_id, source_name])
|
||||
row = cur.fetchone()
|
||||
|
||||
record = None
|
||||
|
||||
if row:
|
||||
# get_series_info should only fail if someone is doing something weird
|
||||
series = self.get_series_info(row[10], source_name, False) or ComicSeries(
|
||||
id=row[10],
|
||||
series = self.get_series_info(row[1], source_name, False) or ComicSeries(
|
||||
id=row[1],
|
||||
name="",
|
||||
description="",
|
||||
genres=[],
|
||||
image_url="",
|
||||
publisher="",
|
||||
start_year=None,
|
||||
aliases=[],
|
||||
count_of_issues=None,
|
||||
count_of_volumes=None,
|
||||
format=None,
|
||||
)
|
||||
|
||||
# now process the results
|
||||
credits = []
|
||||
try:
|
||||
for credit in json.loads(row[13]):
|
||||
for credit in json.loads(row[15]):
|
||||
credits.append(Credit(**credit))
|
||||
except Exception:
|
||||
logger.exception("credits failed")
|
||||
record = ComicIssue(
|
||||
id=row[1],
|
||||
id=row[0],
|
||||
name=row[2],
|
||||
issue_number=row[3],
|
||||
site_detail_url=row[4],
|
||||
cover_date=row[5],
|
||||
image_url=row[6],
|
||||
description=row[7],
|
||||
volume=row[25],
|
||||
site_detail_url=row[7],
|
||||
cover_date=row[6],
|
||||
image_url=row[4],
|
||||
description=row[8],
|
||||
series=series,
|
||||
aliases=row[8].strip().splitlines(),
|
||||
alt_image_urls=row[10].strip().splitlines(),
|
||||
characters=row[11].strip().splitlines(),
|
||||
locations=row[12].strip().splitlines(),
|
||||
aliases=row[11].strip().splitlines(),
|
||||
alt_image_urls=row[12].strip().splitlines(),
|
||||
characters=row[13].strip().splitlines(),
|
||||
locations=row[14].strip().splitlines(),
|
||||
credits=credits,
|
||||
teams=row[14].strip().splitlines(),
|
||||
story_arcs=row[15].strip().splitlines(),
|
||||
complete=bool(row[16]),
|
||||
teams=row[16].strip().splitlines(),
|
||||
story_arcs=row[17].strip().splitlines(),
|
||||
genres=row[18].strip().splitlines(),
|
||||
tags=row[19].strip().splitlines(),
|
||||
critical_rating=row[20],
|
||||
manga=row[21],
|
||||
maturity_rating=row[22],
|
||||
language=row[23],
|
||||
country=row[24],
|
||||
complete=bool(row[26]),
|
||||
)
|
||||
|
||||
return record
|
||||
|
@ -130,17 +130,17 @@ class ComicTalker:
|
||||
settings is a dictionary of settings defined in register_settings.
|
||||
It is only guaranteed that the settings defined in register_settings will be present.
|
||||
"""
|
||||
if settings[f"{self.id}_key"]:
|
||||
if settings.get(f"{self.id}_key") is not None:
|
||||
self.api_key = settings[f"{self.id}_key"]
|
||||
if settings[f"{self.id}_url"]:
|
||||
if settings.get(f"{self.id}_url") is not None:
|
||||
self.api_url = fix_url(settings[f"{self.id}_url"])
|
||||
|
||||
settings[f"{self.id}_url"] = self.api_url
|
||||
|
||||
if self.api_key == "":
|
||||
if self.api_key in ("", self.default_api_key):
|
||||
self.api_key = self.default_api_key
|
||||
if self.api_url == "":
|
||||
settings[f"{self.id}_key"] = None
|
||||
if self.api_url in ("", self.default_api_url):
|
||||
self.api_url = self.default_api_url
|
||||
settings[f"{self.id}_url"] = None
|
||||
return settings
|
||||
|
||||
def check_api_key(self, url: str, key: str) -> tuple[str, bool]:
|
||||
|
@ -14,12 +14,15 @@ class Credit:
|
||||
class ComicSeries:
|
||||
aliases: list[str]
|
||||
count_of_issues: int | None
|
||||
count_of_volumes: int | None
|
||||
description: str
|
||||
id: str
|
||||
image_url: str
|
||||
name: str
|
||||
publisher: str
|
||||
start_year: int | None
|
||||
genres: list[str]
|
||||
format: str | None
|
||||
|
||||
def copy(self) -> ComicSeries:
|
||||
return copy.deepcopy(self)
|
||||
@ -33,7 +36,15 @@ class ComicIssue:
|
||||
id: str
|
||||
image_url: str
|
||||
issue_number: str
|
||||
volume: str | None
|
||||
critical_rating: float
|
||||
maturity_rating: str
|
||||
manga: str
|
||||
genres: list[str]
|
||||
tags: list[str]
|
||||
name: str
|
||||
language: str
|
||||
country: str
|
||||
site_detail_url: str
|
||||
series: ComicSeries
|
||||
alt_image_urls: list[str]
|
||||
|
@ -18,8 +18,6 @@ import posixpath
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comicapi.issuestring import IssueString
|
||||
@ -29,9 +27,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fix_url(url: str) -> str:
|
||||
if not url:
|
||||
return ""
|
||||
tmp_url = urlsplit(url)
|
||||
new_path = posixpath.normpath(tmp_url.path)
|
||||
if new_path in (".", "/"):
|
||||
new_path = ""
|
||||
# joinurl only works properly if there is a trailing slash
|
||||
tmp_url = tmp_url._replace(path=posixpath.normpath(tmp_url.path) + "/")
|
||||
tmp_url = tmp_url._replace(path=new_path + "/")
|
||||
return tmp_url.geturl()
|
||||
|
||||
|
||||
@ -43,7 +46,16 @@ def map_comic_issue_to_metadata(
|
||||
metadata.is_empty = False
|
||||
|
||||
metadata.series = utils.xlate(issue_results.series.name)
|
||||
metadata.issue = IssueString(issue_results.issue_number).as_string()
|
||||
metadata.issue = utils.xlate(IssueString(issue_results.issue_number).as_string())
|
||||
|
||||
# Rely on comic talker to validate this number
|
||||
metadata.issue_count = utils.xlate_int(issue_results.series.count_of_issues)
|
||||
|
||||
if issue_results.series.format:
|
||||
metadata.format = issue_results.series.format
|
||||
|
||||
metadata.volume = utils.xlate_int(issue_results.volume)
|
||||
metadata.volume_count = utils.xlate_int(issue_results.series.count_of_volumes)
|
||||
|
||||
if issue_results.name:
|
||||
metadata.title = utils.xlate(issue_results.name)
|
||||
@ -81,6 +93,26 @@ def map_comic_issue_to_metadata(
|
||||
metadata.locations = ", ".join(issue_results.locations)
|
||||
if issue_results.story_arcs:
|
||||
metadata.story_arc = ", ".join(issue_results.story_arcs)
|
||||
if issue_results.genres:
|
||||
metadata.genre = ", ".join(issue_results.genres)
|
||||
|
||||
if issue_results.tags:
|
||||
metadata.tags = set(issue_results.tags)
|
||||
|
||||
if issue_results.manga:
|
||||
metadata.manga = issue_results.manga
|
||||
|
||||
if issue_results.critical_rating:
|
||||
metadata.critical_rating = utils.xlate_float(issue_results.critical_rating)
|
||||
|
||||
if issue_results.maturity_rating:
|
||||
metadata.maturity_rating = issue_results.maturity_rating
|
||||
|
||||
if issue_results.language:
|
||||
metadata.language = issue_results.language
|
||||
|
||||
if issue_results.country:
|
||||
metadata.country = issue_results.country
|
||||
|
||||
return metadata
|
||||
|
||||
@ -103,6 +135,8 @@ def cleanup_html(string: str, remove_html_tables: bool = False) -> str:
|
||||
"""Cleans HTML code from any text. Will remove any HTML tables with remove_html_tables"""
|
||||
if string is None:
|
||||
return ""
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# find any tables
|
||||
soup = BeautifulSoup(string, "html.parser")
|
||||
tables = soup.findAll("table")
|
||||
|
@ -26,6 +26,7 @@ from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
import settngs
|
||||
from pyrate_limiter import Limiter, RequestRate
|
||||
from typing_extensions import Required, TypedDict
|
||||
|
||||
import comictalker.talker_utils as talker_utils
|
||||
@ -150,7 +151,10 @@ class CVResult(TypedDict, Generic[T]):
|
||||
version: str
|
||||
|
||||
|
||||
CV_STATUS_RATELIMIT = 107
|
||||
# https://comicvine.gamespot.com/forums/api-developers-2334/api-rate-limiting-1746419/
|
||||
# "Space out your requests so AT LEAST one second passes between each and you can make requests all day."
|
||||
custom_limiter = Limiter(RequestRate(10, 10))
|
||||
default_limiter = Limiter(RequestRate(1, 5))
|
||||
|
||||
|
||||
class ComicVineTalker(ComicTalker):
|
||||
@ -162,15 +166,12 @@ class ComicVineTalker(ComicTalker):
|
||||
|
||||
def __init__(self, version: str, cache_folder: pathlib.Path):
|
||||
super().__init__(version, cache_folder)
|
||||
self.limiter = default_limiter
|
||||
# Default settings
|
||||
self.default_api_url = self.api_url = f"{self.website}/api/"
|
||||
self.default_api_key = self.api_key = "27431e6787042105bd3e47e169a624521f89f3a4"
|
||||
self.remove_html_tables: bool = False
|
||||
self.use_series_start_as_volume: bool = False
|
||||
self.wait_on_ratelimit: bool = False
|
||||
|
||||
# NOTE: This was hardcoded before which is why it isn't in settings
|
||||
self.wait_on_ratelimit_time: int = 20
|
||||
|
||||
def register_settings(self, parser: settngs.Manager) -> None:
|
||||
parser.add_setting(
|
||||
@ -180,13 +181,6 @@ class ComicVineTalker(ComicTalker):
|
||||
display_name="Use series start as volume",
|
||||
help="Use the series start year as the volume number",
|
||||
)
|
||||
parser.add_setting(
|
||||
"--cv-wait-on-ratelimit",
|
||||
default=False,
|
||||
action=argparse.BooleanOptionalAction,
|
||||
display_name="Wait on ratelimit",
|
||||
help="Wait when the rate limit is hit",
|
||||
)
|
||||
parser.add_setting(
|
||||
"--cv-remove-html-tables",
|
||||
default=False,
|
||||
@ -194,16 +188,16 @@ class ComicVineTalker(ComicTalker):
|
||||
display_name="Remove HTML tables",
|
||||
help="Removes html tables instead of converting them to text",
|
||||
)
|
||||
# The empty string being the default allows this setting to be unset, allowing the default to change
|
||||
|
||||
# The default needs to be unset or None.
|
||||
# This allows this setting to be unset with the empty string, allowing the default to change
|
||||
parser.add_setting(
|
||||
f"--{self.id}-key",
|
||||
default="",
|
||||
display_name="API Key",
|
||||
help=f"Use the given Comic Vine API Key. (default: {self.default_api_key})",
|
||||
)
|
||||
parser.add_setting(
|
||||
f"--{self.id}-url",
|
||||
default="",
|
||||
display_name="API URL",
|
||||
help=f"Use the given Comic Vine URL. (default: {self.default_api_url})",
|
||||
)
|
||||
@ -212,8 +206,14 @@ class ComicVineTalker(ComicTalker):
|
||||
settings = super().parse_settings(settings)
|
||||
|
||||
self.use_series_start_as_volume = settings["cv_use_series_start_as_volume"]
|
||||
self.wait_on_ratelimit = settings["cv_wait_on_ratelimit"]
|
||||
self.remove_html_tables = settings["cv_remove_html_tables"]
|
||||
|
||||
# Set a different limit if using the default API key
|
||||
if self.api_key == self.default_api_key:
|
||||
self.limiter = default_limiter
|
||||
else:
|
||||
self.limiter = custom_limiter
|
||||
|
||||
return settings
|
||||
|
||||
def check_api_key(self, url: str, key: str) -> tuple[str, bool]:
|
||||
@ -430,40 +430,24 @@ class ComicVineTalker(ComicTalker):
|
||||
|
||||
def _get_cv_content(self, url: str, params: dict[str, Any]) -> CVResult:
|
||||
"""
|
||||
Get the content from the CV server. If we're in "wait mode" and status code is a rate limit error
|
||||
sleep for a bit and retry.
|
||||
Get the content from the CV server.
|
||||
"""
|
||||
total_time_waited = 0
|
||||
limit_wait_time = 1
|
||||
counter = 0
|
||||
wait_times = [1, 2, 3, 4]
|
||||
while True:
|
||||
with self.limiter.ratelimit("cv", delay=True):
|
||||
cv_response: CVResult = self._get_url_content(url, params)
|
||||
if self.wait_on_ratelimit and cv_response["status_code"] == CV_STATUS_RATELIMIT:
|
||||
logger.info(f"Rate limit encountered. Waiting for {limit_wait_time} minutes\n")
|
||||
time.sleep(limit_wait_time * 60)
|
||||
total_time_waited += limit_wait_time
|
||||
limit_wait_time = wait_times[counter]
|
||||
if counter < 3:
|
||||
counter += 1
|
||||
# don't wait much more than 20 minutes
|
||||
if total_time_waited < self.wait_on_ratelimit_time:
|
||||
continue
|
||||
|
||||
if cv_response["status_code"] != 1:
|
||||
logger.debug(
|
||||
f"{self.name} query failed with error #{cv_response['status_code']}: [{cv_response['error']}]."
|
||||
)
|
||||
raise TalkerNetworkError(self.name, 0, f"{cv_response['status_code']}: {cv_response['error']}")
|
||||
|
||||
# it's all good
|
||||
break
|
||||
return cv_response
|
||||
return cv_response
|
||||
|
||||
def _get_url_content(self, url: str, params: dict[str, Any]) -> Any:
|
||||
# connect to server:
|
||||
# if there is a 500 error, try a few more times before giving up
|
||||
# any other error, just bail
|
||||
for tries in range(3):
|
||||
limit_counter = 0
|
||||
tries = 0
|
||||
while tries < 4:
|
||||
try:
|
||||
resp = requests.get(url, params=params, headers={"user-agent": "comictagger/" + self.version})
|
||||
if resp.status_code == 200:
|
||||
@ -472,6 +456,19 @@ class ComicVineTalker(ComicTalker):
|
||||
logger.debug(f"Try #{tries + 1}: ")
|
||||
time.sleep(1)
|
||||
logger.debug(str(resp.status_code))
|
||||
tries += 1
|
||||
if resp.status_code == requests.status_codes.codes.TOO_MANY_REQUESTS:
|
||||
logger.info(f"{self.name} rate limit encountered. Waiting for 10 seconds\n")
|
||||
time.sleep(10)
|
||||
limit_counter += 1
|
||||
if limit_counter > 3:
|
||||
# Tried 3 times, inform user to check CV website.
|
||||
logger.error(f"{self.name} rate limit error. Exceeded 3 retires.")
|
||||
raise TalkerNetworkError(
|
||||
self.name,
|
||||
3,
|
||||
"Rate Limit Error: Check your current API usage limit at https://comicvine.gamespot.com/api/",
|
||||
)
|
||||
else:
|
||||
break
|
||||
|
||||
@ -509,12 +506,15 @@ class ComicVineTalker(ComicTalker):
|
||||
ComicSeries(
|
||||
aliases=aliases.splitlines(),
|
||||
count_of_issues=record.get("count_of_issues", 0),
|
||||
count_of_volumes=None,
|
||||
description=record.get("description", ""),
|
||||
id=str(record["id"]),
|
||||
image_url=image_url,
|
||||
name=record["name"],
|
||||
publisher=pub_name,
|
||||
start_year=start_year,
|
||||
genres=[],
|
||||
format=None,
|
||||
)
|
||||
)
|
||||
|
||||
@ -523,7 +523,7 @@ class ComicVineTalker(ComicTalker):
|
||||
def _format_issue_results(self, issue_results: list[CVIssue], complete: bool = False) -> list[ComicIssue]:
|
||||
formatted_results = []
|
||||
for record in issue_results:
|
||||
# Extract image super and thumb to name only
|
||||
# Extract image super
|
||||
if record.get("image") is None:
|
||||
image_url = ""
|
||||
else:
|
||||
@ -568,6 +568,7 @@ class ComicVineTalker(ComicTalker):
|
||||
id=str(record["id"]),
|
||||
image_url=image_url,
|
||||
issue_number=record["issue_number"],
|
||||
volume=None,
|
||||
name=record["name"],
|
||||
site_detail_url=record.get("site_detail_url", ""),
|
||||
series=series, # CV uses volume to mean series
|
||||
@ -576,6 +577,13 @@ class ComicVineTalker(ComicTalker):
|
||||
locations=location_list,
|
||||
teams=teams_list,
|
||||
story_arcs=story_list,
|
||||
critical_rating=0,
|
||||
maturity_rating="",
|
||||
manga="",
|
||||
language="",
|
||||
country="",
|
||||
genres=[],
|
||||
tags=[],
|
||||
credits=persons_list,
|
||||
complete=complete,
|
||||
)
|
||||
|
@ -40,11 +40,12 @@ install_requires =
|
||||
pathvalidate
|
||||
pillow>=9.1.0,<10
|
||||
pycountry
|
||||
pyrate-limiter
|
||||
rapidfuzz>=2.12.0
|
||||
requests==2.*
|
||||
settngs==0.6.3
|
||||
settngs==0.7.1
|
||||
text2digits
|
||||
typing-extensions
|
||||
typing-extensions>=4.3.0
|
||||
wordninja
|
||||
python_requires = >=3.9
|
||||
|
||||
@ -280,3 +281,4 @@ extend-ignore = E203, E501, A003
|
||||
extend-exclude = venv, scripts, build, dist, comictaggerlib/ctversion.py
|
||||
per-file-ignores =
|
||||
comictaggerlib/cli.py: T20
|
||||
build-tools/generate_settngs.py: T20
|
||||
|
@ -7,6 +7,7 @@ from comicapi import utils
|
||||
search_results = [
|
||||
comictalker.resulttypes.ComicSeries(
|
||||
count_of_issues=1,
|
||||
count_of_volumes=1,
|
||||
description="this is a description",
|
||||
id="1",
|
||||
image_url="https://test.org/image/1",
|
||||
@ -14,9 +15,12 @@ search_results = [
|
||||
publisher="test",
|
||||
start_year=0,
|
||||
aliases=[],
|
||||
genres=[],
|
||||
format=None,
|
||||
),
|
||||
comictalker.resulttypes.ComicSeries(
|
||||
count_of_issues=1,
|
||||
count_of_volumes=1,
|
||||
description="this is a description",
|
||||
id="2",
|
||||
image_url="https://test.org/image/2",
|
||||
@ -24,6 +28,8 @@ search_results = [
|
||||
publisher="test",
|
||||
start_year=0,
|
||||
aliases=[],
|
||||
genres=[],
|
||||
format=None,
|
||||
),
|
||||
]
|
||||
|
||||
@ -59,6 +65,14 @@ metadata = [
|
||||
comicapi.genericmetadata.GenericMetadata(series="", issue="2", title="never"),
|
||||
comicapi.genericmetadata.md_test.replace(series=None, issue="2", title="never"),
|
||||
),
|
||||
(
|
||||
comicapi.genericmetadata.GenericMetadata(series="", issue="", title="never"),
|
||||
comicapi.genericmetadata.md_test.replace(series=None, issue=None, title="never"),
|
||||
),
|
||||
(
|
||||
comicapi.genericmetadata.GenericMetadata(series="", issue=None, title="never"),
|
||||
comicapi.genericmetadata.md_test.replace(series=None, issue="1", title="never"),
|
||||
),
|
||||
(
|
||||
comicapi.genericmetadata.GenericMetadata(),
|
||||
comicapi.genericmetadata.md_test.copy(),
|
||||
|
@ -165,6 +165,7 @@ comic_issue_result = ComicIssue(
|
||||
id=str(cv_issue_result["results"]["id"]),
|
||||
image_url=cv_issue_result["results"]["image"]["super_url"],
|
||||
issue_number=cv_issue_result["results"]["issue_number"],
|
||||
volume=None,
|
||||
name=cv_issue_result["results"]["name"],
|
||||
site_detail_url=cv_issue_result["results"]["site_detail_url"],
|
||||
series=ComicSeries(
|
||||
@ -172,10 +173,13 @@ comic_issue_result = ComicIssue(
|
||||
name=cv_issue_result["results"]["volume"]["name"],
|
||||
aliases=[],
|
||||
count_of_issues=cv_volume_result["results"]["count_of_issues"],
|
||||
count_of_volumes=None,
|
||||
description=cv_volume_result["results"]["description"],
|
||||
image_url=cv_volume_result["results"]["image"]["super_url"],
|
||||
publisher=cv_volume_result["results"]["publisher"]["name"],
|
||||
start_year=int(cv_volume_result["results"]["start_year"]),
|
||||
genres=[],
|
||||
format=None,
|
||||
),
|
||||
characters=[],
|
||||
alt_image_urls=[],
|
||||
@ -183,6 +187,13 @@ comic_issue_result = ComicIssue(
|
||||
credits=[],
|
||||
locations=[],
|
||||
story_arcs=[],
|
||||
critical_rating=0,
|
||||
maturity_rating="",
|
||||
manga="",
|
||||
language="",
|
||||
country="",
|
||||
genres=[],
|
||||
tags=[],
|
||||
teams=[],
|
||||
)
|
||||
date = utils.parse_date_str(cv_issue_result["results"]["cover_date"])
|
||||
@ -198,7 +209,7 @@ cv_md = comicapi.genericmetadata.GenericMetadata(
|
||||
month=date[1],
|
||||
year=date[2],
|
||||
day=date[0],
|
||||
issue_count=None,
|
||||
issue_count=6,
|
||||
volume=None,
|
||||
genre=None,
|
||||
language=None,
|
||||
|
@ -6,6 +6,7 @@ import shutil
|
||||
import pytest
|
||||
from importlib_metadata import entry_points
|
||||
|
||||
import comicapi.archivers.rar
|
||||
import comicapi.comicarchive
|
||||
import comicapi.genericmetadata
|
||||
from testing.filenames import datadir
|
||||
|
@ -9,7 +9,7 @@ from testing.comicdata import search_results
|
||||
def test_create_cache(config, mock_version):
|
||||
config, definitions = config
|
||||
comictalker.comiccacher.ComicCacher(config.runtime_config.user_cache_dir, mock_version[0])
|
||||
assert (config.runtime_config.user_cache_dir).exists()
|
||||
assert config.runtime_config.user_cache_dir.exists()
|
||||
|
||||
|
||||
def test_search_results(comic_cache):
|
||||
|
@ -9,7 +9,6 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import settngs
|
||||
from PIL import Image
|
||||
|
||||
import comicapi.comicarchive
|
||||
@ -128,7 +127,7 @@ def mock_version(monkeypatch):
|
||||
monkeypatch.setattr(comictaggerlib.ctversion, "__version__", version)
|
||||
monkeypatch.setattr(comictaggerlib.ctversion, "version_tuple", version_tuple)
|
||||
monkeypatch.setattr(comictaggerlib.ctversion, "__version_tuple__", version_tuple)
|
||||
yield (version, version_tuple)
|
||||
yield version, version_tuple
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -154,11 +153,13 @@ def seed_all_publishers(monkeypatch):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(settings_manager, tmp_path):
|
||||
comictaggerlib.ctsettings.register_commandline_settings(settings_manager)
|
||||
comictaggerlib.ctsettings.register_file_settings(settings_manager)
|
||||
defaults = settings_manager.get_namespace(settings_manager.defaults())
|
||||
defaults[0].runtime_config = comictaggerlib.ctsettings.ComicTaggerPaths(tmp_path / "config")
|
||||
def config(tmp_path):
|
||||
from comictaggerlib.main import App
|
||||
|
||||
app = App()
|
||||
app.register_settings()
|
||||
|
||||
defaults = app.parse_settings(comictaggerlib.ctsettings.ComicTaggerPaths(tmp_path / "config"), "")
|
||||
defaults[0].runtime_config.user_data_dir.mkdir(parents=True, exist_ok=True)
|
||||
defaults[0].runtime_config.user_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
defaults[0].runtime_config.user_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
@ -167,12 +168,6 @@ def config(settings_manager, tmp_path):
|
||||
yield defaults
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings_manager():
|
||||
manager = settngs.Manager()
|
||||
yield manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def comic_cache(config, mock_version) -> Generator[comictalker.comiccacher.ComicCacher, Any, None]:
|
||||
yield comictalker.comiccacher.ComicCacher(config[0].runtime_config.user_cache_dir, mock_version[0])
|
||||
|
@ -5,6 +5,7 @@ import os
|
||||
import pytest
|
||||
|
||||
import comicapi.utils
|
||||
import comictalker.talker_utils
|
||||
|
||||
|
||||
def test_os_sorted():
|
||||
@ -200,3 +201,20 @@ titles_2 = [
|
||||
@pytest.mark.parametrize("value, result", titles_2)
|
||||
def test_sanitize_title(value, result):
|
||||
assert comicapi.utils.sanitize_title(value) == result.casefold()
|
||||
|
||||
|
||||
urls = [
|
||||
("", ""),
|
||||
("http://test.test", "http://test.test/"),
|
||||
("http://test.test/", "http://test.test/"),
|
||||
("http://test.test/..", "http://test.test/"),
|
||||
("http://test.test/../hello", "http://test.test/hello/"),
|
||||
("http://test.test/../hello/", "http://test.test/hello/"),
|
||||
("http://test.test/../hello/..", "http://test.test/"),
|
||||
("http://test.test/../hello/../", "http://test.test/"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, result", urls)
|
||||
def test_fix_url(value, result):
|
||||
assert comictalker.talker_utils.fix_url(value) == result
|
||||
|
Reference in New Issue
Block a user