Compare commits

...

4 Commits

Author SHA1 Message Date
Timmy Welch
4d02e88905 revamp scripts access 2022-05-27 12:22:45 -07:00
lordwelch
c870ed86e0 Update find_dupes.py
Update to Python 3
Use ImageHasher to compare and find images
Use filetype to find all images regardless of imagetype
2022-05-24 12:38:52 -07:00
lordwelch
923f551983 Merge branch 'argparse' into develop 2022-05-24 12:36:22 -07:00
lordwelch
97e65f10cf argparse... 2022-05-24 11:45:17 -07:00
24 changed files with 1485 additions and 604 deletions

View File

@ -64,6 +64,7 @@ class MetaDataStyle:
CIX = 1
COMET = 2
name = ["ComicBookLover", "ComicRack", "CoMet"]
short = ["cbl", "cr", "comet"]
class UnknownArchiver:

View File

@ -21,7 +21,7 @@ import pathlib
import re
import unicodedata
from collections import defaultdict
from typing import Any, List, Optional, Union
from typing import Any, Iterable, List, Optional, Union
import pycountry
@ -233,7 +233,7 @@ class ImprintDict(dict):
if the key does not exist the key is returned as the publisher unchanged
"""
def __init__(self, publisher, mapping=(), **kwargs):
def __init__(self, publisher: str, mapping: Iterable = (), **kwargs: Any):
super().__init__(mapping, **kwargs)
self.publisher = publisher

View File

@ -16,12 +16,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import argparse
import json
import logging
import os
import sys
from pprint import pprint
from typing import cast
from comicapi import utils
from comicapi.comicarchive import ComicArchive, MetaDataStyle
@ -30,18 +30,19 @@ from comictaggerlib.cbltransformer import CBLTransformer
from comictaggerlib.comicvinetalker import ComicVineTalker, ComicVineTalkerException
from comictaggerlib.filerenamer import FileRenamer
from comictaggerlib.issueidentifier import IssueIdentifier
from comictaggerlib.options import Options
from comictaggerlib.resulttypes import IssueResult, MultipleMatch, OnlineMatchResults
from comictaggerlib.settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
def actual_issue_data_fetch(match: IssueResult, settings: ComicTaggerSettings, opts: Options) -> GenericMetadata:
def actual_issue_data_fetch(
match: IssueResult, settings: ComicTaggerSettings, opts: argparse.Namespace
) -> GenericMetadata:
# now get the particular issue data
try:
comic_vine = ComicVineTalker()
comic_vine.wait_for_rate_limit = opts.wait_and_retry_on_rate_limit
comic_vine.wait_for_rate_limit = opts.wait_on_cv_rate_limit
cv_md = comic_vine.fetch_issue_data(match["volume_id"], match["issue_number"], settings)
except ComicVineTalkerException:
logger.exception("Network error while getting issue details. Save aborted")
@ -53,10 +54,10 @@ def actual_issue_data_fetch(match: IssueResult, settings: ComicTaggerSettings, o
return cv_md
def actual_metadata_save(ca: ComicArchive, opts: Options, md: GenericMetadata) -> bool:
def actual_metadata_save(ca: ComicArchive, opts: argparse.Namespace, md: GenericMetadata) -> bool:
if not opts.dryrun:
# write out the new data
if not ca.write_metadata(md, opts.data_style if opts.data_style is not None else 0):
if not ca.write_metadata(md, opts.type if opts.type is not None else 0):
logger.error("The tag save seemed to fail!")
return False
@ -74,7 +75,7 @@ def actual_metadata_save(ca: ComicArchive, opts: Options, md: GenericMetadata) -
def display_match_set_for_choice(
label: str, match_set: MultipleMatch, opts: Options, settings: ComicTaggerSettings
label: str, match_set: MultipleMatch, opts: argparse.Namespace, settings: ComicTaggerSettings
) -> None:
print(f"{match_set.ca.path} -- {label}:")
@ -103,11 +104,9 @@ def display_match_set_for_choice(
# save the data!
# we know at this point, that the file is all good to go
ca = match_set.ca
md = create_local_metadata(
opts, ca, ca.has_metadata(opts.data_style if opts.data_style is not None else 0), settings
)
md = create_local_metadata(opts, ca, ca.has_metadata(opts.type if opts.type is not None else 0), settings)
cv_md = actual_issue_data_fetch(match_set.matches[int(i) - 1], settings, opts)
if opts.overwrite_metadata:
if opts.overwrite:
md = cv_md
else:
md.overlay(cv_md)
@ -118,7 +117,9 @@ def display_match_set_for_choice(
actual_metadata_save(ca, opts, md)
def post_process_matches(match_results: OnlineMatchResults, opts: Options, settings: ComicTaggerSettings) -> None:
def post_process_matches(
match_results: OnlineMatchResults, opts: argparse.Namespace, settings: ComicTaggerSettings
) -> None:
# now go through the match results
if opts.show_save_summary:
if len(match_results.good_matches) > 0:
@ -161,14 +162,14 @@ def post_process_matches(match_results: OnlineMatchResults, opts: Options, setti
display_match_set_for_choice(label, match_set, opts, settings)
def cli_mode(opts: Options, settings: ComicTaggerSettings) -> None:
if len(opts.file_list) < 1:
def cli_mode(opts: argparse.Namespace, settings: ComicTaggerSettings) -> None:
if len(opts.files) < 1:
logger.error("You must specify at least one filename. Use the -h option for more info")
return
match_results = OnlineMatchResults()
for f in opts.file_list:
for f in opts.files:
process_file_cli(f, opts, settings, match_results)
sys.stdout.flush()
@ -176,7 +177,7 @@ def cli_mode(opts: Options, settings: ComicTaggerSettings) -> None:
def create_local_metadata(
opts: Options, ca: ComicArchive, has_desired_tags: bool, settings: ComicTaggerSettings
opts: argparse.Namespace, ca: ComicArchive, has_desired_tags: bool, settings: ComicTaggerSettings
) -> GenericMetadata:
md = GenericMetadata()
md.set_default_page_list(ca.get_number_of_pages())
@ -190,17 +191,17 @@ def create_local_metadata(
settings.remove_publisher,
opts.split_words,
)
if opts.overwrite_metadata:
if opts.overwrite:
md = f_md
else:
md.overlay(f_md)
if has_desired_tags:
t_md = ca.read_metadata(opts.data_style if opts.data_style is not None else 0)
t_md = ca.read_metadata(opts.type if opts.type is not None else 0)
md.overlay(t_md)
# finally, use explicit stuff
if opts.overwrite_metadata and not opts.metadata.is_empty:
if opts.overwrite and not opts.metadata.is_empty:
md = opts.metadata
else:
md.overlay(opts.metadata)
@ -209,9 +210,9 @@ def create_local_metadata(
def process_file_cli(
filename: str, opts: Options, settings: ComicTaggerSettings, match_results: OnlineMatchResults
filename: str, opts: argparse.Namespace, settings: ComicTaggerSettings, match_results: OnlineMatchResults
) -> None:
batch_mode = len(opts.file_list) > 1
batch_mode = len(opts.files) > 1
ca = ComicArchive(filename, settings.rar_exe_path, ComicTaggerSettings.get_graphic("nocover.png"))
@ -223,7 +224,7 @@ def process_file_cli(
logger.error("Sorry, but %s is not a comic archive!", filename)
return
if not ca.is_writable() and (opts.delete_tags or opts.copy_tags or opts.save_tags or opts.rename_file):
if not ca.is_writable() and (opts.delete or opts.copy or opts.save or opts.rename):
logger.error("This archive is not writable for that tag type")
return
@ -235,9 +236,9 @@ def process_file_cli(
if ca.has_comet():
has[MetaDataStyle.COMET] = True
if opts.print_tags:
if opts.print:
if opts.data_style is None:
if opts.type is None:
page_count = ca.get_number_of_pages()
brief = ""
@ -275,7 +276,7 @@ def process_file_cli(
print()
if opts.data_style is None or opts.data_style == MetaDataStyle.CIX:
if opts.type is None or opts.type == MetaDataStyle.CIX:
if has[MetaDataStyle.CIX]:
print("--------- ComicRack tags ---------")
if opts.raw:
@ -283,7 +284,7 @@ def process_file_cli(
else:
print(ca.read_cix())
if opts.data_style is None or opts.data_style == MetaDataStyle.CBI:
if opts.type is None or opts.type == MetaDataStyle.CBI:
if has[MetaDataStyle.CBI]:
print("------- ComicBookLover tags -------")
if opts.raw:
@ -291,7 +292,7 @@ def process_file_cli(
else:
print(ca.read_cbi())
if opts.data_style is None or opts.data_style == MetaDataStyle.COMET:
if opts.type is None or opts.type == MetaDataStyle.COMET:
if has[MetaDataStyle.COMET]:
print("----------- CoMet tags -----------")
if opts.raw:
@ -299,11 +300,11 @@ def process_file_cli(
else:
print(ca.read_comet())
elif opts.delete_tags:
style_name = MetaDataStyle.name[cast(int, opts.data_style)]
if has[cast(int, opts.data_style)]:
elif opts.delete:
style_name = MetaDataStyle.name[opts.type]
if has[opts.type]:
if not opts.dryrun:
if not ca.remove_metadata(cast(int, opts.data_style)):
if not ca.remove_metadata(opts.type):
print(f"{filename}: Tag removal seemed to fail!")
else:
print(f"{filename}: Removed {style_name} tags.")
@ -312,24 +313,24 @@ def process_file_cli(
else:
print(f"{filename}: This archive doesn't have {style_name} tags to remove.")
elif opts.copy_tags:
dst_style_name = MetaDataStyle.name[cast(int, opts.data_style)]
if opts.no_overwrite and has[cast(int, opts.data_style)]:
elif opts.copy:
dst_style_name = MetaDataStyle.name[opts.type]
if opts.no_overwrite and has[opts.type]:
print(f"{filename}: Already has {dst_style_name} tags. Not overwriting.")
return
if opts.copy_source == opts.data_style:
if opts.copy == opts.type:
print(f"{filename}: Destination and source are same: {dst_style_name}. Nothing to do.")
return
src_style_name = MetaDataStyle.name[cast(int, opts.copy_source)]
if has[cast(int, opts.copy_source)]:
src_style_name = MetaDataStyle.name[opts.copy]
if has[opts.copy]:
if not opts.dryrun:
md = ca.read_metadata(cast(int, opts.copy_source))
md = ca.read_metadata(opts.copy)
if settings.apply_cbl_transform_on_bulk_operation and opts.data_style == MetaDataStyle.CBI:
if settings.apply_cbl_transform_on_bulk_operation and opts.type == MetaDataStyle.CBI:
md = CBLTransformer(md, settings).apply()
if not ca.write_metadata(md, cast(int, opts.data_style)):
if not ca.write_metadata(md, opts.type):
print(f"{filename}: Tag copy seemed to fail!")
else:
print(f"{filename}: Copied {src_style_name} tags to {dst_style_name}.")
@ -338,35 +339,35 @@ def process_file_cli(
else:
print(f"{filename}: This archive doesn't have {src_style_name} tags to copy.")
elif opts.save_tags:
elif opts.save:
if opts.no_overwrite and has[cast(int, opts.data_style)]:
print(f"{filename}: Already has {MetaDataStyle.name[cast(int, opts.data_style)]} tags. Not overwriting.")
if opts.no_overwrite and has[opts.type]:
print(f"{filename}: Already has {MetaDataStyle.name[opts.type]} tags. Not overwriting.")
return
if batch_mode:
print(f"Processing {ca.path}...")
md = create_local_metadata(opts, ca, has[cast(int, opts.data_style)], settings)
md = create_local_metadata(opts, ca, has[opts.type], settings)
if md.issue is None or md.issue == "":
if opts.assume_issue_is_one_if_not_set:
if opts.assume_issue_one:
md.issue = "1"
# now, search online
if opts.search_online:
if opts.issue_id is not None:
if opts.online:
if opts.id is not None:
# we were given the actual ID to search with
try:
comic_vine = ComicVineTalker()
comic_vine.wait_for_rate_limit = opts.wait_and_retry_on_rate_limit
cv_md = comic_vine.fetch_issue_data_by_issue_id(opts.issue_id, settings)
comic_vine.wait_for_rate_limit = opts.wait_on_cv_rate_limit
cv_md = comic_vine.fetch_issue_data_by_issue_id(opts.id, settings)
except ComicVineTalkerException:
logger.exception("Network error while getting issue details. Save aborted")
match_results.fetch_data_failures.append(str(ca.path.absolute()))
return
if cv_md is None:
logger.error("No match for ID %s was found.", opts.issue_id)
logger.error("No match for ID %s was found.", opts.id)
match_results.no_matches.append(str(ca.path.absolute()))
return
@ -387,7 +388,7 @@ def process_file_cli(
# use our overlayed MD struct to search
ii.set_additional_metadata(md)
ii.only_use_additional_meta_data = True
ii.wait_and_retry_on_rate_limit = opts.wait_and_retry_on_rate_limit
ii.wait_and_retry_on_rate_limit = opts.wait_on_cv_rate_limit
ii.set_output_function(myoutput)
ii.cover_page_index = md.get_cover_page_index_list()[0]
matches = ii.search()
@ -422,7 +423,7 @@ def process_file_cli(
logger.error("Online search: Multiple good matches. Save aborted")
match_results.multiple_matches.append(MultipleMatch(ca, matches))
return
if low_confidence and opts.abortOnLowConfidence:
if low_confidence and opts.abort_on_low_confidence:
logger.error("Online search: Low confidence match. Save aborted")
match_results.low_confidence_matches.append(MultipleMatch(ca, matches))
return
@ -439,7 +440,7 @@ def process_file_cli(
match_results.fetch_data_failures.append(str(ca.path.absolute()))
return
if opts.overwrite_metadata:
if opts.overwrite:
md = cv_md
else:
md.overlay(cv_md)
@ -453,14 +454,14 @@ def process_file_cli(
else:
match_results.good_matches.append(str(ca.path.absolute()))
elif opts.rename_file:
elif opts.rename:
msg_hdr = ""
if batch_mode:
msg_hdr = f"{ca.path}: "
if opts.data_style is not None:
use_tags = has[opts.data_style]
if opts.type is not None:
use_tags = has[opts.type]
else:
use_tags = False
@ -529,7 +530,7 @@ def process_file_cli(
rar_file = os.path.abspath(os.path.abspath(filename))
new_file = os.path.splitext(rar_file)[0] + ".cbz"
if opts.abort_export_on_conflict and os.path.lexists(new_file):
if opts.abort_on_conflict and os.path.lexists(new_file):
print(msg_hdr + f"{os.path.split(new_file)[1]} already exists in the that folder.")
return

View File

@ -438,9 +438,9 @@ class ComicVineTalker:
# Now, map the Comic Vine data to generic metadata
return self.map_cv_data_to_metadata(volume_results, issue_results, settings)
def fetch_issue_data_by_issue_id(self, issue_id: str, settings: ComicTaggerSettings) -> GenericMetadata:
def fetch_issue_data_by_issue_id(self, issue_id: int, settings: ComicTaggerSettings) -> GenericMetadata:
issue_url = self.api_base_url + "/issue/" + CVTypeID.Issue + "-" + issue_id
issue_url = self.api_base_url + "/issue/" + CVTypeID.Issue + "-" + str(issue_id)
params = {"api_key": self.api_key, "format": "json"}
cv_response = self.get_cv_content(issue_url, params)

View File

@ -108,6 +108,34 @@ class FileSelectionList(QtWidgets.QWidget):
def deselect_all(self) -> None:
self.twList.setRangeSelected(QtWidgets.QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, 5), False)
def remove_paths(self, file_list: list[str]) -> None:
flist = file_list
current_removed = False
self.twList.setSortingEnabled(False)
for row in reversed(range(self.twList.rowCount())):
print(row)
ca = self.get_archive_by_row(row)
print(flist, str(ca.path.absolute()))
if ca and str(ca.path.absolute()) in flist:
flist.remove(str(ca.path.absolute()))
self.twList.removeRow(row)
if row == self.twList.currentRow():
current_removed = True
self.twList.setSortingEnabled(True)
self.items_removed(current_removed)
def items_removed(self, current_removed: bool):
if self.twList.rowCount() > 0 and current_removed:
# since on a removal, we select row 0, make sure callback occurs if
# we're already there
if self.twList.currentRow() == 0:
self.current_item_changed_cb(self.twList.currentItem(), None)
self.twList.selectRow(0)
elif self.twList.rowCount() <= 0:
self.listCleared.emit()
def remove_archive_list(self, ca_list: list[ComicArchive]) -> None:
self.twList.setSortingEnabled(False)
current_removed = False
@ -120,15 +148,7 @@ class FileSelectionList(QtWidgets.QWidget):
self.twList.removeRow(row)
break
self.twList.setSortingEnabled(True)
if self.twList.rowCount() > 0 and current_removed:
# since on a removal, we select row 0, make sure callback occurs if
# we're already there
if self.twList.currentRow() == 0:
self.current_item_changed_cb(self.twList.currentItem(), None)
self.twList.selectRow(0)
elif self.twList.rowCount() <= 0:
self.listCleared.emit()
self.items_removed(current_removed)
def get_archive_by_row(self, row: int) -> Optional[ComicArchive]:
if row >= 0:
@ -166,14 +186,7 @@ class FileSelectionList(QtWidgets.QWidget):
self.twList.setSortingEnabled(True)
self.twList.currentItemChanged.connect(self.current_item_changed_cb)
if self.twList.rowCount() > 0:
# since on a removal, we select row 0, make sure callback occurs if
# we're already there
if self.twList.currentRow() == 0:
self.current_item_changed_cb(self.twList.currentItem(), None)
self.twList.selectRow(0)
else:
self.listCleared.emit()
self.items_removed(True)
def add_path_list(self, pathlist: list[str]) -> None:
@ -345,11 +358,11 @@ class FileSelectionList(QtWidgets.QWidget):
fi.ca.read_cix()
fi.ca.has_cbi()
def get_selected_archive_list(self) -> List[ComicArchive]:
def get_archive_list(self, all_comics=False) -> List[ComicArchive]:
ca_list: List[ComicArchive] = []
for r in range(self.twList.rowCount()):
item = self.twList.item(r, FileSelectionList.dataColNum)
if item.isSelected():
if item.isSelected() or all_comics:
fi: FileInfo = item.data(QtCore.Qt.ItemDataRole.UserRole)
ca_list.append(fi.ca)

View File

@ -32,7 +32,7 @@ from comicapi import utils
from comictaggerlib import cli
from comictaggerlib.comicvinetalker import ComicVineTalker
from comictaggerlib.ctversion import version
from comictaggerlib.options import Options
from comictaggerlib.options import parse_cmd_line
from comictaggerlib.settings import ComicTaggerSettings
logger = logging.getLogger("comictagger")
@ -111,8 +111,7 @@ def update_publishers() -> None:
def ctmain() -> None:
opts = Options()
opts.parse_cmd_line_args()
opts = parse_cmd_line()
SETTINGS = ComicTaggerSettings(opts.config_path)
os.makedirs(ComicTaggerSettings.get_settings_folder() / "logs", exist_ok=True)
@ -138,7 +137,7 @@ def ctmain() -> None:
if opts.cv_api_key != SETTINGS.cv_api_key:
SETTINGS.cv_api_key = opts.cv_api_key
SETTINGS.save()
if opts.only_set_key:
if opts.only_set_cv_key:
print("Key set")
return
@ -203,7 +202,7 @@ def ctmain() -> None:
QtWidgets.QApplication.processEvents()
try:
tagger_window = TaggerWindow(opts.file_list, SETTINGS, opts=opts)
tagger_window = TaggerWindow(opts.files, SETTINGS, opts=opts)
tagger_window.setWindowIcon(QtGui.QIcon(ComicTaggerSettings.get_graphic("app.png")))
tagger_window.show()

View File

@ -14,12 +14,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import getopt
import argparse
import logging
import os
import platform
import sys
from typing import Optional
from comicapi import utils
from comicapi.comicarchive import MetaDataStyle
@ -29,440 +28,367 @@ from comictaggerlib import ctversion
logger = logging.getLogger(__name__)
class Options:
help_text = """Usage: {0} [option] ... [file [files ...]]
def define_args() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="""A utility for reading and writing metadata to comic archives.
A utility for reading and writing metadata to comic archives.
If no options are given, %(prog)s will run in windowed mode.""",
epilog="For more help visit the wiki at: https://github.com/comictagger/comictagger/wiki",
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument(
"--version",
action="store_true",
help="Display version.",
)
commands = parser.add_mutually_exclusive_group()
commands.add_argument(
"-p",
"--print",
action="store_true",
help="""Print out tag info from file. Specify type\n(via -t) to get only info of that tag type.\n\n""",
)
commands.add_argument(
"-d",
"--delete",
action="store_true",
help="Deletes the tag block of specified type (via -t).\n",
)
commands.add_argument(
"-c",
"--copy",
type=metadata_type,
metavar="{CR,CBL,COMET}",
help="Copy the specified source tag block to\ndestination style specified via -t\n(potentially lossy operation).\n\n",
)
commands.add_argument(
"-s",
"--save",
action="store_true",
help="Save out tags as specified type (via -t).\nMust specify also at least -o, -f, or -m.\n\n",
)
commands.add_argument(
"-r",
"--rename",
action="store_true",
help="Rename the file based on specified tag style.",
)
commands.add_argument(
"-e",
"--export-to-zip",
action="store_true",
help="Export RAR archive to Zip format.",
)
commands.add_argument(
"--only-set-cv-key",
action="store_true",
help="Only set the Comic Vine API key and quit.\n\n",
)
parser.add_argument(
"-1",
"--assume-issue-one",
action="store_true",
help="""Assume issue number is 1 if not found (relevant for -s).\n\n""",
)
parser.add_argument(
"--abort-on-conflict",
action="store_true",
help="""Don't export to zip if intended new filename\nexists (otherwise, creates a new unique filename).\n\n""",
)
parser.add_argument(
"-a",
"--auto-imprint",
action="store_true",
help="""Enables the auto imprint functionality.\ne.g. if the publisher is set to 'vertigo' it\nwill be updated to 'DC Comics' and the imprint\nproperty will be set to 'Vertigo'.\n\n""",
)
parser.add_argument(
"--config",
dest="config_path",
help="""Config directory defaults to ~/.ComicTagger\non Linux/Mac and %%APPDATA%% on Windows\n""",
)
parser.add_argument(
"--cv-api-key",
help="Use the given Comic Vine API Key (persisted in settings).",
)
parser.add_argument(
"--delete-rar",
action="store_true",
dest="delete_after_zip_export",
help="""Delete original RAR archive after successful\nexport to Zip.""",
)
parser.add_argument(
"-f",
"--parse-filename",
"--parsefilename",
action="store_true",
help="""Parse the filename to get some info,\nspecifically series name, issue number,\nvolume, and publication year.\n\n""",
)
parser.add_argument(
"--id",
dest="issue_id",
type=int,
help="""Use the issue ID when searching online.\nOverrides all other metadata.\n\n""",
)
parser.add_argument(
"-t",
"--type",
metavar="{CR,CBL,COMET}",
type=metadata_type,
help="""Specify TYPE as either CR, CBL, COMET\n(as either ComicRack, ComicBookLover,\nor CoMet style tags, respectively).\n\n""",
)
parser.add_argument(
"-o",
"--online",
action="store_true",
help="""Search online and attempt to identify file\nusing existing metadata and images in archive.\nMay be used in conjunction with -f and -m.\n\n""",
)
parser.add_argument(
"-m",
"--metadata",
type=parse_metadata_from_string,
help="""Explicitly define, as a list, some tags to be used. e.g.:\n"series=Plastic Man, publisher=Quality Comics"\n"series=Kickers^, Inc., issue=1, year=1986"\nName-Value pairs are comma separated. Use a\n"^" to escape an "=" or a ",", as shown in\nthe example above. Some names that can be\nused: series, issue, issue_count, year,\npublisher, title\n\n""",
)
parser.add_argument(
"-i",
"--interactive",
action="store_true",
help="""Interactively query the user when there are\nmultiple matches for an online search.\n\n""",
)
parser.add_argument(
"--no-overwrite",
"--nooverwrite",
action="store_true",
help="""Don't modify tag block if it already exists (relevant for -s or -c).""",
)
parser.add_argument(
"--noabort",
dest="abort_on_low_confidence",
action="store_false",
help="""Don't abort save operation when online match\nis of low confidence.\n\n""",
)
parser.add_argument(
"--nosummary",
dest="show_save_summary",
action="store_false",
help="Suppress the default summary after a save operation.\n\n",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="""Overwite all existing metadata.\nMay be used in conjunction with -o, -f and -m.\n\n""",
)
parser.add_argument(
"--raw", action="store_true", help="""With -p, will print out the raw tag block(s)\nfrom the file.\n"""
)
parser.add_argument(
"-R",
"--recursive",
action="store_true",
help="Recursively include files in sub-folders.",
)
parser.add_argument(
"-S",
"--script",
help="""Run an "add-on" python script that uses the\nComicTagger library for custom processing.\nScript arguments can follow the script name.\n\n""",
)
parser.add_argument(
"--split-words",
action="store_true",
help="""Splits words before parsing the filename.\ne.g. 'judgedredd' to 'judge dredd'\n\n""",
)
parser.add_argument(
"--terse",
action="store_true",
help="Don't say much (for print mode).",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Be noisy when doing what it does.",
)
parser.add_argument(
"-w",
"--wait-on-cv-rate-limit",
action="store_true",
help="""When encountering a Comic Vine rate limit\nerror, wait and retry query.\n\n""",
)
parser.add_argument(
"-n", "--dryrun", action="store_true", help="Don't actually modify file (only relevant for -d, -s, or -r).\n\n"
)
parser.add_argument(
"--darkmode",
action="store_true",
help="Windows only. Force a dark pallet",
)
parser.add_argument(
"-g",
"--glob",
action="store_true",
help="Windows only. Enable globbing",
)
parser.add_argument("files", nargs="*")
return parser
If no options are given, {0} will run in windowed mode.
-p, --print Print out tag info from file. Specify type
(via -t) to get only info of that tag type.
--raw With -p, will print out the raw tag block(s)
from the file.
-d, --delete Deletes the tag block of specified type (via
-t).
-c, --copy=SOURCE Copy the specified source tag block to
destination style specified via -t
(potentially lossy operation).
-s, --save Save out tags as specified type (via -t).
Must specify also at least -o, -p, or -m.
--nooverwrite Don't modify tag block if it already exists
(relevant for -s or -c).
-1, --assume-issue-one Assume issue number is 1 if not found
(relevant for -s).
-n, --dryrun Don't actually modify file (only relevant for
-d, -s, or -r).
-t, --type=TYPE Specify TYPE as either "CR", "CBL", or
"COMET" (as either ComicRack, ComicBookLover,
or CoMet style tags, respectively).
-f, --parsefilename Parse the filename to get some info,
specifically series name, issue number,
volume, and publication year.
--split-words Splits words before parsing the filename.
e.g. 'judgedredd' to 'judge dredd'
-i, --interactive Interactively query the user when there are
multiple matches for an online search.
--nosummary Suppress the default summary after a save
operation.
-o, --online Search online and attempt to identify file
using existing metadata and images in archive.
May be used in conjunction with -f and -m.
--overwrite Overwite any existing metadata.
May be used in conjunction with -o, -f and -m.
--id=ID Use the issue ID when searching online.
Overrides all other metadata.
-m, --metadata=LIST Explicitly define, as a list, some tags to be
used. e.g.:
"series=Plastic Man, publisher=Quality Comics"
"series=Kickers^, Inc., issue=1, year=1986"
Name-Value pairs are comma separated. Use a
"^" to escape an "=" or a ",", as shown in
the example above. Some names that can be
used: series, issue, issueCount, year,
publisher, title
-a, --auto-imprint Enables the auto imprint functionality.
e.g. if the publisher is set to 'vertigo' it
will be updated to 'DC Comics' and the imprint
property will be set to 'Vertigo'.
-r, --rename Rename the file based on specified tag style.
--noabort Don't abort save operation when online match
is of low confidence.
-e, --export-to-zip Export RAR archive to Zip format.
--delete-rar Delete original RAR archive after successful
export to Zip.
--abort-on-conflict Don't export to zip if intended new filename
exists (otherwise, creates a new unique
filename).
-S, --script=FILE Run an "add-on" python script that uses the
ComicTagger library for custom processing.
Script arguments can follow the script name.
-R, --recursive Recursively include files in sub-folders.
--cv-api-key=KEY Use the given Comic Vine API Key (persisted
in settings).
--only-set-cv-key Only set the Comic Vine API key and quit.
-w, --wait-on-cv-rate-limit When encountering a Comic Vine rate limit
error, wait and retry query.
-v, --verbose Be noisy when doing what it does.
--terse Don't say much (for print mode).
--darkmode Windows only. Force a dark pallet
--config=CONFIG_DIR Config directory defaults to ~/.ComicTagger
on Linux/Mac and %APPDATA% on Windows
--version Display version.
-h, --help Display this message.
def metadata_type(typ: str) -> int:
if typ.casefold() not in MetaDataStyle.short:
choices = ", ".join(MetaDataStyle.short)
raise argparse.ArgumentTypeError(f"invalid choice: {typ} (choose from {choices.upper()})")
return MetaDataStyle.short.index(typ)
For more help visit the wiki at: https://github.com/comictagger/comictagger/wiki
"""
def __init__(self) -> None:
self.data_style: Optional[int] = None
self.no_gui = False
self.filename: Optional[str] = None
self.verbose = False
self.terse = False
self.metadata = GenericMetadata()
self.auto_imprint = False
self.print_tags = False
self.copy_tags = False
self.delete_tags = False
self.export_to_zip = False
self.abort_export_on_conflict = False
self.delete_after_zip_export = False
self.search_online = False
self.dryrun = False
self.abortOnLowConfidence = True
self.save_tags = False
self.parse_filename = False
self.show_save_summary = True
self.raw = False
self.cv_api_key: Optional[str] = None
self.only_set_key = False
self.rename_file = False
self.no_overwrite = False
self.interactive = False
self.issue_id: Optional[str] = None
self.recursive = False
self.run_script = False
self.script: Optional[str] = None
self.wait_and_retry_on_rate_limit = False
self.assume_issue_is_one_if_not_set = False
self.file_list: list[str] = []
self.darkmode = False
self.copy_source: Optional[int] = None
self.config_path = ""
self.overwrite_metadata = False
self.split_words = False
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)
The caret is the special "escape character", since it's not common in
natural language text
def display_msg_and_quit(self, msg: Optional[str], code: int, show_help: bool = False) -> None:
appname = os.path.basename(sys.argv[0])
if msg is not None:
print(msg)
if show_help:
print((self.help_text.format(appname)))
example = "series=Kickers^, Inc. ,issue=1, year=1986"
"""
escaped_comma = "^,"
escaped_equals = "^="
replacement_token = "<_~_>"
md = GenericMetadata()
# First, replace escaped commas with with a unique token (to be changed back later)
mdstr = mdstr.replace(escaped_comma, replacement_token)
tmp_list = mdstr.split(",")
md_list = []
for item in tmp_list:
item = item.replace(replacement_token, ",")
md_list.append(item)
# Now build a nice dict from the list
md_dict = {}
for item in md_list:
# Make sure to fix any escaped equal signs
i = item.replace(escaped_equals, replacement_token)
key, value = i.split("=")
value = value.replace(replacement_token, "=").strip()
key = key.strip()
if key.lower() == "credit":
cred_attribs = value.split(":")
role = cred_attribs[0]
person = cred_attribs[1] if len(cred_attribs) > 1 else ""
primary = len(cred_attribs) > 2
md.add_credit(person.strip(), role.strip(), primary)
else:
print("For more help, run with '--help'")
sys.exit(code)
md_dict[key] = value
def parse_metadata_from_string(self, 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)
The caret is the special "escape character", since it's not common in
natural language text
example = "series=Kickers^, Inc. ,issue=1, year=1986"
"""
escaped_comma = "^,"
escaped_equals = "^="
replacement_token = "<_~_>"
md = GenericMetadata()
# First, replace escaped commas with with a unique token (to be changed back later)
mdstr = mdstr.replace(escaped_comma, replacement_token)
tmp_list = mdstr.split(",")
md_list = []
for item in tmp_list:
item = item.replace(replacement_token, ",")
md_list.append(item)
# Now build a nice dict from the list
md_dict = {}
for item in md_list:
# Make sure to fix any escaped equal signs
i = item.replace(escaped_equals, replacement_token)
key, value = i.split("=")
value = value.replace(replacement_token, "=").strip()
key = key.strip()
if key.lower() == "credit":
cred_attribs = value.split(":")
role = cred_attribs[0]
person = cred_attribs[1] if len(cred_attribs) > 1 else ""
primary = len(cred_attribs) > 2
md.add_credit(person.strip(), role.strip(), primary)
else:
md_dict[key] = value
# Map the dict to the metadata object
for key, value in md_dict.items():
if not hasattr(md, key):
logger.warning("'%s' is not a valid tag name", key)
else:
md.is_empty = False
setattr(md, key, value)
return md
def launch_script(self, scriptfile: str) -> None:
# we were given a script. special case for the args:
# 1. ignore everything before the -S,
# 2. pass all the ones that follow (including script name) to the script
script_args = []
for idx, arg in enumerate(sys.argv):
if arg in ["-S", "--script"]:
# found script!
script_args = sys.argv[idx + 1 :]
break
sys.argv = script_args
if not os.path.exists(scriptfile):
logger.error("Can't find %s", scriptfile)
# Map the dict to the metadata object
for key, value in md_dict.items():
if not hasattr(md, key):
raise argparse.ArgumentTypeError(f"'{key}' is not a valid tag name")
else:
# I *think* this makes sense:
# assume the base name of the file is the module name
# add the folder of the given file to the python path import module
dirname = os.path.dirname(scriptfile)
module_name = os.path.splitext(os.path.basename(scriptfile))[0]
sys.path = [dirname] + sys.path
try:
script = __import__(module_name)
md.is_empty = False
setattr(md, key, value)
return md
# Determine if the entry point exists before trying to run it
if "main" in dir(script):
script.main()
else:
logger.error("Can't find entry point 'main()' in module '%s'", module_name)
except Exception:
logger.exception("Script: %s raised an unhandled exception: ", module_name)
sys.exit(0)
def parse_cmd_line_args(self) -> None:
if platform.system() == "Darwin" and hasattr(sys, "frozen") and sys.frozen == 1:
# remove the PSN (process serial number) argument from OS/X
input_args = [a for a in sys.argv[1:] if "-psn_0_" not in a]
else:
input_args = sys.argv[1:]
# first check if we're launching a script:
for n, _ in enumerate(input_args):
if input_args[n] in ["-S", "--script"] and n + 1 < len(input_args):
# insert a "--" which will cause getopt to ignore the remaining args
# so they will be passed to the script
input_args.insert(n + 2, "--")
break
# parse command line options
def launch_script(scriptfile: str, args: list[str]) -> None:
# we were given a script. special case for the args:
# 1. ignore everything before the -S,
# 2. pass all the ones that follow (including script name) to the script
if not os.path.exists(scriptfile):
logger.error("Can't find %s", scriptfile)
else:
# I *think* this makes sense:
# assume the base name of the file is the module name
# add the folder of the given file to the python path import module
dirname = os.path.dirname(scriptfile)
module_name = os.path.splitext(os.path.basename(scriptfile))[0]
sys.path = [dirname] + sys.path
try:
opts, args = getopt.getopt(
input_args,
"hpdt:fm:vownsrc:ieRS:1",
[
"help",
"print",
"delete",
"type=",
"copy=",
"parsefilename",
"metadata=",
"verbose",
"online",
"dryrun",
"save",
"rename",
"raw",
"noabort",
"terse",
"nooverwrite",
"interactive",
"nosummary",
"version",
"id=",
"recursive",
"script=",
"export-to-zip",
"delete-rar",
"abort-on-conflict",
"assume-issue-one",
"cv-api-key=",
"only-set-cv-key",
"wait-on-cv-rate-limit",
"darkmode",
"config=",
"overwrite",
"split-words",
],
)
script = __import__(module_name)
except getopt.GetoptError as err:
self.display_msg_and_quit(str(err), 2)
# process options
for o, a in opts:
if o in ("-h", "--help"):
self.display_msg_and_quit(None, 0, show_help=True)
if o in ("-v", "--verbose"):
self.verbose = True
if o in ("-S", "--script"):
self.run_script = True
self.script = a
if o in ("-R", "--recursive"):
self.recursive = True
if o in ("-p", "--print"):
self.print_tags = True
if o in ("-d", "--delete"):
self.delete_tags = True
if o in ("-i", "--interactive"):
self.interactive = True
if o in ("-a", "--auto-imprint"):
self.auto_imprint = True
if o in ("-c", "--copy"):
self.copy_tags = True
if a.lower() == "cr":
self.copy_source = MetaDataStyle.CIX
elif a.lower() == "cbl":
self.copy_source = MetaDataStyle.CBI
elif a.lower() == "comet":
self.copy_source = MetaDataStyle.COMET
else:
self.display_msg_and_quit("Invalid copy tag source type", 1)
if o in ("-o", "--online"):
self.search_online = True
if o == "--overwrite":
self.overwrite_metadata = True
if o in ("-n", "--dryrun"):
self.dryrun = True
if o in ("-m", "--metadata"):
self.metadata = self.parse_metadata_from_string(a)
if o in ("-s", "--save"):
self.save_tags = True
if o in ("-r", "--rename"):
self.rename_file = True
if o in ("-e", "--export_to_zip"):
self.export_to_zip = True
if o == "--delete-rar":
self.delete_after_zip_export = True
if o == "--abort-on-conflict":
self.abort_export_on_conflict = True
if o in ("-f", "--parsefilename"):
self.parse_filename = True
if o == "--split-words":
self.split_words = True
if o in ("-w", "--wait-on-cv-rate-limit"):
self.wait_and_retry_on_rate_limit = True
if o == "--config":
self.config_path = os.path.abspath(a)
if o == "--id":
self.issue_id = a
if o == "--raw":
self.raw = True
if o == "--noabort":
self.abortOnLowConfidence = False
if o == "--terse":
self.terse = True
if o == "--nosummary":
self.show_save_summary = False
if o in ("-1", "--assume-issue-one"):
self.assume_issue_is_one_if_not_set = True
if o == "--nooverwrite":
self.no_overwrite = True
if o == "--cv-api-key":
self.cv_api_key = a
if o == "--only-set-cv-key":
self.only_set_key = True
if o == "--version":
print(f"ComicTagger {ctversion.version}: Copyright (c) 2012-2022 ComicTagger Team")
print("Distributed under Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)")
sys.exit(0)
if o in ("-t", "--type"):
if a.lower() == "cr":
self.data_style = MetaDataStyle.CIX
elif a.lower() == "cbl":
self.data_style = MetaDataStyle.CBI
elif a.lower() == "comet":
self.data_style = MetaDataStyle.COMET
else:
self.display_msg_and_quit("Invalid tag type", 1)
if o == "--darkmode":
self.darkmode = True
if any(
[
self.print_tags,
self.delete_tags,
self.save_tags,
self.copy_tags,
self.rename_file,
self.export_to_zip,
self.only_set_key,
]
):
self.no_gui = True
count = 0
if self.run_script:
count += 1
if self.print_tags:
count += 1
if self.delete_tags:
count += 1
if self.save_tags:
count += 1
if self.copy_tags:
count += 1
if self.rename_file:
count += 1
if self.export_to_zip:
count += 1
if self.only_set_key:
count += 1
if count > 1:
self.display_msg_and_quit(
"Must choose only one action of print, delete, save, copy, rename, export, set key, or run script", 1
)
if self.script is not None:
self.launch_script(self.script)
if len(args) > 0:
if platform.system() == "Windows":
# no globbing on windows shell, so do it for them
import glob
self.file_list = []
for item in args:
self.file_list.extend(glob.glob(item))
if len(self.file_list) > 0:
self.filename = self.file_list[0]
# Determine if the entry point exists before trying to run it
if "main" in dir(script):
script.main(args)
else:
self.filename = args[0]
self.file_list = args
logger.error("Can't find entry point 'main()' in module '%s'", module_name)
except Exception:
logger.exception("Script: %s raised an unhandled exception: ", module_name)
if self.only_set_key and self.cv_api_key is None:
self.display_msg_and_quit("Key not given!", 1)
sys.exit(0)
if not self.only_set_key and self.no_gui and self.filename is None:
self.display_msg_and_quit("Command requires at least one filename!", 1)
if self.delete_tags and self.data_style is None:
self.display_msg_and_quit("Please specify the type to delete with -t", 1)
def parse_cmd_line() -> argparse.Namespace:
if self.save_tags and self.data_style is None:
self.display_msg_and_quit("Please specify the type to save with -t", 1)
if platform.system() == "Darwin" and hasattr(sys, "frozen") and sys.frozen == 1:
# remove the PSN (process serial number) argument from OS/X
input_args = [a for a in sys.argv[1:] if "-psn_0_" not in a]
else:
input_args = sys.argv[1:]
if self.copy_tags and self.data_style is None:
self.display_msg_and_quit("Please specify the type to copy to with -t", 1)
script_args = []
if self.recursive:
self.file_list = utils.get_recursive_filelist(self.file_list)
# first check if we're launching a script and split off script args
for n, _ in enumerate(input_args):
if input_args[n] == "--":
break
if input_args[n] in ["-S", "--script"] and n + 1 < len(input_args):
# insert a "--" which will cause getopt to ignore the remaining args
# so they will be passed to the script
script_args = input_args[n + 2 :]
input_args = input_args[: n + 2]
break
parser = define_args()
opts = parser.parse_args(input_args)
if opts.config_path:
opts.config_path = os.path.abspath(opts.config_path)
if opts.version:
parser.exit(
status=1,
message=f"ComicTagger {ctversion.version}: Copyright (c) 2012-2022 ComicTagger Team\n"
"Distributed under Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)\n",
)
opts.no_gui = any(
[
opts.print,
opts.delete,
opts.save,
opts.copy,
opts.rename,
opts.export_to_zip,
opts.only_set_cv_key,
]
)
if opts.script is not None:
launch_script(opts.script, script_args)
if platform.system() == "Windows" and opts.glob:
# no globbing on windows shell, so do it for them
import glob
globs = opts.files
opts.files = []
for item in globs:
opts.files.extend(glob.glob(item))
if opts.only_set_cv_key and opts.cv_api_key is None:
parser.exit(message="Key not given!", status=1)
if not opts.only_set_cv_key and opts.no_gui and not opts.files:
parser.exit(message="Command requires at least one filename!\n", status=1)
if opts.delete and opts.type is None:
parser.exit(message="Please specify the type to delete with -t\n", status=1)
if opts.save and opts.type is None:
parser.exit(message="Please specify the type to save with -t\n", status=1)
if opts.copy and opts.type is None:
parser.exit(message="Please specify the type to copy to with -t\n", status=1)
if opts.recursive:
opts.file_list = utils.get_recursive_filelist(opts.file_list)
return opts

View File

@ -38,7 +38,7 @@ class OnlineMatchResults:
class MultipleMatch:
def __init__(self, ca: ComicArchive, match_list: List[IssueResult]) -> None:
self.ca: ComicArchive = ca
self.matches = match_list
self.matches: list[IssueResult] = match_list
class SelectDetails(TypedDict):

View File

@ -49,7 +49,6 @@ from comictaggerlib.fileselectionlist import FileInfo, FileSelectionList
from comictaggerlib.issueidentifier import IssueIdentifier
from comictaggerlib.logwindow import LogWindow
from comictaggerlib.optionalmsgdialog import OptionalMessageDialog
from comictaggerlib.options import Options
from comictaggerlib.pagebrowser import PageBrowserWindow
from comictaggerlib.pagelisteditor import PageListEditor
from comictaggerlib.renamewindow import RenameWindow
@ -76,13 +75,15 @@ class TaggerWindow(QtWidgets.QMainWindow):
file_list: list[str],
settings: ComicTaggerSettings,
parent: Optional[QtWidgets.QWidget] = None,
opts: Optional[Options] = None,
opts = None,
) -> None:
super().__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("taggerwindow.ui"), self)
self.settings = settings
self.window_ref = {}
# prevent multiple instances
socket = QtNetwork.QLocalSocket(self)
socket.connectToServer(settings.install_id)
@ -148,10 +149,10 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.setWindowIcon(QtGui.QIcon(ComicTaggerSettings.get_graphic("app.png")))
# TODO: this needs to be looked at
if opts is not None and opts.data_style is not None:
if opts is not None and opts.type is not None:
# respect the command line option tag type
settings.last_selected_save_data_style = opts.data_style
settings.last_selected_load_data_style = opts.data_style
settings.last_selected_save_data_style = opts.type
settings.last_selected_load_data_style = opts.type
self.save_data_style = settings.last_selected_save_data_style
self.load_data_style = settings.last_selected_load_data_style
@ -387,6 +388,8 @@ Have fun!
self.actionPageBrowser.setStatusTip("Show the page browser")
self.actionPageBrowser.triggered.connect(self.show_page_browser)
self.actionFind_Duplicate_Comics.triggered.connect(self.find_dupes)
# Help Menu
self.actionAbout.setStatusTip("Show the " + self.appName + " info")
self.actionAbout.triggered.connect(self.about_app)
@ -420,8 +423,20 @@ Have fun!
self.toolBar.addAction(self.actionPageBrowser)
self.toolBar.addAction(self.actionAutoImprint)
def find_dupes(self):
import comictaggerscripts.find_dupes
window = comictaggerscripts.find_dupes.main((str(x.path.absolute()) for x in self.fileSelectionList.get_archive_list(True)), self.settings)
window.closed.connect(self.finish_dupes)
window.show()
self.window_ref['comictaggerscripts.find_dupes'] = window
def finish_dupes(self, files: list[str]):
self.fileSelectionList.remove_paths(files)
if 'comictaggerscripts.find_dupes' in self.window_ref:
del self.window_ref['comictaggerscripts.find_dupes']
def repackage_archive(self) -> None:
ca_list = self.fileSelectionList.get_selected_archive_list()
ca_list = self.fileSelectionList.get_archive_list()
rar_count = 0
for ca in ca_list:
if ca.is_rar():
@ -1485,7 +1500,7 @@ Please choose options below, and select OK.
def remove_tags(self, style: int) -> None:
# remove the indicated tags from the archive
ca_list = self.fileSelectionList.get_selected_archive_list()
ca_list = self.fileSelectionList.get_archive_list()
has_md_count = 0
for ca in ca_list:
if ca.has_metadata(style):
@ -1560,7 +1575,7 @@ Please choose options below, and select OK.
def copy_tags(self) -> None:
# copy the indicated tags in the archive
ca_list = self.fileSelectionList.get_selected_archive_list()
ca_list = self.fileSelectionList.get_archive_list()
has_src_count = 0
src_style = self.load_data_style
@ -1787,7 +1802,7 @@ Please choose options below, and select OK.
return success, match_results
def auto_tag(self) -> None:
ca_list = self.fileSelectionList.get_selected_archive_list()
ca_list = self.fileSelectionList.get_archive_list()
style = self.save_data_style
if len(ca_list) == 0:
@ -2011,7 +2026,7 @@ Please choose options below, and select OK to Auto-Tag.
QtWidgets.QApplication.restoreOverrideCursor()
def rename_archive(self) -> None:
ca_list = self.fileSelectionList.get_selected_archive_list()
ca_list = self.fileSelectionList.get_archive_list()
if len(ca_list) == 0:
QtWidgets.QMessageBox.information(self, "Rename", "No archives selected!")
@ -2134,7 +2149,7 @@ Please choose options below, and select OK to Auto-Tag.
self.setWindowFlags(flags)
self.show()
def auto_imprint(self):
def auto_imprint(self) -> None:
self.form_to_metadata()
self.metadata.fix_publisher()
self.metadata_to_form()

View File

@ -1182,7 +1182,7 @@
<x>0</x>
<y>0</y>
<width>1096</width>
<height>21</height>
<height>30</height>
</rect>
</property>
<widget class="QMenu" name="menuComicTagger">
@ -1245,6 +1245,7 @@
<string>Window</string>
</property>
<addaction name="actionPageBrowser"/>
<addaction name="actionFind_Duplicate_Comics"/>
</widget>
<addaction name="menuComicTagger"/>
<addaction name="menuTags"/>
@ -1455,6 +1456,11 @@
<string>Parse Filename and split words</string>
</property>
</action>
<action name="actionFind_Duplicate_Comics">
<property name="text">
<string>Find Duplicate Comics</string>
</property>
</action>
</widget>
<layoutdefault spacing="6" margin="11"/>
<resources/>

158
comictaggerscripts/dupe.ui Normal file
View File

@ -0,0 +1,158 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>729</width>
<height>406</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="sizeConstraint">
<enum>QLayout::SetMinAndMaxSize</enum>
</property>
<item>
<widget class="QListWidget" name="dupeList">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
</widget>
</item>
<item>
<widget class="QSplitter" name="splitter">
<property name="enabled">
<bool>true</bool>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="childrenCollapsible">
<bool>false</bool>
</property>
<widget class="QTableWidget" name="pageList">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<property name="columnCount">
<number>3</number>
</property>
<column>
<property name="text">
<string>name</string>
</property>
</column>
<column>
<property name="text">
<string>score</string>
</property>
</column>
<column>
<property name="text">
<string>dupe name</string>
</property>
</column>
</widget>
<widget class="QFrame" name="comicData">
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QFrame" name="comic1Data">
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout" stretch="0,0">
<item>
<widget class="QWidget" name="comic1Image" native="true">
<layout class="QVBoxLayout" name="verticalLayout_3"/>
</widget>
</item>
<item>
<widget class="QPushButton" name="comic1Delete">
<property name="toolTip">
<string>Delete Comic 1</string>
</property>
<property name="text">
<string>Delete</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="comic2Data">
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QWidget" name="comic2Image" native="true">
<layout class="QVBoxLayout" name="verticalLayout_4"/>
</widget>
</item>
<item>
<widget class="QPushButton" name="comic2Delete">
<property name="toolTip">
<string>Delete Comic 2</string>
</property>
<property name="text">
<string>Delete</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

755
comictaggerscripts/find_dupes.py Executable file
View File

@ -0,0 +1,755 @@
#!/usr/bin/python3
"""Find all duplicate comics"""
import argparse
import hashlib
import os
import shutil
import signal
import sys
import tempfile
import typing
from operator import itemgetter
from pathlib import Path
from typing import Optional
import filetype
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from comicapi import utils
from comicapi.comicarchive import ComicArchive, MetaDataStyle
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib.filerenamer import FileRenamer
from comictaggerlib.imagehasher import ImageHasher
from comictaggerlib.settings import ComicTaggerSettings
from comictaggerlib.ui.qtutils import center_window_on_parent
root = 1 << 31 - 1
something = 1 << 31 - 1
class ImageMeta:
def __init__(self, name, file_hash, image_hash, image_type, score=-1, score_file_hash=""):
self.name = name
self.file_hash = file_hash
self.image_hash = image_hash
self.type = image_type
self.score = score
self.score_file_hash = score_file_hash
class Duplicate:
"""docstring for Duplicate"""
imageHashes: dict[str, ImageMeta]
def __init__(self, path, metadata: GenericMetadata, ca: ComicArchive, cover):
self.path = path
self.digest = ""
self.ca: ComicArchive = ca
self.metadata = metadata
self.imageHashes = {}
self.duplicateImages = set()
self.extras = set()
self.extractedPath = ""
self.deletable = False
self.keeping = False
self.fileCount = 0 # Excluding comicinfo.xml
self.imageCount = 0
self.cover = cover
blake2b = hashlib.blake2b(digest_size=16)
with open(self.path, "rb") as f:
for line in f:
blake2b.update(line)
self.digest = blake2b.hexdigest()
def extract(self, directory):
if self.ca.seems_to_be_a_comic_archive():
self.extractedPath = directory
for filepath in self.ca.archiver.get_filename_list():
filename = os.path.basename(filepath)
if filename.lower() in ["comicinfo.xml"]:
continue
self.fileCount += 1
archived_file = self.ca.archiver.read_file(filepath)
image_type = filetype.image_match(archived_file)
if image_type is not None:
self.imageCount += 1
file_hash = hashlib.blake2b(archived_file, digest_size=16).hexdigest().upper()
if file_hash in self.imageHashes:
self.duplicateImages.add(filename)
else:
image_hash = ImageHasher(data=archived_file, width=12, height=12).average_hash()
self.imageHashes[file_hash] = ImageMeta(
os.path.join(self.extractedPath, filename), file_hash, image_hash, image_type.extension
)
else:
self.extras.add(filename)
os.makedirs(self.extractedPath, 0o777, True)
unarchived_file = Path(os.path.join(self.extractedPath, filename))
unarchived_file.write_bytes(archived_file)
def clean(self):
shutil.rmtree(self.extractedPath, ignore_errors=True)
def delete(self):
if not self.keeping:
self.clean()
try:
os.remove(self.path)
except Exception:
pass
return not (os.path.exists(self.path) or os.path.exists(self.extractedPath))
class Tree(QtCore.QAbstractListModel):
def __init__(self, item: list[list[Duplicate]]):
super().__init__()
self.rootItem = item
def rowCount(self, index: QtCore.QModelIndex = ...) -> int:
if not index.isValid():
return len(self.rootItem)
return 0
def columnCount(self, index: QtCore.QModelIndex = ...) -> int:
if index.isValid():
return 1
return 0
def data(self, index: QtCore.QModelIndex, role: int = ...) -> typing.Any:
if not index.isValid():
return QtCore.QVariant()
f = FileRenamer(self.rootItem[index.row()][0].metadata)
f.set_template("{series} #{issue} - {title} ({year})")
if role in [QtCore.Qt.DisplayRole, QtCore.Qt.UserRole]:
return f.determine_name("")
return QtCore.QVariant()
class MainWindow(QtWidgets.QMainWindow):
closed = QtCore.pyqtSignal(list)
def __init__(self, file_list, style, work_path, settings, parent=None):
super().__init__(parent)
uic.loadUi(ComicTaggerSettings.get_ui_file("../../comictaggerscripts/mainwindow.ui"), self)
self.dupes = []
self.removed: list[str] = []
self.firstRun = 0
self.dupe_set_list: list[list[Duplicate]] = []
self.style = style
if work_path == "":
self.work_path = tempfile.mkdtemp()
else:
self.work_path = work_path
self.initFiles = file_list
self.settings = settings
self.dupe_set_qlist.clicked.connect(self.dupe_set_clicked)
self.dupe_set_qlist.doubleClicked.connect(self.dupe_set_double_clicked)
self.actionCompare_Comic.triggered.connect(self.compare_action)
def __del__(self):
print("fuck this")
shutil.rmtree(self.work_path, True)
def closeEvent(self, event: QtGui.QCloseEvent):
self.closed.emit(self.removed)
event.accept()
def comic_deleted(self, archive_path: str):
self.removed.append(archive_path)
def update_dupes(self):
# print("updating duplicates")
new_set_list = []
for dupe in self.dupe_set_list:
dupe_list = []
for d in dupe:
QtCore.QCoreApplication.processEvents()
if os.path.exists(d.path):
dupe_list.append(d)
else:
d.clean()
if len(dupe_list) > 1:
new_set_list.append(dupe_list)
else:
dupe_list[0].clean()
self.dupe_set_list: list[list[Duplicate]] = new_set_list
self.dupe_set_qlist.setModel(Tree(self.dupe_set_list))
if new_set_list:
self.dupe_set_qlist.setSelection(QtCore.QRect(0, 0, 0, 1), QtCore.QItemSelectionModel.ClearAndSelect)
self.dupe_set_clicked(self.dupe_set_qlist.model().index(0, 0))
else:
self.clear_dupe_list()
def compare(self, i):
if len(self.dupe_set_list) > i:
dw = DupeWindow(self.dupe_set_list[i], self.work_path, self)
dw.closed.connect(self.update_dupes)
dw.comic_deleted.connect(self.comic_deleted)
dw.show()
def compare_action(self, b):
selection = self.dupe_set_qlist.selectedIndexes()
if len(selection) > 0:
self.compare(selection[0].row())
def dupe_set_double_clicked(self, index: QtCore.QModelIndex):
self.compare(index.row())
def clear_dupe_list(self):
for f in self.dupe_list.children():
if isinstance(f, DupeImage):
f.deleteLater()
def dupe_set_clicked(self, index: QtCore.QModelIndex):
self.clear_dupe_list()
if self.dupe_set_list:
self.dupe_set_list[index.row()].sort(key=lambda k: k.digest)
for i, f in enumerate(self.dupe_set_list[index.row()]):
color = "black"
if i > 0:
if self.dupe_set_list[index.row()][i - 1].digest == f.digest:
color = "green"
elif i == 0:
if len(self.dupe_set_list[index.row()]) > 1:
if self.dupe_set_list[index.row()][i + 1].digest == f.digest:
color = "green"
ql = DupeImage(
duplicate=f, style=f".path {{color: black;}}.hash {{color: {color};}}", parent=self.dupe_list
)
ql.deleted.connect(self.update_dupes)
ql.deleted.connect(self.comic_deleted)
ql.setMinimumWidth(300)
ql.setMinimumHeight(500)
print(self.dupe_list.layout())
layout = self.dupe_list.layout()
layout.addWidget(ql)
def showEvent(self, event: QtGui.QShowEvent):
if self.firstRun == 0:
self.firstRun = 1
self.load_files(self.initFiles)
if len(self.dupe_set_list) < 1:
dialog = QtWidgets.QMessageBox(
QtWidgets.QMessageBox.NoIcon,
"ComicTagger Duplicate finder",
"No duplicate comics found",
QtWidgets.QMessageBox.Ok,
parent=self,
)
dialog.setWindowModality(QtCore.Qt.ApplicationModal)
qw = QtWidgets.QWidget()
qw.setFixedWidth(90)
dialog.layout().addWidget(qw, 3, 2, 1, 3)
dialog.exec()
QtCore.QTimer.singleShot(1, self.close)
self.close()
self.dupe_set_qlist.setSelection(QtCore.QRect(0, 0, 0, 1), QtCore.QItemSelectionModel.ClearAndSelect)
self.dupe_set_clicked(self.dupe_set_qlist.model().index(0, 0))
def load_files(self, file_list):
# Progress dialog on Linux flakes out for small range, so scale up
dialog = QtWidgets.QProgressDialog("", "Cancel", 0, len(file_list), parent=self)
dialog.setWindowTitle("Reading Comics")
dialog.setWindowModality(QtCore.Qt.ApplicationModal)
dialog.setMinimumDuration(300)
dialog.setMinimumWidth(400)
center_window_on_parent(dialog)
comic_list = []
max_name_len = 2
for filename in file_list:
QtCore.QCoreApplication.processEvents()
if dialog.wasCanceled():
break
dialog.setValue(dialog.value() + 1)
dialog.setLabelText(filename)
ca = ComicArchive(
path=filename,
rar_exe_path=self.settings.rar_exe_path,
default_image_path=ComicTaggerSettings.get_graphic("nocover.png"),
)
if ca.seems_to_be_a_comic_archive() and ca.has_metadata(self.style):
# fmt_str = "{{0:{0}}}".format(max_name_len)
# print(fmt_str.format(filename) + "\r", end='', file=sys.stderr)
# sys.stderr.flush()
md = ca.read_metadata(self.style)
cover = ca.get_page(0)
comic_list.append((make_key(md), filename, ca, md, cover))
# max_name_len = len(filename)
comic_list.sort(key=itemgetter(0), reverse=False)
# look for duplicate blocks
dupe_set = []
prev_key = ""
dialog.setWindowTitle("Finding Duplicates")
dialog.setMaximum(len(comic_list))
dialog.setValue(dialog.minimum())
set_list = []
for new_key, filename, ca, md, cover in comic_list:
dialog.setValue(dialog.value() + 1)
QtCore.QCoreApplication.processEvents()
if dialog.wasCanceled():
break
dialog.setLabelText(filename)
# if the new key same as the last, add to to dupe set
if new_key == prev_key:
dupe_set.append((filename, ca, md, cover))
# else we're on a new potential block
else:
# only add if the dupe list has 2 or more
if len(dupe_set) > 1:
set_list.append(dupe_set)
dupe_set = []
dupe_set.append((filename, ca, md, cover))
prev_key = new_key
# Final dupe_set
if len(dupe_set) > 1:
set_list.append(dupe_set)
for d_set in set_list:
new_set = []
for filename, ca, md, cover in d_set:
new_set.append(Duplicate(filename, md, ca, cover))
self.dupe_set_list.append(new_set)
self.dupe_set_qlist.setModel(Tree(self.dupe_set_list))
# print()
dialog.close()
# def delete_hashes(self):
# working_dir = os.path.join(self.tmp, "working")
# s = False
# # while working and len(dupe_set) > 1:
# remaining = []
# for dupe_set in self.dupe_set_list:
# not_deleted = True
# if os.path.exists(working_dir):
# shutil.rmtree(working_dir, ignore_errors=True)
#
# os.mkdir(working_dir)
# extract(dupe_set, working_dir)
# if mark_hashes(dupe_set):
# if s: # Auto delete if s flag or if there are not any non image extras
# dupe_set.sort(key=attrgetter("fileCount"))
# dupe_set.sort(key=lambda x: len(x.duplicateImages))
# dupe_set[0].keeping = True
# else:
# dupe_set[select_archive("Select archive to keep: ", dupe_set)].keeping = True
# else:
# # app.exec_()
# compare_dupe(dupe_set[0], dupe_set[1])
# for i, dupe in enumerate(dupe_set):
# print("{0}. {1}: {2.series} #{2.issue:0>3} {2.year}; extras: {3}; deletable: {4}".format(
# i,
# dupe.path,
# dupe.metadata,
# ", ".join(sorted(dupe.extras)), dupe.deletable))
# dupe_set = delete(dupe_set)
# if not_deleted:
# remaining.append(dupe_set)
# self.dupe_set_list = remaining
class DupeWindow(QtWidgets.QWidget):
closed = QtCore.pyqtSignal()
comic_deleted = QtCore.pyqtSignal(str)
def __init__(self, duplicates: list[Duplicate], tmp, parent=None):
super().__init__(parent, QtCore.Qt.Window)
uic.loadUi(ComicTaggerSettings.get_ui_file("../../comictaggerscripts/dupe.ui"), self)
for f in self.comic1Image.children():
f.deleteLater()
for f in self.comic2Image.children():
f.deleteLater()
self.deleting = -1
self.duplicates: list[Duplicate] = duplicates
self.dupe1 = -1
self.dupe2 = -1
self.tmp = tmp
self.setWindowTitle("ComicTagger Duplicate compare")
self.pageList.currentItemChanged.connect(self.current_item_changed)
self.comic1Delete.clicked.connect(self.delete_1)
self.comic2Delete.clicked.connect(self.delete_2)
self.dupeList.itemSelectionChanged.connect(self.show_dupe_list)
# self.dupeList = QtWidgets.QListWidget()
self.dupeList.setIconSize(QtCore.QSize(100, 50))
while self.pageList.rowCount() > 0:
self.pageList.removeRow(0)
self.pageList.setSortingEnabled(False)
if len(duplicates) < 2:
return
extract(duplicates, tmp)
tmp1 = DupeImage(self.duplicates[0])
tmp2 = DupeImage(self.duplicates[1])
self.comic1Data.layout().replaceWidget(self.comic1Image, tmp1)
self.comic2Data.layout().replaceWidget(self.comic2Image, tmp2)
self.comic1Image = tmp1
self.comic2Image = tmp2
self.comic1Image.deleted.connect(self.update_dupes)
self.comic2Image.deleted.connect(self.update_dupes)
def showEvent(self, event: QtGui.QShowEvent) -> None:
self.update_dupes()
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
self.closed.emit()
event.accept()
def show_dupe_list(self):
dupes = self.dupeList.selectedItems()
if len(dupes) != 2:
return
self.dupe1 = int(dupes[0].data(QtCore.Qt.UserRole))
self.dupe2 = int(dupes[1].data(QtCore.Qt.UserRole))
if len(self.duplicates[self.dupe2].imageHashes) > len(self.duplicates[self.dupe1].imageHashes):
self.dupe1, self.dupe2 = self.dupe2, self.dupe1
compare_dupe(self.duplicates[self.dupe1].imageHashes, self.duplicates[self.dupe2].imageHashes)
self.display_dupe()
def update_dupes(self):
dupes: list[Duplicate] = []
for f in self.duplicates:
if os.path.exists(f.path):
dupes.append(f)
else:
f.clean()
self.duplicates = dupes
if len(self.duplicates) < 2:
self.close()
self.dupeList.clear()
for i, dupe in enumerate(self.duplicates):
item = QtWidgets.QListWidgetItem()
item.setText(dupe.path)
item.setToolTip(dupe.path)
pm = QtGui.QPixmap()
pm.loadFromData(dupe.cover)
item.setIcon(QtGui.QIcon(pm))
item.setData(QtCore.Qt.UserRole, i)
self.dupeList.addItem(item)
self.dupeList.setCurrentRow(0)
self.dupeList.setCurrentRow(1, QtCore.QItemSelectionModel.Select)
def delete_1(self):
self.duplicates[self.dupe1].delete()
self.comic_deleted.emit(self.duplicates[self.dupe1].path)
self.update_dupes()
def delete_2(self):
self.duplicates[self.dupe2].delete()
self.comic_deleted.emit(self.duplicates[self.dupe2].path)
self.update_dupes()
def display_dupe(self):
for _ in range(self.pageList.rowCount()):
self.pageList.removeRow(0)
for h in self.duplicates[self.dupe1].imageHashes.values():
row = self.pageList.rowCount()
self.pageList.insertRow(row)
name = QtWidgets.QTableWidgetItem()
score = QtWidgets.QTableWidgetItem()
dupe_name = QtWidgets.QTableWidgetItem()
item_text = os.path.basename(h.name)
name.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
name.setText(item_text)
name.setData(QtCore.Qt.UserRole, h.file_hash)
name.setData(QtCore.Qt.ToolTipRole, item_text)
self.pageList.setItem(row, 0, name)
item_text = str(h.score)
score.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
score.setText(item_text)
score.setData(QtCore.Qt.UserRole, h.file_hash)
score.setData(QtCore.Qt.ToolTipRole, item_text)
self.pageList.setItem(row, 1, score)
item_text = os.path.basename(self.duplicates[self.dupe2].imageHashes[h.score_file_hash].name)
dupe_name.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
dupe_name.setText(item_text)
dupe_name.setData(QtCore.Qt.UserRole, h.file_hash)
dupe_name.setData(QtCore.Qt.ToolTipRole, item_text)
self.pageList.setItem(row, 2, dupe_name)
self.pageList.resizeColumnsToContents()
self.pageList.selectRow(0)
def current_item_changed(self, curr, prev):
if curr is None:
return
if prev is not None and prev.row() == curr.row():
return
file_hash = str(self.pageList.item(curr.row(), 0).data(QtCore.Qt.UserRole))
image_hash = self.duplicates[self.dupe1].imageHashes[file_hash]
score_hash = self.duplicates[self.dupe2].imageHashes[image_hash.score_file_hash]
image1 = QtGui.QPixmap(image_hash.name)
image2 = QtGui.QPixmap(score_hash.name)
page_color = "red"
size_color = "red"
type_color = "red"
file_color = "black"
image_color = "black"
if image1.width() == image2.width() and image2.height() == image1.height():
size_color = "green"
if len(self.duplicates[self.dupe1].imageHashes) == len(self.duplicates[self.dupe2].imageHashes):
page_color = "green"
if image_hash.type == score_hash.type:
type_color = "green"
if image_hash.image_hash == score_hash.image_hash:
image_color = "green"
if image_hash.file_hash == score_hash.file_hash:
file_color = "green"
style = f"""
.page {{
color: {page_color};
}}
.size {{
color: {size_color};
}}
.type {{
color: {type_color};
}}
.file {{
color: {file_color};
}}
.image {{
color: {image_color};
}}"""
text = ( # this is a format string that creates a format string
"name: {{duplicate.path}}<br/>"
"page count: <span class='page'>{len}</span><br/>"
"size/type: <span class='size'>{{width}}x{{height}}</span>/<span class='type'>{meta.type}</span><br/>"
"file_hash: <span class='file'>{meta.file_hash}</span><br/>"
"image_hash: <span class='image'>{meta.image_hash}</span>".format(
meta=image_hash, style=style, len=len(self.duplicates[self.dupe1].imageHashes)
)
)
self.comic1Image.setDuplicate(self.duplicates[self.dupe1])
self.comic1Image.setImage(image_hash.name)
self.comic1Image.setText(text)
self.comic1Image.setLabelStyle(style)
text = (
"name: {{duplicate.path}}<br/>"
"page count: <span class='page'>{len}</span><br/>"
"size/type: <span class='size'>{{width}}x{{height}}</span>/<span class='type'>{score.type}</span><br/>"
"file_hash: <span class='file'>{score.file_hash}</span><br/>"
"image_hash: <span class='image'>{score.image_hash}</span>".format(
score=score_hash, style=style, len=len(self.duplicates[self.dupe2].imageHashes)
)
)
self.comic2Image.setDuplicate(self.duplicates[self.dupe2])
self.comic2Image.setImage(score_hash.name)
self.comic2Image.setText(text)
self.comic2Image.setLabelStyle(style)
class QQlabel(QtWidgets.QLabel):
def __init__(self, parent=None):
super().__init__(parent)
self.image = None
self.setMinimumSize(1, 1)
self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
def setPixmap(self, pixmap: QtGui.QPixmap) -> None:
self.image = pixmap
self.setMaximumWidth(pixmap.width())
self.setMaximumHeight(pixmap.height())
super().setPixmap(
self.image.scaled(self.width(), self.height(), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
)
def resizeEvent(self, a0: QtGui.QResizeEvent) -> None:
if self.image is not None:
super().setPixmap(
self.image.scaled(
self.width(), self.height(), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation
)
)
class DupeImage(QtWidgets.QWidget):
deleted = QtCore.pyqtSignal(str)
def __init__(
self,
duplicate: Duplicate,
style=".path {color: black;}.hash {color: black;}",
text="path: <span class='path'>{duplicate.path}</span><br/>hash: <span class='hash'>{duplicate.digest}</span>",
image="cover",
parent=None,
):
super().__init__(parent)
self.setLayout(QtWidgets.QVBoxLayout())
self.image = QQlabel()
self.label = QtWidgets.QLabel()
self.duplicate = duplicate
self.text = text
self.labelStyle = style
self.iHeight = 0
self.iWidth = 0
self.setStyleSheet("color: black;")
self.label.setWordWrap(True)
self.setImage(image)
self.setLabelStyle(self.labelStyle)
self.setText(self.text)
# label.setSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum)
self.layout().addWidget(self.image)
self.layout().addWidget(self.label)
def contextMenuEvent(self, event: QtGui.QContextMenuEvent):
menu = QtWidgets.QMenu()
delete_action = menu.addAction("delete")
action = menu.exec(self.mapToGlobal(event.pos()))
if action == delete_action:
if self.duplicate.delete():
self.hide()
self.deleteLater()
# print("signal emitted")
self.deleted.emit(self.duplicate.path)
def setDuplicate(self, duplicate: Duplicate):
self.duplicate = duplicate
self.setImage("cover")
self.label.setText(
f"<style>{self.labelStyle}</style>"
+ self.text.format(duplicate=self.duplicate, width=self.iWidth, height=self.iHeight)
)
def setText(self, text):
self.text = text
self.label.setText(
f"<style>{self.labelStyle}</style>"
+ self.text.format(duplicate=self.duplicate, width=self.iWidth, height=self.iHeight)
)
def setImage(self, image):
if self.duplicate is not None:
pm = QtGui.QPixmap()
if image == "cover":
pm.loadFromData(self.duplicate.cover)
else:
pm.load(image)
self.iHeight = pm.height()
self.iWidth = pm.width()
self.image.setPixmap(pm)
def setLabelStyle(self, style):
self.labelStyle = style
self.label.setText(
f"<style>{self.labelStyle}</style>"
+ self.text.format(duplicate=self.duplicate, width=self.iWidth, height=self.iHeight)
)
def extract(dupe_set, directory):
for dupe in dupe_set:
dupe.extract(unique_dir(os.path.join(directory, os.path.basename(dupe.path))))
def compare_dupe(dupe1: dict[str, ImageMeta], dupe2: dict[str, ImageMeta]):
for k, image1 in dupe1.items():
score = sys.maxsize
file_hash = ""
for _, image2 in dupe2.items():
tmp = ImageHasher.hamming_distance(image1.image_hash, image2.image_hash)
if tmp < score:
score = tmp
file_hash = image2.file_hash
dupe1[k].score = score
dupe1[k].score_file_hash = file_hash
def make_key(x):
return "<" + str(x.series) + " #" + str(x.issue) + " - " + str(x.title) + " - " + str(x.year) + ">"
def unique_dir(file_name):
counter = 1
file_name_parts = os.path.splitext(file_name)
while True:
if not os.path.lexists(file_name):
return file_name
file_name = file_name_parts[0] + " (" + str(counter) + ")" + file_name_parts[1]
counter += 1
def parse_args(args: list[str], config=True):
parser = argparse.ArgumentParser(description="ComicTagger Duplicate comparison script")
parser.add_argument(
"-w",
metavar="workdir",
type=str,
nargs=1,
default=tempfile.mkdtemp(),
help="work directory, will be deleted on close",
)
parser.add_argument("paths", metavar="PATH", type=str, nargs="+", help="Path(s) to search for duplicates")
if config:
parser.add_argument(
"--config",
help="""Config directory defaults to ~/.ComicTagger\non Linux/Mac and %%APPDATA%% on Windows\n""",
)
return parser.parse_args(args)
def main(args: Optional[list[str]] = None, settings: Optional[ComicTaggerSettings] = None):
opts = parse_args(args)
if not settings:
settings = ComicTaggerSettings(opts.config)
style = MetaDataStyle.CIX
file_list = utils.get_recursive_filelist(opts.paths)
return MainWindow(file_list, style, opts.w, settings)
def sigint_handler(*args):
"""Handler for the SIGINT signal."""
sys.stderr.write("\r")
QtWidgets.QApplication.quit()
if __name__ == "__main__":
signal.signal(signal.SIGINT, sigint_handler)
app = QtWidgets.QApplication([])
window = main()
window.show()
app.exec()

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>600</height>
</rect>
</property>
<property name="windowTitle">
<string>ComicTagger Duplicate finder</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="sizeConstraint">
<enum>QLayout::SetMinAndMaxSize</enum>
</property>
<item>
<widget class="QSplitter" name="splitter">
<property name="enabled">
<bool>true</bool>
</property>
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="childrenCollapsible">
<bool>false</bool>
</property>
<widget class="QTreeView" name="dupe_set_qlist"/>
<widget class="QScrollArea" name="dupe_list_p">
<property name="minimumSize">
<size>
<width>400</width>
<height>0</height>
</size>
</property>
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="dupe_list">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>396</width>
<height>520</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout"/>
</widget>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>30</height>
</rect>
</property>
</widget>
<widget class="QToolBar" name="toolBar">
<property name="windowTitle">
<string>toolBar</string>
</property>
<attribute name="toolBarArea">
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="actionCompare_Comic"/>
</widget>
<action name="actionCompare_Comic">
<property name="text">
<string>Compare Comic</string>
</property>
</action>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -1,86 +0,0 @@
#!/usr/bin/python
"""Find all duplicate comics"""
# import sys
from comictaggerlib.comicarchive import *
from comictaggerlib.settings import *
# from comictaggerlib.issuestring import *
# import comictaggerlib.utils
def main():
utils.fix_output_encoding()
settings = ComicTaggerSettings()
style = MetaDataStyle.CIX
if len(sys.argv) < 2:
print >> sys.stderr, "Usage: {0} [comic_folder]".format(sys.argv[0])
return
filelist = utils.get_recursive_filelist(sys.argv[1:])
# first find all comics with metadata
print >> sys.stderr, "Reading in all comics..."
comic_list = []
fmt_str = ""
max_name_len = 2
for filename in filelist:
ca = ComicArchive(filename, settings.rar_exe_path)
if ca.seemsToBeAComicArchive() and ca.hasMetadata(style):
max_name_len = max(max_name_len, len(filename))
fmt_str = u"{{0:{0}}}".format(max_name_len)
print >> sys.stderr, fmt_str.format(filename) + "\r",
sys.stderr.flush()
comic_list.append((filename, ca.readMetadata(style)))
print >> sys.stderr, fmt_str.format("") + "\r",
print "--------------------------------------------------------------------------"
print "Found {0} comics with {1} tags".format(len(comic_list), MetaDataStyle.name[style])
print "--------------------------------------------------------------------------"
# sort the list by series+issue+year, to put all the dupes together
def makeKey(x):
return "<" + unicode(x[1].series) + u" #" + unicode(x[1].issue) + u" - " + unicode(x[1].year) + ">"
comic_list.sort(key=makeKey, reverse=False)
# look for duplicate blocks
dupe_set_list = []
dupe_set = []
prev_key = ""
for filename, md in comic_list:
print >> sys.stderr, fmt_str.format(filename) + "\r",
sys.stderr.flush()
new_key = makeKey((filename, md))
# if the new key same as the last, add to to dupe set
if new_key == prev_key:
dupe_set.append(filename)
# else we're on a new potential block
else:
# only add if the dupe list has 2 or more
if len(dupe_set) > 1:
dupe_set_list.append(dupe_set)
dupe_set = []
dupe_set.append(filename)
prev_key = new_key
print >> sys.stderr, fmt_str.format("") + "\r",
print "Found {0} duplicate sets".format(len(dupe_set_list))
for dupe_set in dupe_set_list:
ca = ComicArchive(dupe_set[0], settings.rar_exe_path)
md = ca.readMetadata(style)
print "{0} #{1} ({2})".format(md.series, md.issue, md.year)
for filename in dupe_set:
print "------>{0}".format(filename)
if __name__ == "__main__":
main()

View File

@ -53,7 +53,8 @@ setup(
author="ComicTagger team",
author_email="comictagger@gmail.com",
url="https://github.com/comictagger/comictagger",
packages=["comictaggerlib", "comicapi"],
packages=["comictaggerlib", "comicapi", "comictaggerscripts"],
package_dir={"comictaggerscripts": "scripts"},
package_data={"comictaggerlib": ["ui/*", "graphics/*"], "comicapi": ["data/*"]},
entry_points=dict(console_scripts=["comictagger=comictaggerlib.main:ctmain"]),
classifiers=[