66import logging
77import typing
88
9- from PySide6 .QtCore import QThread , Qt , QThreadPool
9+ from PySide6 .QtCore import Qt , QThreadPool
1010from PySide6 .QtWidgets import QWidget , QVBoxLayout , QHBoxLayout , QLabel , QPushButton
1111
1212from src .core .library import Library
1313from src .qt .helpers .function_iterator import FunctionIterator
1414from src .qt .helpers .custom_runnable import CustomRunnable
1515from src .qt .modals .delete_unlinked import DeleteUnlinkedEntriesModal
1616from src .qt .modals .relink_unlinked import RelinkUnlinkedEntries
17+ from src .qt .modals .merge_dupe_entries import MergeDuplicateEntries
1718from src .qt .widgets .progress import ProgressWidget
1819
1920# Only import for type checking/autocompletion, will not be imported at runtime.
2021if typing .TYPE_CHECKING :
2122 from src .qt .ts_qt import QtDriver
2223
2324
24- ERROR = f "[ERROR]"
25- WARNING = f "[WARNING]"
26- INFO = f "[INFO]"
25+ ERROR = "[ERROR]"
26+ WARNING = "[WARNING]"
27+ INFO = "[INFO]"
2728
2829logging .basicConfig (format = "%(message)s" , level = logging .INFO )
2930
3031
3132class FixUnlinkedEntriesModal (QWidget ):
32- # done = Signal(int)
3333 def __init__ (self , library : "Library" , driver : "QtDriver" ):
3434 super ().__init__ ()
3535 self .lib = library
3636 self .driver = driver
37- self .count = - 1
38- self .setWindowTitle (f"Fix Unlinked Entries" )
37+ self .missing_count = - 1
38+ self .dupe_count = - 1
39+ self .setWindowTitle ("Fix Unlinked Entries" )
3940 self .setWindowModality (Qt .WindowModality .ApplicationModal )
4041 self .setMinimumSize (400 , 300 )
4142 self .root_layout = QVBoxLayout (self )
4243 self .root_layout .setContentsMargins (6 , 6 , 6 , 6 )
4344
44- self .desc_widget = QLabel ()
45- self .desc_widget .setObjectName ("descriptionLabel" )
46- self .desc_widget .setWordWrap (True )
47- self .desc_widget .setStyleSheet (
48- # 'background:blue;'
49- "text-align:left;"
50- # 'font-weight:bold;'
51- # 'font-size:14px;'
52- # 'padding-top: 6px'
53- ""
45+ self .unlinked_desc_widget = QLabel ()
46+ self .unlinked_desc_widget .setObjectName ("unlinkedDescriptionLabel" )
47+ self .unlinked_desc_widget .setWordWrap (True )
48+ self .unlinked_desc_widget .setStyleSheet ("text-align:left;" )
49+ self .unlinked_desc_widget .setText (
50+ """Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked. Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired."""
5451 )
55- self .desc_widget .setText ("""Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.
56- Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired.""" )
57- self .desc_widget .setAlignment (Qt .AlignmentFlag .AlignCenter )
58-
59- self .missing_count = QLabel ()
60- self .missing_count .setObjectName ("missingCountLabel" )
61- self .missing_count .setStyleSheet (
62- # 'background:blue;'
63- # 'text-align:center;'
64- "font-weight:bold;"
65- "font-size:14px;"
66- # 'padding-top: 6px'
67- ""
52+
53+ self .dupe_desc_widget = QLabel ()
54+ self .dupe_desc_widget .setObjectName ("dupeDescriptionLabel" )
55+ self .dupe_desc_widget .setWordWrap (True )
56+ self .dupe_desc_widget .setStyleSheet ("text-align:left;" )
57+ self .dupe_desc_widget .setText (
58+ """Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with "duplicate files", which are duplicates of your files themselves outside of TagStudio."""
59+ )
60+
61+ self .missing_count_label = QLabel ()
62+ self .missing_count_label .setObjectName ("missingCountLabel" )
63+ self .missing_count_label .setStyleSheet ("font-weight:bold;" "font-size:14px;" )
64+ self .missing_count_label .setAlignment (Qt .AlignmentFlag .AlignCenter )
65+
66+ self .dupe_count_label = QLabel ()
67+ self .dupe_count_label .setObjectName ("dupeCountLabel" )
68+ self .dupe_count_label .setStyleSheet ("font-weight:bold;" "font-size:14px;" )
69+ self .dupe_count_label .setAlignment (Qt .AlignmentFlag .AlignCenter )
70+
71+ self .refresh_unlinked_button = QPushButton ()
72+ self .refresh_unlinked_button .setText ("&Refresh All" )
73+ self .refresh_unlinked_button .clicked .connect (
74+ lambda : self .refresh_missing_files ()
6875 )
69- self .missing_count .setAlignment (Qt .AlignmentFlag .AlignCenter )
70- # self.missing_count.setText('Missing Files: N/A')
7176
72- self .refresh_button = QPushButton ()
73- self .refresh_button .setText ("&Refresh" )
74- self .refresh_button .clicked .connect (lambda : self .refresh_missing_files ())
77+ self .merge_class = MergeDuplicateEntries (self .lib , self .driver )
78+ self .relink_class = RelinkUnlinkedEntries (self .lib , self .driver )
7579
7680 self .search_button = QPushButton ()
7781 self .search_button .setText ("&Search && Relink" )
78- self .relink_class = RelinkUnlinkedEntries ( self . lib , self . driver )
79- self . relink_class . done . connect ( lambda : self .refresh_missing_files () )
80- self . relink_class . done . connect ( lambda : self . driver . update_thumbs () )
82+ self .relink_class . done . connect (
83+ lambda : self .refresh_and_repair_dupe_entries ( self . merge_class )
84+ )
8185 self .search_button .clicked .connect (lambda : self .relink_class .repair_entries ())
8286
87+ self .refresh_dupe_button = QPushButton ()
88+ self .refresh_dupe_button .setText ("Refresh Duplicate Entries" )
89+ self .refresh_dupe_button .clicked .connect (lambda : self .refresh_dupe_entries ())
90+
91+ self .merge_dupe_button = QPushButton ()
92+ self .merge_dupe_button .setText ("&Merge Duplicate Entries" )
93+ self .merge_class .done .connect (lambda : self .set_dupe_count (- 1 ))
94+ self .merge_class .done .connect (lambda : self .set_missing_count (- 1 ))
95+ self .merge_class .done .connect (lambda : self .driver .filter_items ())
96+ self .merge_dupe_button .clicked .connect (lambda : self .merge_class .merge_entries ())
97+
8398 self .manual_button = QPushButton ()
8499 self .manual_button .setText ("&Manual Relink" )
85100
@@ -92,65 +107,46 @@ def __init__(self, library: "Library", driver: "QtDriver"):
92107 self .delete_button .setText ("De&lete Unlinked Entries" )
93108 self .delete_button .clicked .connect (lambda : self .delete_modal .show ())
94109
95- # self.combo_box = QComboBox()
96- # self.combo_box.setEditable(False)
97- # # self.combo_box.setMaxVisibleItems(5)
98- # self.combo_box.setStyleSheet('combobox-popup:0;')
99- # self.combo_box.view().setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
100- # for df in self.lib.default_fields:
101- # self.combo_box.addItem(f'{df["name"]} ({df["type"].replace("_", " ").title()})')
102-
103110 self .button_container = QWidget ()
104111 self .button_layout = QHBoxLayout (self .button_container )
105112 self .button_layout .setContentsMargins (6 , 6 , 6 , 6 )
106113 self .button_layout .addStretch (1 )
107114
108115 self .done_button = QPushButton ()
109116 self .done_button .setText ("&Done" )
110- # self.save_button.setAutoDefault(True)
111117 self .done_button .setDefault (True )
112118 self .done_button .clicked .connect (self .hide )
113- # self.done_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
114- # self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
115119 self .button_layout .addWidget (self .done_button )
116120
117- # self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
118-
119- # self.done.connect(lambda x: callback(x))
120-
121- self .root_layout .addWidget (self .desc_widget )
122- self .root_layout .addWidget (self .missing_count )
123- self .root_layout .addWidget (self .refresh_button )
121+ self .root_layout .addWidget (self .missing_count_label )
122+ self .root_layout .addWidget (self .unlinked_desc_widget )
123+ self .root_layout .addWidget (self .refresh_unlinked_button )
124124 self .root_layout .addWidget (self .search_button )
125125 self .manual_button .setHidden (True )
126126 self .root_layout .addWidget (self .manual_button )
127127 self .root_layout .addWidget (self .delete_button )
128- # self.root_layout.setStretch(1,2)
129128 self .root_layout .addStretch (1 )
129+ self .root_layout .addWidget (self .dupe_count_label )
130+ self .root_layout .addWidget (self .dupe_desc_widget )
131+ self .root_layout .addWidget (self .refresh_dupe_button )
132+ self .root_layout .addWidget (self .merge_dupe_button )
133+ self .root_layout .addStretch (2 )
130134 self .root_layout .addWidget (self .button_container )
131135
132- self .set_missing_count (self .count )
136+ self .set_missing_count (self .missing_count )
137+ self .set_dupe_count (self .dupe_count )
133138
134139 def refresh_missing_files (self ):
135- logging .info (f"Start RMF: { QThread .currentThread ()} " )
136- # pb = QProgressDialog(f'Scanning Library for Unlinked Entries...', None, 0,len(self.lib.entries))
137- # pb.setFixedSize(432, 112)
138- # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
139- # pb.setWindowTitle('Scanning Library')
140- # pb.setWindowModality(Qt.WindowModality.ApplicationModal)
141- # pb.show()
142-
143140 iterator = FunctionIterator (self .lib .refresh_missing_files )
144141 pw = ProgressWidget (
145142 window_title = "Scanning Library" ,
146- label_text = f "Scanning Library for Unlinked Entries..." ,
143+ label_text = "Scanning Library for Unlinked Entries..." ,
147144 cancel_button_text = None ,
148145 minimum = 0 ,
149146 maximum = len (self .lib .entries ),
150147 )
151148 pw .show ()
152149 iterator .value .connect (lambda v : pw .update_progress (v + 1 ))
153- # rmf.value.connect(lambda v: pw.update_label(f'Progress: {v}'))
154150 r = CustomRunnable (lambda : iterator .run ())
155151 QThreadPool .globalInstance ().start (r )
156152 r .done .connect (
@@ -159,30 +155,76 @@ def refresh_missing_files(self):
159155 pw .deleteLater (),
160156 self .set_missing_count (len (self .lib .missing_files )),
161157 self .delete_modal .refresh_list (),
158+ self .refresh_dupe_entries (),
162159 )
163160 )
164161
165- # r = CustomRunnable(lambda: self.lib.refresh_missing_files(lambda v: self.update_scan_value(pb, v)))
166- # r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.set_missing_count(len(self.lib.missing_files)), self.delete_modal.refresh_list()))
167- # QThreadPool.globalInstance().start(r)
168- # # r.run()
169- # pass
162+ def refresh_dupe_entries (self ):
163+ iterator = FunctionIterator (self .lib .refresh_dupe_entries )
164+ pw = ProgressWidget (
165+ window_title = "Scanning Library" ,
166+ label_text = "Scanning Library for Duplicate Entries..." ,
167+ cancel_button_text = None ,
168+ minimum = 0 ,
169+ maximum = len (self .lib .entries ),
170+ )
171+ pw .show ()
172+ iterator .value .connect (lambda v : pw .update_progress (v + 1 ))
173+ r = CustomRunnable (lambda : iterator .run ())
174+ QThreadPool .globalInstance ().start (r )
175+ r .done .connect (
176+ lambda : (
177+ pw .hide (),
178+ pw .deleteLater (),
179+ self .set_dupe_count (len (self .lib .dupe_entries )),
180+ )
181+ )
170182
171- # def update_scan_value(self, pb:QProgressDialog, value=int):
172- # # pb.setLabelText(f'Scanning Library for Unlinked Entries ({value}/{len(self.lib.entries)})...')
173- # pb.setValue(value)
183+ def refresh_and_repair_dupe_entries (self , merge_class : MergeDuplicateEntries ):
184+ iterator = FunctionIterator (self .lib .refresh_dupe_entries )
185+ pw = ProgressWidget (
186+ window_title = "Scanning Library" ,
187+ label_text = "Scanning Library for Duplicate Entries..." ,
188+ cancel_button_text = None ,
189+ minimum = 0 ,
190+ maximum = len (self .lib .entries ),
191+ )
192+ pw .show ()
193+ iterator .value .connect (lambda v : pw .update_progress (v + 1 ))
194+ r = CustomRunnable (lambda : iterator .run ())
195+ QThreadPool .globalInstance ().start (r )
196+ r .done .connect (
197+ lambda : (
198+ pw .hide (),
199+ pw .deleteLater (),
200+ self .set_dupe_count (len (self .lib .dupe_entries )),
201+ merge_class .merge_entries (),
202+ )
203+ )
174204
175205 def set_missing_count (self , count : int ):
176- self .count = count
177- if self .count < 0 :
206+ self .missing_count = count
207+ if self .missing_count < 0 :
178208 self .search_button .setDisabled (True )
179209 self .delete_button .setDisabled (True )
180- self .missing_count .setText (f "Unlinked Entries: N/A" )
181- elif self .count == 0 :
210+ self .missing_count_label .setText ("Unlinked Entries: N/A" )
211+ elif self .missing_count == 0 :
182212 self .search_button .setDisabled (True )
183213 self .delete_button .setDisabled (True )
184- self .missing_count .setText (f"Unlinked Entries: { count } " )
214+ self .missing_count_label .setText (f"Unlinked Entries: { count } " )
185215 else :
186216 self .search_button .setDisabled (False )
187217 self .delete_button .setDisabled (False )
188- self .missing_count .setText (f"Unlinked Entries: { count } " )
218+ self .missing_count_label .setText (f"Unlinked Entries: { count } " )
219+
220+ def set_dupe_count (self , count : int ):
221+ self .dupe_count = count
222+ if self .dupe_count < 0 :
223+ self .dupe_count_label .setText ("Duplicate Entries: N/A" )
224+ self .merge_dupe_button .setDisabled (True )
225+ elif self .dupe_count == 0 :
226+ self .dupe_count_label .setText (f"Duplicate Entries: { count } " )
227+ self .merge_dupe_button .setDisabled (True )
228+ else :
229+ self .dupe_count_label .setText (f"Duplicate Entries: { count } " )
230+ self .merge_dupe_button .setDisabled (False )
0 commit comments