Compare commits
4 Commits
develop
...
duplicateF
Author | SHA1 | Date | |
---|---|---|---|
|
4d02e88905 | ||
|
c870ed86e0 | ||
|
923f551983 | ||
|
97e65f10cf |
@ -64,6 +64,7 @@ class MetaDataStyle:
|
||||
CIX = 1
|
||||
COMET = 2
|
||||
name = ["ComicBookLover", "ComicRack", "CoMet"]
|
||||
short = ["cbl", "cr", "comet"]
|
||||
|
||||
|
||||
class UnknownArchiver:
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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()
|
||||
|
@ -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
158
comictaggerscripts/dupe.ui
Normal 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
755
comictaggerscripts/find_dupes.py
Executable 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()
|
92
comictaggerscripts/mainwindow.ui
Normal file
92
comictaggerscripts/mainwindow.ui
Normal 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>
|
@ -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()
|
3
setup.py
3
setup.py
@ -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=[
|
||||
|
Loading…
Reference in New Issue
Block a user