├── GUI
├── control_room.py
├── gui_text.py
├── main_gui.py
├── misc_classes.py
├── mv_classes.py
├── profiles.py
├── resources.py
├── settings_window.py
└── widget_bank.py
├── changelog
├── cli_config
└── cli_config.py.example
├── core
├── img_rehost.py
├── info_2_upl.py
├── lean_torrent.py
├── tp_text.py
├── transplant.py
└── utils.py
├── gazelle
├── api_classes.py
├── torrent_info.py
├── tracker_data.py
└── upload.py
├── requirements.txt
├── transplant_GUI.py
└── transplant_cli.py
/GUI/control_room.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 | import logging
4 | from pathlib import Path
5 | from urllib.parse import urlparse, parse_qs
6 |
7 | from GUI.profiles import STab
8 | from core import utils, tp_text
9 | from core.img_rehost import IH
10 | from core.transplant import Job, Transplanter, JobCreationError
11 | from gazelle.tracker_data import TR
12 | from GUI import gui_text
13 | from GUI.main_gui import MainWindow
14 | from GUI.misc_classes import IniSettings
15 | from GUI.settings_window import SettingsWindow
16 | from GUI.widget_bank import wb, CONFIG_NAMES, ACTION_MAP
17 |
18 | from PyQt6.QtWidgets import QFileDialog, QMessageBox
19 | from PyQt6.QtGui import QDesktopServices, QTextCursor, QShortcut
20 | from PyQt6.QtCore import Qt, QObject, pyqtSignal, QThread, QSize, QUrl, QModelIndex
21 |
22 |
23 | class LogForward(QObject, logging.Handler):
24 | log_forward = pyqtSignal(logging.LogRecord)
25 | flushOnClose = True # logging looks for this attribute on shutdown: https://stackoverflow.com/questions/79588998
26 |
27 | def emit(self, record):
28 | self.log_forward.emit(record)
29 |
30 |
31 | logger = logging.getLogger('tr')
32 | logger.setLevel(logging.INFO)
33 | handler = LogForward()
34 | logger.addHandler(handler)
35 |
36 |
37 | class TransplantThread(QThread):
38 | def __init__(self):
39 | super().__init__()
40 | self.trpl_settings = None
41 |
42 | def run(self):
43 | logger.log(22, gui_text.start)
44 | key_dict = {
45 | TR.RED: wb.config.value('main/le_key_1'),
46 | TR.OPS: wb.config.value('main/le_key_2')
47 | }
48 | transplanter = Transplanter(key_dict, **self.trpl_settings)
49 |
50 | for job in wb.job_data.jobs.copy():
51 | if self.isInterruptionRequested():
52 | break
53 | try:
54 | if job not in wb.job_data.jobs: # It's possible to remove jobs from joblist during transplanting
55 | logger.warning(f'{gui_text.removed} {job.display_name}')
56 | continue
57 | try:
58 | success = transplanter.do_your_job(job)
59 | except Exception:
60 | logger.exception('')
61 | continue
62 | if success:
63 | wb.job_data.remove_this_job(job)
64 |
65 | finally:
66 | logger.info('')
67 |
68 |
69 | def start_up():
70 | wb.main_window = MainWindow()
71 | set_shortcuts()
72 | set_tooltips()
73 | wb.settings_window = SettingsWindow(wb.main_window)
74 | main_connections()
75 | config_connections()
76 | load_config()
77 | wb.emit_state()
78 | wb.pb_scan.setFocus()
79 | wb.main_window.show()
80 |
81 |
82 | def main_connections():
83 | handler.log_forward.connect(print_logs)
84 | wb.profiles.load_profile.connect(load_profile)
85 | wb.profiles.new_profile.connect(new_profile)
86 | wb.profiles.save_profile.connect(save_profile)
87 | wb.te_paste_box.plain_text_changed.connect(lambda x: wb.pb_add.setEnabled(bool(x)))
88 | wb.bg_source.idClicked.connect(lambda x: wb.config.setValue('bg_source', x))
89 | wb.pb_add.clicked.connect(parse_paste_input)
90 | wb.pb_open_dtors.clicked.connect(select_dtors)
91 | wb.pb_scan.clicked.connect(scan_dtorrents)
92 | wb.pb_clear_j.clicked.connect(wb.job_data.clear)
93 | wb.pb_clear_r.clicked.connect(wb.result_view.clear)
94 | wb.pb_rem_sel.clicked.connect(remove_selected)
95 | wb.pb_crop.clicked.connect(crop)
96 | wb.pb_del_sel.clicked.connect(delete_selected)
97 | wb.pb_rem_tr1.clicked.connect(lambda: wb.job_data.filter_for_attr('src_tr', TR.RED))
98 | wb.pb_rem_tr2.clicked.connect(lambda: wb.job_data.filter_for_attr('src_tr', TR.OPS))
99 | wb.pb_open_tsavedir.clicked.connect(
100 | lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(wb.fsb_dtor_save_dir.currentText())))
101 | wb.pb_go.clicked.connect(gogogo)
102 | wb.pb_open_upl_urls.clicked.connect(open_tor_urls)
103 | wb.job_view.horizontalHeader().sectionDoubleClicked.connect(job_view_header_double_clicked)
104 | wb.selection.selectionChanged.connect(lambda: wb.pb_rem_sel.setEnabled(wb.selection.hasSelection()))
105 | wb.selection.selectionChanged.connect(
106 | lambda: wb.pb_crop.setEnabled(0 < len(wb.selection.selectedRows()) < len(wb.job_data.jobs)))
107 | wb.selection.selectionChanged.connect(lambda x: wb.pb_del_sel.setEnabled(wb.selection.hasSelection()))
108 | wb.job_view.doubleClicked.connect(open_torrent_page)
109 | wb.job_data.layout_changed.connect(lambda: wb.pb_go.setEnabled(bool(wb.job_data)))
110 | wb.job_data.layout_changed.connect(lambda: wb.pb_clear_j.setEnabled(bool(wb.job_data)))
111 | wb.job_data.layout_changed.connect(
112 | lambda: wb.pb_rem_tr1.setEnabled(any(j.src_tr is TR.RED for j in wb.job_data)))
113 | wb.job_data.layout_changed.connect(
114 | lambda: wb.pb_rem_tr2.setEnabled(any(j.src_tr is TR.OPS for j in wb.job_data)))
115 | wb.result_view.textChanged.connect(
116 | lambda: wb.pb_clear_r.setEnabled(bool(wb.result_view.toPlainText())))
117 | wb.result_view.textChanged.connect(
118 | lambda: wb.pb_open_upl_urls.setEnabled('torrentid' in wb.result_view.toPlainText()))
119 | wb.tb_open_config.clicked.connect(wb.settings_window.open)
120 | wb.tabs.currentChanged.connect(wb.view_stack.setCurrentIndex)
121 | wb.view_stack.currentChanged.connect(wb.tabs.setCurrentIndex)
122 | wb.view_stack.currentChanged.connect(wb.button_stack.setCurrentIndex)
123 |
124 |
125 | def config_connections():
126 | wb.pb_ok.clicked.connect(settings_check)
127 | wb.settings_window.accepted.connect(settings_accepted)
128 | wb.tb_key_test1.clicked.connect(lambda: api_key_test(TR.RED, wb.le_key_1.text()))
129 | wb.tb_key_test2.clicked.connect(lambda: api_key_test(TR.OPS, wb.le_key_2.text()))
130 | wb.le_key_1.textChanged.connect(lambda t: wb.tb_key_test1.setEnabled(bool(t)))
131 | wb.le_key_2.textChanged.connect(lambda t: wb.tb_key_test2.setEnabled(bool(t)))
132 | wb.fsb_scan_dir.list_changed.connect(
133 | lambda: wb.pb_scan.setEnabled(bool(wb.fsb_scan_dir.currentText())))
134 | wb.fsb_dtor_save_dir.list_changed.connect(
135 | lambda: wb.pb_open_tsavedir.setEnabled(bool(wb.fsb_dtor_save_dir.currentText())))
136 | wb.chb_deep_search.toggled.connect(wb.spb_deep_search_level.setEnabled)
137 | wb.chb_show_tips.toggled.connect(wb.tt_filter.set_tt_enabled)
138 | wb.spb_verbosity.valueChanged.connect(set_verbosity)
139 | wb.chb_rehost.toggled.connect(wb.rh_on_off_container.setEnabled)
140 | wb.pb_def_descr.clicked.connect(default_descr)
141 | wb.sty_style_selector.currentTextChanged.connect(wb.app.set_style)
142 | if wb.theme_writable:
143 | wb.sty_style_selector.currentTextChanged.connect(
144 | lambda t: wb.thm_theme_selector.setEnabled(t.lower() != 'windowsvista'))
145 | wb.thm_theme_selector.current_data_changed.connect(lambda x: wb.app.styleHints().setColorScheme(x))
146 | wb.chb_toolbar_loc.toggled.connect(
147 | lambda checked: wb.main_window.addToolBar(Qt.ToolBarArea.BottomToolBarArea if checked
148 | else Qt.ToolBarArea.TopToolBarArea, wb.toolbar))
149 | wb.chb_show_add_dtors.toggled.connect(wb.pb_open_dtors.setVisible),
150 | wb.chb_show_rem_tr1.toggled.connect(wb.pb_rem_tr1.setVisible),
151 | wb.chb_show_rem_tr2.toggled.connect(wb.pb_rem_tr2.setVisible),
152 | wb.chb_no_icon.toggled.connect(wb.job_data.layoutChanged.emit)
153 | wb.chb_show_tor_folder.toggled.connect(wb.job_data.layoutChanged.emit)
154 | wb.chb_alt_row_colour.toggled.connect(wb.job_view.setAlternatingRowColors)
155 | wb.chb_show_grid.toggled.connect(wb.job_view.setShowGrid)
156 | wb.spb_row_height.valueChanged.connect(wb.job_view.verticalHeader().setDefaultSectionSize)
157 | wb.ple_warning_color.text_changed.connect(lambda t: wb.color_examples.update_colors(t, 1))
158 | wb.ple_error_color.text_changed.connect(lambda t: wb.color_examples.update_colors(t, 2))
159 | wb.ple_success_color.text_changed.connect(lambda t: wb.color_examples.update_colors(t, 3))
160 | wb.ple_link_color.text_changed.connect(lambda t: wb.color_examples.update_colors(t, 4))
161 | wb.ple_link_color.text_changed.connect(lambda c: wb.l_colors.setText(gui_text.l_colors.format(c)))
162 | wb.color_examples.css_changed.connect(wb.result_view.document().setDefaultStyleSheet)
163 |
164 |
165 | def load_config():
166 | source_id = wb.config.value('bg_source', defaultValue=1)
167 | wb.bg_source.button(source_id).click()
168 | wb.main_window.resize(wb.config.value('geometry/size', defaultValue=QSize(850, 550)))
169 |
170 | try:
171 | wb.main_window.move(wb.config.value('geometry/position'))
172 | except TypeError:
173 | pass
174 |
175 | splittersizes = wb.config.value('geometry/splitter_pos', defaultValue=[100, 415], type=int)
176 | wb.splitter.setSizes(splittersizes)
177 | wb.splitter.splitterMoved.emit(splittersizes[0], 1)
178 | wb.job_view.horizontalHeader().restoreState(wb.config.value('geometry/job_view_header'))
179 |
180 | wb.settings_window.resize(wb.config.value('geometry/config_window_size', defaultValue=QSize(400, 450)))
181 |
182 |
183 | def settings_accepted():
184 | wb.config.setValue('geometry/config_window_size', wb.settings_window.size())
185 | for fsb in wb.fsbs:
186 | fsb.consolidate()
187 |
188 |
189 | SC_DATA = (
190 | (('pb_go',), 'Ctrl+Shift+Return'),
191 | (('tabs',), 'Ctrl+Tab'),
192 | (('pb_scan',), 'Ctrl+S'),
193 | (('pb_rem_sel',), 'Backspace'),
194 | (('pb_crop',), 'Ctrl+R'),
195 | (('pb_clear_j', 'pb_clear_r'), 'Ctrl+W'),
196 | (('pb_open_upl_urls',), 'Ctrl+O'),
197 | (('pb_rem_tr1',), 'Ctrl+1'),
198 | (('pb_rem_tr2',), 'Ctrl+2'),
199 | )
200 | widg_sc_map = {}
201 |
202 |
203 | def set_shortcuts():
204 | for w_names, default in SC_DATA:
205 | sc = QShortcut(wb.main_window)
206 | sc.setKey(default)
207 | for w_name in w_names:
208 | widg_sc_map[w_name] = sc
209 | widg = getattr(wb, w_name)
210 | if w_name == 'tabs':
211 | slot = widg.next
212 | else:
213 | slot = widg.animateClick
214 | sc.activated.connect(slot)
215 |
216 |
217 | LINK_REGEX = re.compile(r'(https?://)([^\s\n\r]+)')
218 | REPL_PATTERN = r'\2'
219 | LEVEL_SETTING_NAME_MAP = {
220 | 40: 'bad',
221 | 30: 'warning',
222 | 25: 'good',
223 | 20: 'normal'
224 | }
225 |
226 |
227 | def print_logs(record: logging.LogRecord):
228 | if wb.tabs.count() == 1:
229 | wb.tabs.addTab(gui_text.tab_results)
230 |
231 | cls_val_q, same_line = divmod(record.levelno, 5)
232 | cls_name = LEVEL_SETTING_NAME_MAP.get(cls_val_q * 5)
233 | prefix = ' ' if same_line else '
'
234 | has_exc = bool(record.exc_info) and None not in record.exc_info
235 | wb.result_view.moveCursor(QTextCursor.MoveOperation.End)
236 |
237 | if record.msg or not has_exc:
238 | if record.msg:
239 | msg = LINK_REGEX.sub(REPL_PATTERN, record.msg)
240 | msg = msg.replace('\n', '
')
241 | msg = f"{prefix}{msg}"
242 | else:
243 | msg = prefix
244 |
245 | wb.result_view.insertHtml(msg)
246 |
247 | if has_exc:
248 | cls, ex, tb = record.exc_info
249 | msg = f'{cls.__name__}: {ex}'
250 |
251 | if logger.level < logging.INFO:
252 | wb.result_view.insertHtml('') # to prevent the following text taking previous color
253 | wb.result_view.insertPlainText('\n' + 'Traceback (most recent call last):')
254 | wb.result_view.insertPlainText('\n' + '\n'.join(utils.tb_line_gen(tb)))
255 |
256 | wb.result_view.insertHtml(f'
{msg}')
257 |
258 | wb.result_view.ensureCursorVisible()
259 |
260 |
261 | def trpl_settings():
262 | user_settings = (
263 | 'main/chb_deep_search',
264 | 'main/spb_deep_search_level',
265 | 'main/chb_save_dtors',
266 | 'main/chb_del_dtors',
267 | 'main/chb_file_check',
268 | 'main/chb_post_compare',
269 | 'descriptions/te_rel_descr_templ',
270 | 'descriptions/te_rel_descr_own_templ',
271 | 'descriptions/chb_add_src_descr',
272 | 'descriptions/te_src_descr_templ'
273 | )
274 | settings_dict = {
275 | 'data_dir': Path(wb.fsb_data_dir.currentText()),
276 | 'dtor_save_dir': Path(tsd) if (tsd := wb.fsb_dtor_save_dir.currentText()) else None
277 | }
278 | for s in user_settings:
279 | typ, arg_name = s.split('_', maxsplit=1)
280 | val = wb.config.value(s)
281 | settings_dict[arg_name] = val
282 |
283 | if wb.config.value('rehost/chb_rehost'):
284 | white_str_nospace = ''.join(wb.config.value('rehost/le_whitelist').split())
285 |
286 | whitelist = white_str_nospace.split(',')
287 | if '' in whitelist:
288 | whitelist.remove('')
289 | settings_dict.update(img_rehost=True, whitelist=whitelist)
290 |
291 | return settings_dict
292 |
293 |
294 | def gogogo():
295 | if not wb.job_data:
296 | return
297 |
298 | min_req_config = ("main/le_key_1", "main/le_key_2", "main/fsb_data_dir")
299 | if not all(wb.config.value(x) for x in min_req_config):
300 | wb.settings_window.open()
301 | return
302 |
303 | if wb.tabs.count() == 1:
304 | wb.tabs.addTab(gui_text.tab_results)
305 | wb.tabs.setCurrentIndex(1)
306 |
307 | if not wb.thread:
308 | wb.thread = TransplantThread()
309 | wb.thread.started.connect(lambda: wb.go_stop_stack.setCurrentIndex(1))
310 | wb.thread.started.connect(lambda: wb.pb_stop.clicked.connect(wb.thread.requestInterruption))
311 | wb.thread.finished.connect(lambda: logger.info(gui_text.thread_finish))
312 | wb.thread.finished.connect(lambda: wb.go_stop_stack.setCurrentIndex(0))
313 |
314 | wb.thread.trpl_settings = trpl_settings()
315 | wb.thread.stop_run = False
316 | wb.thread.start()
317 |
318 |
319 | class JobCollector:
320 | def __init__(self):
321 | self.jobs = []
322 |
323 | def collect(self, name, **kwargs):
324 | try:
325 | job = Job(**kwargs)
326 | except JobCreationError as e:
327 | logger.debug(name)
328 | logger.debug(str(e) + '\n')
329 | return
330 |
331 | if job in self.jobs or job in wb.job_data.jobs:
332 | logger.debug(name)
333 | logger.debug(f'{tp_text.skip}{gui_text.dupe_add}\n')
334 | return
335 |
336 | self.jobs.append(job)
337 | return True
338 |
339 | def add_jobs_2_joblist(self, empty_msg=None):
340 | if self.jobs:
341 | wb.job_data.append_jobs(self.jobs)
342 | elif empty_msg:
343 | wb.pop_up.pop_up(empty_msg)
344 | wb.job_view.setFocus()
345 |
346 |
347 | def parse_paste_input():
348 | paste_blob = wb.te_paste_box.toPlainText()
349 | if not paste_blob:
350 | return
351 |
352 | wb.tabs.setCurrentIndex(0)
353 | src_tr = TR(wb.config.value('bg_source'))
354 |
355 | new_jobs = JobCollector()
356 | for line in paste_blob.split():
357 | match_id = re.fullmatch(r"\d+", line)
358 | if match_id:
359 | new_jobs.collect(line, src_tr=src_tr, tor_id=line)
360 | else:
361 | parsed = urlparse(line)
362 | hostname = parsed.hostname
363 | id_list = parse_qs(parsed.query).get('torrentid')
364 | if id_list and hostname:
365 | new_jobs.collect(line, src_dom=hostname, tor_id=id_list.pop())
366 |
367 | new_jobs.add_jobs_2_joblist(gui_text.pop3)
368 | wb.te_paste_box.clear()
369 |
370 |
371 | def select_dtors():
372 | file_paths = QFileDialog.getOpenFileNames(wb.main_window, gui_text.sel_dtors_window_title,
373 | wb.config.value('torselect_dir'),
374 | "torrents (*.torrent);;All Files (*)")[0]
375 | if not file_paths:
376 | return
377 |
378 | wb.tabs.setCurrentIndex(0)
379 | if len(file_paths) > 1:
380 | common_path = os.path.commonpath(file_paths)
381 | else:
382 | common_path = os.path.dirname(file_paths[0])
383 |
384 | wb.config.setValue('torselect_dir', common_path)
385 |
386 | new_jobs = JobCollector()
387 | for fp in file_paths:
388 | p = Path(fp)
389 | new_jobs.collect(p.name, dtor_path=p)
390 |
391 | new_jobs.add_jobs_2_joblist()
392 |
393 |
394 | def scan_dtorrents():
395 | scan_path = Path(wb.fsb_scan_dir.currentText())
396 | wb.tabs.setCurrentIndex(0)
397 |
398 | torpaths = tuple(scan_path.glob('*.torrent'))
399 | new_jobs = JobCollector()
400 |
401 | for p in torpaths:
402 | new_jobs.collect(p.name, dtor_path=p, scanned=True)
403 |
404 | poptxt = gui_text.pop2 if torpaths else gui_text.pop1
405 | new_jobs.add_jobs_2_joblist(f'{poptxt}\n{scan_path}')
406 |
407 |
408 | def settings_check():
409 | data_dir = wb.fsb_data_dir.currentText()
410 | scan_dir = wb.fsb_scan_dir.currentText()
411 | dtor_save_dir = wb.fsb_dtor_save_dir.currentText()
412 | save_dtors = wb.config.value('main/chb_save_dtors')
413 | rehost = wb.config.value('rehost/chb_rehost')
414 | add_src_descr = wb.config.value('descriptions/chb_add_src_descr')
415 |
416 | # Path('') exists and is_dir
417 | sum_ting_wong = []
418 | if not data_dir or not Path(data_dir).is_dir():
419 | sum_ting_wong.append(gui_text.sum_ting_wong_1)
420 | if scan_dir and not Path(scan_dir).is_dir():
421 | sum_ting_wong.append(gui_text.sum_ting_wong_2)
422 | if save_dtors and (not dtor_save_dir or not Path(dtor_save_dir).is_dir()):
423 | sum_ting_wong.append(gui_text.sum_ting_wong_3)
424 | if rehost and not any(h.enabled for h in IH):
425 | sum_ting_wong.append(gui_text.sum_ting_wong_4)
426 | if add_src_descr and '%src_descr%' not in wb.te_src_descr_templ.toPlainText():
427 | sum_ting_wong.append(gui_text.sum_ting_wong_5)
428 |
429 | if sum_ting_wong:
430 | warning = QMessageBox(wb.settings_window)
431 | warning.setWindowFlag(Qt.WindowType.FramelessWindowHint)
432 | warning.setIcon(QMessageBox.Icon.Warning)
433 | warning.setText("- " + "\n- ".join(sum_ting_wong))
434 | warning.exec()
435 | return
436 | else:
437 | wb.settings_window.accept()
438 |
439 |
440 | def set_tooltip(name: str, ttip: str):
441 | obj = getattr(wb, name)
442 | obj.installEventFilter(wb.tt_filter)
443 | obj.setToolTip(ttip)
444 |
445 |
446 | def set_tooltips():
447 | wb.splitter_handle = wb.splitter.handle(1)
448 | for name, ttip in gui_text.tooltips.items():
449 | set_tooltip(name, ttip)
450 | for name, ttip in gui_text.tooltips_with_sc.items():
451 | sc = widg_sc_map.get(name)
452 | if sc:
453 | ttip = f'{ttip} ({sc.key().toString()})'
454 | set_tooltip(name, ttip)
455 |
456 |
457 | def default_descr():
458 | wb.te_rel_descr_templ.setText(gui_text.def_rel_descr)
459 | wb.te_rel_descr_own_templ.setText(gui_text.def_rel_descr_own)
460 | wb.te_src_descr_templ.setText(gui_text.def_src_descr)
461 |
462 |
463 | def open_tor_urls():
464 | for piece in wb.result_view.toPlainText().split():
465 | if 'torrentid' in piece:
466 | QDesktopServices.openUrl(QUrl('https://' + piece))
467 |
468 |
469 | def remove_selected():
470 | row_list = wb.selection.selectedRows()
471 | if not row_list:
472 | return
473 |
474 | wb.job_data.del_multi(row_list)
475 |
476 |
477 | def crop():
478 | row_list = wb.selection.selectedRows()
479 | if not row_list:
480 | return
481 |
482 | reversed_selection = [x for x in range(len(wb.job_data.jobs)) if x not in row_list]
483 | wb.job_data.del_multi(reversed_selection)
484 | wb.selection.clearSelection()
485 |
486 |
487 | def delete_selected():
488 | row_list = wb.selection.selectedRows()
489 | if not row_list:
490 | return
491 |
492 | non_scanned = 0
493 | for i in row_list.copy():
494 | job = wb.job_data.jobs[i]
495 | if job.scanned:
496 | job.dtor_path.unlink()
497 | else:
498 | row_list.remove(i)
499 | non_scanned += 1
500 |
501 | if non_scanned:
502 | wb.pop_up.pop_up(gui_text.pop4.format(non_scanned, 's' if non_scanned > 1 else ''))
503 |
504 | wb.job_data.del_multi(row_list)
505 |
506 |
507 | def job_view_header_double_clicked(section: int):
508 | if section == 2:
509 | wb.job_data.nt_check_uncheck_all()
510 |
511 |
512 | def open_torrent_page(index: QModelIndex):
513 | if index.column() > 0:
514 | return
515 | job = wb.job_data.jobs[index.row()]
516 | domain = job.src_tr.site
517 | if job.info_hash:
518 | url = domain + 'torrents.php?searchstr=' + job.info_hash
519 | elif job.tor_id:
520 | url = domain + 'torrents.php?torrentid=' + job.tor_id
521 | else:
522 | return
523 | QDesktopServices.openUrl(QUrl(url))
524 |
525 |
526 | def new_profile(profile_name: str, tabs: STab):
527 | if not hasattr(tabs, '__iter__'): # Flag members are not iterable in py 3.10-
528 | tabs = tuple(t for t in STab if t in tabs)
529 | suffix = f"({','.join(t.name[0] for t in tabs)})"
530 | profile_name = f'{profile_name.strip()} {suffix}.tpp'
531 | try:
532 | Path(profile_name).touch(exist_ok=False)
533 | except OSError as e:
534 | wb.pop_up.pop_up(gui_text.prof_bad_filename.format(e), 4000)
535 | return
536 | save_profile(profile_name, tabs)
537 |
538 |
539 | def check_file_exists(profile_name: str) -> bool:
540 | if not os.path.isfile(profile_name):
541 | wb.pop_up.pop_up(gui_text.prof_file_gone.format(profile_name))
542 | wb.profiles.refresh()
543 | return False
544 | return True
545 |
546 |
547 | def save_profile(profile_name: str, tabs: STab = None):
548 | profile = IniSettings(profile_name)
549 | if not tabs:
550 | if not check_file_exists(profile_name):
551 | return
552 | tabs = STab(0)
553 | for tab_name in profile.childGroups():
554 | tabs |= STab[tab_name]
555 |
556 | for tab, sd in CONFIG_NAMES.items():
557 | if tab not in tabs:
558 | continue
559 |
560 | wb.config.beginGroup(tab.name)
561 | profile.beginGroup(tab.name)
562 | for el_name in sd:
563 | val = wb.config.value(el_name)
564 | profile.setValue(el_name, val)
565 |
566 | wb.config.endGroup()
567 | profile.endGroup()
568 |
569 | profile.sync()
570 | wb.profiles.refresh()
571 |
572 |
573 | def load_profile(profile_name: str):
574 | if not check_file_exists(profile_name):
575 | return
576 | profile = IniSettings(profile_name)
577 | for key in profile.allKeys():
578 | current_value = wb.config.value(key)
579 | new_value = profile.value(key)
580 | if current_value == new_value:
581 | continue
582 |
583 | obj = getattr(wb, key.partition('/')[2])
584 | signal_func, set_value_func = ACTION_MAP[type(obj)]
585 | set_value_func(obj, new_value)
586 |
587 |
588 | def key_precheck(tracker: TR, key: str) -> str:
589 | if key != key.strip():
590 | return gui_text.keycheck_spaces
591 |
592 | if tracker is TR.RED:
593 | m = re.match(r'[0-9a-f]{8}\.[0-9a-f]{32}', key)
594 | if not m:
595 | return gui_text.keycheck_red_mismatch
596 |
597 | elif tracker is TR.OPS:
598 | if len(key) not in (116, 118):
599 | return gui_text.keycheck_ops_mismatch
600 |
601 | return ''
602 |
603 |
604 | def api_key_test(tracker: TR, key: str):
605 | msg_box = QMessageBox(wb.settings_window)
606 | msg_box.setWindowTitle(gui_text.msg_box_title.format(tracker.name))
607 | precheck_msg = key_precheck(tracker, key)
608 | if precheck_msg:
609 | msg_box.setIcon(QMessageBox.Icon.Critical)
610 | msg_box.setText(precheck_msg)
611 | msg_box.show()
612 | else:
613 | from gazelle.api_classes import sleeve, RequestFailure
614 | api = sleeve(tracker, key=key)
615 | try:
616 | account_info = api.account_info
617 | except RequestFailure as e:
618 | msg_box.setIcon(QMessageBox.Icon.Critical)
619 | msg_box.setText(gui_text.keycheck_bad_key.format(tracker.name, e))
620 | else:
621 | msg_box.setIcon(QMessageBox.Icon.Information)
622 | msg_box.setText(gui_text.keycheck_good_key.format(account_info['username']))
623 | msg_box.show()
624 |
625 |
626 | def set_verbosity(lvl: int):
627 | verb_map = {
628 | 0: logging.CRITICAL,
629 | 1: logging.ERROR,
630 | 2: logging.INFO,
631 | 3: logging.DEBUG}
632 | logger.setLevel(verb_map[lvl])
633 |
634 |
635 | def save_state():
636 | wb.config.setValue('geometry/size', wb.main_window.size())
637 | wb.config.setValue('geometry/position', wb.main_window.pos())
638 | wb.config.setValue('geometry/splitter_pos', wb.splitter.sizes())
639 | wb.config.setValue('geometry/job_view_header', wb.job_view.horizontalHeader().saveState())
640 | wb.config.sync()
641 |
--------------------------------------------------------------------------------
/GUI/gui_text.py:
--------------------------------------------------------------------------------
1 | from gazelle.tracker_data import TR
2 |
3 | # thread
4 | start = 'Starting\n'
5 | removed = 'No longer on job list:'
6 | thread_finish = 'Finished\n'
7 |
8 | # main
9 | main_window_title = "Transplant {}"
10 | sel_dtors_window_title = "Select .torrent files"
11 | pb_placeholder = ("Paste/type torrent ids and/or urls here.\n"
12 | "Space or newline separated.\n"
13 | "The source buttons only apply to ids.")
14 | tab_results = "Results"
15 | tab_joblist = "Job list"
16 |
17 | header_restore = 'Restore all'
18 | job_list_headers = ('torrent', ' dest. group ', ' nt ')
19 |
20 | pb_add = "Add"
21 | open_dtors = "Add .torrent files"
22 | pb_scan = "Scan"
23 | pb_clear = "Clear"
24 | pb_rem_sel = "Rem sel"
25 | pb_crop = "Crop"
26 | pb_del_sel = "Del sel"
27 | pb_rem_tr1 = f"Rem {TR.RED.name}"
28 | pb_rem_tr2 = f"Rem {TR.OPS.name}"
29 | pb_open_tsavedir = "Save dir"
30 | pb_open_upl_urls = "Open urls"
31 | pb_stop = 'Stop'
32 |
33 | profile_buttons = ('Load', 'Save', 'New', 'Delete')
34 | profiles = 'Profiles: '
35 | prof_action_new = 'create new'
36 | prof_action_save = 'overwrite'
37 | prof_action_del = 'delete'
38 | prof_conf = 'Are you sure you want to {action} profile "{profile}"?'
39 | prof_placeholder = 'Enter new profile name'
40 | prof_bad_filename = 'Could not create file:\n{}'
41 | prof_file_gone = 'Could not find file:\n{}'
42 | newprof_window = 'New Profile'
43 | newprof_name_label = 'New profile name:'
44 |
45 | sum_ting_wong_1 = 'Invalid data folder'
46 | sum_ting_wong_2 = 'Invalid scan folder'
47 | sum_ting_wong_3 = 'Invalid torrent save folder'
48 | sum_ting_wong_4 = 'No image hosts enabled'
49 | sum_ting_wong_5 = 'Source description text must contain %src_descr%'
50 |
51 | dupe_add = ': Torrent already added'
52 |
53 | # Pop-ups
54 | pop1 = 'No (suitable) .torrents in'
55 | pop2 = 'Nothing new found in'
56 | pop3 = 'Nothing useful in pastebox'
57 | pop4 = ('{} torrent{} not deleted.\n'
58 | 'Only scanned torrents can be deleted')
59 |
60 | # settings
61 | settings_window_title = "Settings"
62 | pb_cancel = "Cancel"
63 | pb_ok = "OK"
64 | main_tab = 'Main'
65 | rehost_tab = 'Rehost'
66 | desc_tab = 'Rel Descr'
67 | looks_tab = 'Looks'
68 |
69 | tb_test = 'test'
70 | msg_box_title = '{} API-key check'
71 | keycheck_spaces = 'Key has leading or trailing spaces'
72 | keycheck_red_mismatch = ('Key does not match the pattern for RED keys:\n'
73 | '8 chars . 32 chars\n'
74 | 'xxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n')
75 | keycheck_ops_mismatch = ('An OPS key must be 116 character long\n'
76 | '(older keys can be 118 chars long)')
77 | keycheck_bad_key = ('Not a valid key\n'
78 | '{} says: {}')
79 | keycheck_good_key = 'Hello {}, this key is valid'
80 |
81 | chb_deep_search = 'Deep search to level:'
82 |
83 | default_whitelist = "ptpimg.me, thesungod.xyz"
84 | rehost_columns = ('Host', 'API key')
85 |
86 | l_placeholders = ("Set a custom release description.\n\n"
87 | "You can use these placeholders:\n"
88 | "%src_id% : Source id (OPS/RED)\n"
89 | "%src_url% : Source url (eg https://redacted.ch/)\n"
90 | "%ori_upl% : Name of uploader\n"
91 | "%upl_id% : id of uploader\n"
92 | "%tor_id% : Source torrent id\n"
93 | "%gr_id% : Source torrent group id\n")
94 |
95 | pb_def_descr = 'Restore Defaults'
96 | l_own_uploads = "Description for own uploads."
97 |
98 | def_rel_descr = ("Transplanted from [url=%src_url%torrents.php?torrentid=%tor_id%]%src_id%[/url],\n"
99 | "thanks to the original uploader.")
100 | def_rel_descr_own = "Transplant of my own upload on [url=%src_url%torrents.php?torrentid=%tor_id%]%src_id%[/url]."
101 | chb_add_src_descr = "Add release description from source if present. (Must contain %src_descr%)"
102 | def_src_descr = "[quote=source description:]%src_descr%[/quote]"
103 |
104 | l_job_list = 'Job list:'
105 | l_colors = ('Set colours for text in the results pane.
'
106 | 'Use colour names, hex values: #xxxxxx, or rgb values: rgb(xx,xx,xx)
'
107 | 'See '
108 | 'wikipedia.org/wiki/Web_colors,
'
109 | 'or one of the thousand online html colour pickers
'
110 | 'Leave empty for default text colour.')
111 |
112 | # user input element labels
113 | l_key_1 = f"API-key {TR.RED.name}"
114 | l_key_2 = f"API-key {TR.OPS.name}"
115 | l_data_dir = 'Data folder'
116 | l_scan_dir = 'Scan folder'
117 | l_save_dtors = 'Save new .torrrents'
118 | l_del_dtors = 'Delete scanned .torrents'
119 | l_file_check = 'Check files'
120 | l_post_compare = 'Post upload checks'
121 | l_show_tips = "Show tooltips"
122 | l_verbosity = 'Verbosity'
123 | l_rehost = 'Rehost cover art'
124 | l_whitelist = 'Image host whitelist'
125 | l_rehost_table = ('Enable image hosts with the checkbox.\n'
126 | 'Change priority by dragging rows up or down. (drag row header)\n'
127 | 'Enabled hosts will be tried from the top down.\n'
128 | 'If the first one fails the next will be tried and so forth.')
129 | l_style_selector = 'GUI Style'
130 | l_theme_selector = 'Light/Dark theme'
131 | l_toolbar_loc = 'Toolbar at bottom'
132 | l_show_add_dtors = "Show 'Add torrent files' button"
133 | l_show_rem_tr1 = f"Show '{pb_rem_tr1}' button"
134 | l_show_rem_tr2 = f"Show '{pb_rem_tr2}' button"
135 | l_no_icon = 'Text instead of icon'
136 | l_show_tor_folder = 'Torrent folder instead of file name'
137 | l_alt_row_colour = 'Alternating row colours'
138 | l_show_grid = 'Show grid'
139 | l_row_height = 'Row height'
140 | l_warning_color = 'Warning'
141 | l_error_color = 'Error'
142 | l_success_color = 'Success'
143 | l_link_color = 'link'
144 |
145 | tooltips = {
146 | 'l_key_1': ("Get your API-key from the site's user settings\n"
147 | "Please note that these keys are stored in plain text"),
148 | 'l_key_2': ("Get your API-key from the site's user settings\n"
149 | "Please note that these keys are stored in plain text"),
150 | 'l_data_dir': "This should be the top level folder where the album folders can be found",
151 | 'chb_deep_search': ("When checked, the data folder will be searched for torrent folders up til 'level' deep,\n"
152 | "level 1 is direct subfolder of data dir. Subfolder of that is level 2 etc."),
153 | 'l_scan_dir': ("This folder will be scanned for .torrents when the 'Scan' button is pressed\n"
154 | "You can download the .torrents from the source tracker here"),
155 | 'l_save_dtors': ("Newly created .torrents from the destination tracker can be saved here\n"
156 | "A torrent client's watch folder would be a logical choice to select here"),
157 | 'fsb_data_dir': "Select data folder",
158 | 'fsb_scan_dir': "Select scan folder",
159 | 'fsb_dtor_save_dir': "Select save folder",
160 | 'l_del_dtors': ("If checked, .torrents from the scan folder will be deleted after successful upload\n"
161 | "This setting does not apply to .torrents that were added with the 'Add .torrent files' button\n"
162 | "These will not be deleted"),
163 | 'l_file_check': ("if checked, Transplant will verify that the torrent content (~music files) can be found\n"
164 | "This will prevent transplanting torrents that you can't seed"),
165 | 'l_post_compare': "Check if the upload was merged into an existing group or if the log scores are different",
166 | 'l_show_tips': "Tip the tools",
167 | 'l_verbosity': ("Level of feedback.\n"
168 | "0: silent\n"
169 | "1: only errors\n"
170 | "2: normal\n"
171 | "3: debugging"),
172 | 'l_rehost': 'Rehost non-whitelisted cover images',
173 | 'l_whitelist': ("Images hosted on these sites will not be rehosted\n"
174 | "Comma separated"),
175 | 'pb_def_descr': 'Restore default descriptions',
176 | 'l_theme_selector': 'Enabled for PyQt versions 6.8+',
177 | 'rb_tracker1': ("Select source tracker for torrent id's entered in the paste box\n"
178 | "This setting does not apply to url's and .torrents"),
179 | 'rb_tracker2': ("Select source tracker for torrent id's entered in the paste box\n"
180 | "This setting does not apply to url's and .torrents"),
181 | 'tb_open_config': settings_window_title,
182 | 'pb_add': ("Add content of the paste box to the job list\n"
183 | "Only valid entries will be added"),
184 | 'pb_open_dtors': "Select .torrents to add to the job list",
185 | 'splitter_handle': 'Drag all the way up to hide top section',
186 | }
187 | tooltips_with_sc = {
188 | 'pb_go': "Start Transplanting",
189 | 'pb_open_upl_urls': "Open all uploads in browser",
190 | 'pb_rem_tr1': f"Remove all {TR.RED.name} jobs from job list",
191 | 'pb_rem_tr2': f"Remove all {TR.OPS.name} jobs from job list",
192 | 'pb_scan': ("Scan the 'scan folder' for .torrents and add them to the job list\n"
193 | "Subfolders will not be scanned"),
194 | 'pb_clear_j': "Empty the job list",
195 | 'pb_clear_r': "Empty the results pane",
196 | 'pb_rem_sel': "Remove selected jobs (torrents) from the job list",
197 | 'pb_crop': "Keep selection",
198 | 'pb_del_sel': "Delete selected .torrent files from scan dir",
199 | 'pb_open_tsavedir': "Open torrent save location",
200 | }
201 | ttm_header1 = "Upload to a specific group"
202 | ttm_header2 = ('Create new .torrent file\n'
203 | 'instead of converting source torrent')
204 |
--------------------------------------------------------------------------------
/GUI/main_gui.py:
--------------------------------------------------------------------------------
1 | from PyQt6.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QGridLayout, QMainWindow
2 | from PyQt6.QtGui import QIcon
3 | from PyQt6.QtCore import QSize
4 |
5 | from core.tp_text import tp_version
6 | from GUI import gui_text
7 | from GUI.widget_bank import wb
8 |
9 |
10 | class MainWindow(QMainWindow):
11 | def __init__(self):
12 | super().__init__()
13 | self.setWindowTitle(gui_text.main_window_title.format('.'.join(map(str, tp_version))))
14 | self.setWindowIcon(QIcon(':/switch.svg'))
15 | self.setCentralWidget(CentralWidget())
16 | self.addToolBar(wb.toolbar)
17 | wb.toolbar.addWidget(wb.profiles)
18 | wb.toolbar.addWidget(wb.tb_spacer)
19 | wb.toolbar.addWidget(wb.tb_open_config)
20 | wb.toolbar.setIconSize(QSize(16, 16))
21 |
22 |
23 | class CentralWidget(QWidget):
24 | def __init__(self):
25 | super().__init__()
26 | self.layout()
27 |
28 | def layout(self):
29 | wb.splitter.addWidget(wb.topwidget)
30 | wb.splitter.addWidget(wb.bottomwidget)
31 | wb.splitter.setCollapsible(1, False)
32 | wb.splitter.setStretchFactor(0, 0)
33 | wb.splitter.setStretchFactor(1, 1)
34 |
35 | total_layout = QHBoxLayout(self)
36 | total_layout.setContentsMargins(0, 0, 0, 0)
37 | total_layout.addWidget(wb.splitter)
38 |
39 | # Top
40 | source_buttons = QVBoxLayout()
41 | source_buttons.setSpacing(0)
42 | source_buttons.addStretch(3)
43 | source_buttons.addWidget(wb.rb_tracker1)
44 | source_buttons.addWidget(wb.rb_tracker2)
45 | source_buttons.addStretch(1)
46 | source_buttons.addWidget(wb.pb_add)
47 |
48 | top_layout = QHBoxLayout(wb.topwidget)
49 | top_layout.addWidget(wb.te_paste_box)
50 | top_layout.addLayout(source_buttons, stretch=0)
51 |
52 | # Bottom
53 | buttons_job = QVBoxLayout(wb.job_buttons)
54 | buttons_job.setContentsMargins(0, 0, 0, 0)
55 | buttons_job.addWidget(wb.pb_clear_j)
56 | buttons_job.addWidget(wb.pb_rem_sel)
57 | buttons_job.addWidget(wb.pb_crop)
58 | buttons_job.addWidget(wb.pb_del_sel)
59 | buttons_job.addWidget(wb.pb_rem_tr1)
60 | buttons_job.addWidget(wb.pb_rem_tr2)
61 | buttons_result = QVBoxLayout(wb.result_buttons)
62 | buttons_result.setContentsMargins(0, 0, 0, 0)
63 | buttons_result.addWidget(wb.pb_clear_r)
64 | buttons_result.addWidget(wb.pb_open_upl_urls)
65 | buttons_result.addStretch()
66 |
67 | wb.button_stack.addWidget(wb.job_buttons)
68 | wb.button_stack.addWidget(wb.result_buttons)
69 |
70 | wb.go_stop_stack.addWidget(wb.pb_go)
71 | wb.go_stop_stack.addWidget(wb.pb_stop)
72 |
73 | control_buttons = QVBoxLayout()
74 | control_buttons.setSpacing(total_layout.spacing())
75 | control_buttons.addLayout(wb.button_stack)
76 | control_buttons.addStretch(1)
77 | control_buttons.addWidget(wb.pb_open_tsavedir)
78 | control_buttons.addStretch(3)
79 | control_buttons.addLayout(wb.go_stop_stack)
80 |
81 | wb.view_stack.addWidget(wb.job_view)
82 | wb.view_stack.addWidget(wb.result_view)
83 |
84 | add_n_scan = QHBoxLayout()
85 | add_n_scan.setSpacing(total_layout.spacing())
86 | add_n_scan.addStretch()
87 | add_n_scan.addWidget(wb.pb_open_dtors)
88 | add_n_scan.addWidget(wb.pb_scan)
89 |
90 | right_side = QVBoxLayout()
91 | right_side.addLayout(add_n_scan)
92 | right_side.addSpacing(total_layout.spacing())
93 |
94 | view_n_buttons = QGridLayout()
95 | view_n_buttons.setVerticalSpacing(0)
96 | view_n_buttons.addLayout(right_side, 0, 1, 1, 2)
97 | view_n_buttons.addWidget(wb.tabs, 0, 0)
98 | view_n_buttons.addLayout(wb.view_stack, 1, 0, 1, 2)
99 | view_n_buttons.addLayout(control_buttons, 1, 2)
100 | view_n_buttons.setColumnStretch(0, 5)
101 | view_n_buttons.setColumnStretch(1, 1)
102 | view_n_buttons.setColumnStretch(2, 0)
103 |
104 | bottom_layout = QVBoxLayout(wb.bottomwidget)
105 | bottom_layout.addLayout(view_n_buttons)
106 |
--------------------------------------------------------------------------------
/GUI/misc_classes.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 |
4 | from PyQt6.QtWidgets import (QFrame, QTextEdit, QComboBox, QFileDialog, QLineEdit, QTabBar, QVBoxLayout, QLabel,
5 | QTextBrowser, QSizePolicy, QApplication, QStyleFactory, QToolButton, QPushButton)
6 | from PyQt6.QtGui import QIcon, QAction, QIconEngine
7 | from PyQt6.QtCore import Qt, QObject, QEvent, pyqtSignal, QSettings, QTimer, QAbstractListModel, QModelIndex
8 |
9 |
10 | class TTfilter(QObject):
11 | def __init__(self, *args):
12 | super().__init__(*args)
13 | self.tt_enabled = False
14 |
15 | def set_tt_enabled(self, enabled: bool):
16 | self.tt_enabled = enabled
17 |
18 | def eventFilter(self, obj, event):
19 | if event.type() == QEvent.Type.ToolTip and not self.tt_enabled:
20 | return True
21 | return False
22 |
23 |
24 | class PButton(QPushButton):
25 | def animateClick(self):
26 | if self.isVisible():
27 | super().animateClick()
28 |
29 |
30 | class ClickableLabel(QLabel):
31 | clicked = pyqtSignal()
32 |
33 | def mouseReleaseEvent(self, event):
34 | self.clicked.emit()
35 | super().mouseReleaseEvent(event)
36 |
37 |
38 | class Application(QApplication):
39 | def __init__(self, *args, **kwargs):
40 | super().__init__(*args, **kwargs)
41 | self.scheme = None
42 | self.scheme_eval()
43 | self.styleHints().colorSchemeChanged.connect(self.scheme_eval)
44 |
45 | def set_style(self, style):
46 | super().setStyle(style)
47 | self.scheme_eval()
48 |
49 | def scheme_eval(self):
50 | style = self.style().name()
51 | cur_scheme = self.styleHints().colorScheme()
52 | if cur_scheme is not Qt.ColorScheme.Dark or style == 'windowsvista':
53 | scheme = Qt.ColorScheme.Light
54 | else:
55 | scheme = Qt.ColorScheme.Dark
56 |
57 | self.scheme = scheme
58 |
59 |
60 | class ThemeEngine(QIconEngine):
61 | app = None
62 | offload = {}
63 |
64 | def __init__(self, file_name, f1, f2):
65 | super().__init__()
66 | if not self.app:
67 | self.__class__.app = QApplication.instance()
68 | self.file_name = file_name
69 | if self.file_name not in self.offload:
70 | self.offload[self.file_name] = {
71 | Qt.ColorScheme.Light: QIcon(f1),
72 | Qt.ColorScheme.Dark: QIcon(f2),
73 | }
74 |
75 | def pixmap(self, size, mode, state):
76 | return self.offload[self.file_name][self.app.scheme].pixmap(size, mode, state)
77 |
78 |
79 | class ThemeIcon(QIcon):
80 | def __init__(self, file_name):
81 | f1 = f':/light/{file_name}'
82 | f2 = f':/dark/{file_name}'
83 | engine = ThemeEngine(file_name, f1, f2)
84 | super().__init__(engine)
85 |
86 |
87 | class TempPopUp(QFrame):
88 | def __init__(self, parent):
89 | super().__init__(parent)
90 | self.setWindowFlag(Qt.WindowType.Tool | Qt.WindowType.FramelessWindowHint)
91 | self.setFrameShape(QFrame.Shape.Box)
92 | self.timer = QTimer()
93 | self.timer.setSingleShot(True)
94 | self.timer.timeout.connect(self.close)
95 | self.message = QLabel()
96 | lay = QVBoxLayout(self)
97 | lay.addWidget(self.message)
98 |
99 | def pop_up(self, message, time: int = 2000):
100 | self.message.setText(message)
101 | self.show()
102 | self.timer.start(time)
103 |
104 |
105 | class PatientLineEdit(QLineEdit):
106 | text_changed = pyqtSignal(str)
107 |
108 | def __init__(self):
109 | super().__init__()
110 | self.last_text = None
111 | self.timer = QTimer()
112 | self.timer.setSingleShot(True)
113 | self.timer.setInterval(1500)
114 | self.textChanged.connect(self.timer.start)
115 | self.timer.timeout.connect(self.emit_change)
116 |
117 | def emit_change(self):
118 | if self.text() == self.last_text:
119 | return
120 | self.last_text = self.text()
121 | self.text_changed.emit(self.text())
122 |
123 |
124 | class ColorExample(QTextBrowser):
125 | css_changed = pyqtSignal(str)
126 |
127 | texts = ('This is normal text
'
128 | 'This may require your attention
'
129 | 'Oops, something went bad
'
130 | 'That went well
'
131 | 'example.com')
132 |
133 | def __init__(self, config: QSettings):
134 | super().__init__()
135 | self.current_colors = {i: '_' for i in range(1, 5)}
136 |
137 | @property
138 | def css(self):
139 | return (f'.warning {{color: {self.current_colors[1]}}}'
140 | f'.bad {{color: {self.current_colors[2]}}}'
141 | f'.good {{color: {self.current_colors[3]}}}'
142 | f'a {{color: {self.current_colors[4]}}}')
143 |
144 | def update_colors(self, color: str, index):
145 | color = ''.join(color.split())
146 | if color == self.current_colors[index]:
147 | return
148 | self.current_colors[index] = color
149 | css = self.css
150 | self.css_changed.emit(css)
151 | self.document().setDefaultStyleSheet(css)
152 | self.setHtml(self.texts)
153 |
154 |
155 | class StyleSelector(QComboBox):
156 | def __init__(self):
157 | super().__init__()
158 | self.addItems(QStyleFactory.keys())
159 |
160 |
161 | class ThemeSelector(QComboBox):
162 | current_data_changed = pyqtSignal(Qt.ColorScheme)
163 |
164 | def __init__(self):
165 | super().__init__()
166 | self.setModel(ThemeModel())
167 | self.currentTextChanged.connect(lambda x: self.current_data_changed.emit(self.currentData()))
168 |
169 |
170 | class ThemeModel(QAbstractListModel):
171 | def __init__(self):
172 | super().__init__()
173 | self.themes = Qt.ColorScheme
174 | self.th_names = tuple(t.name.replace('Unknown', 'System') for t in self.themes)
175 |
176 | def rowCount(self, parent=None):
177 | return len(self.themes)
178 |
179 | def data(self, index: QModelIndex, role: int = 1):
180 | if role == Qt.ItemDataRole.DisplayRole:
181 | return self.th_names[index.row()]
182 | if role == Qt.ItemDataRole.UserRole:
183 | return self.themes(index.row())
184 |
185 |
186 | class HistoryBox(QComboBox):
187 | list_changed = pyqtSignal(list)
188 |
189 | def set_list(self, item_list):
190 | if item_list:
191 | self.addItems(item_list)
192 | self.list_changed.emit(item_list)
193 |
194 | @property
195 | def list(self):
196 | return [self.itemText(i) for i in range(self.count())]
197 |
198 | def add(self, txt):
199 | if (index := self.findText(txt)) > 0:
200 | self.setCurrentIndex(index)
201 | elif index < 0:
202 | self.insertItem(0, txt)
203 | self.setCurrentIndex(0)
204 | self.list_changed.emit(self.list)
205 |
206 | def consolidate(self):
207 | self.add(self.currentText())
208 | if self.currentIndex() > 0:
209 | txt = self.currentText()
210 | self.removeItem(self.currentIndex())
211 | self.add(txt)
212 |
213 |
214 | class FolderSelectBox(HistoryBox):
215 | def __init__(self):
216 | super().__init__()
217 | self.setEditable(True)
218 | self.setMaxCount(8)
219 | self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred)
220 | self.setSizeAdjustPolicy(self.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
221 | self.folder_action = QAction()
222 | self.folder_action.setIcon(ThemeIcon('open-folder'))
223 | self.folder_action.triggered.connect(self.select_folder)
224 | self.lineEdit().addAction(self.folder_action, QLineEdit.ActionPosition.TrailingPosition)
225 | self.folder_button = self.lineEdit().findChild(QToolButton)
226 | self.dialog_caption = None
227 |
228 | def select_folder(self):
229 | selected = QFileDialog.getExistingDirectory(self, self.dialog_caption, self.currentText())
230 | if not selected:
231 | return
232 | selected = os.path.normpath(selected)
233 | self.add(selected)
234 |
235 | def setToolTip(self, txt):
236 | self.folder_action.setToolTip(txt)
237 |
238 | def installEventFilter(self, f):
239 | self.folder_button.installEventFilter(f)
240 |
241 |
242 | class IniSettings(QSettings):
243 | int_regex = re.compile(r'#int\((\d+)\)')
244 | bool_regex = re.compile(r'#bool\((True|False)\)')
245 |
246 | def __init__(self, path):
247 | super().__init__(path, QSettings.Format.IniFormat)
248 |
249 | def setValue(self, key, value):
250 | if type(value) is int:
251 | value = f'#int({value})'
252 | elif type(value) is bool:
253 | value = f'#bool({value})'
254 | elif isinstance(value, list) and not value:
255 | value = '#empty list'
256 | super().setValue(key, value)
257 |
258 | def value(self, key, **kwargs):
259 | value = super().value(key, **kwargs)
260 | if isinstance(value, str):
261 | if int_match := self.int_regex.match(value):
262 | value = int(int_match.group(1))
263 | elif bool_match := self.bool_regex.match(value):
264 | value = bool_match.group(1) == 'True'
265 | elif value == '#empty list':
266 | value = []
267 |
268 | return value
269 |
270 |
271 | class TPTextEdit(QTextEdit):
272 | plain_text_changed = pyqtSignal(str)
273 |
274 | def __init__(self):
275 | super().__init__()
276 | self.textChanged.connect(lambda: self.plain_text_changed.emit(self.toPlainText()))
277 |
278 |
279 | class CyclingTabBar(QTabBar):
280 | def next(self):
281 | total = self.count()
282 | if total > 1:
283 | current = self.currentIndex()
284 |
285 | if current < total - 1:
286 | self.setCurrentIndex(current + 1)
287 | else:
288 | self.setCurrentIndex(0)
289 |
--------------------------------------------------------------------------------
/GUI/mv_classes.py:
--------------------------------------------------------------------------------
1 | from functools import partial
2 | from typing import Iterable, Iterator
3 |
4 | from PyQt6.QtWidgets import QHeaderView, QTableView
5 | from PyQt6.QtGui import QIcon, QKeyEvent, QAction
6 | from PyQt6.QtCore import Qt, pyqtSignal, QAbstractTableModel, QModelIndex, QItemSelectionModel, QSignalBlocker
7 |
8 | from GUI import gui_text
9 | from core.img_rehost import IH
10 | from gazelle.tracker_data import TR
11 |
12 | try:
13 | # pairwise = 3.10+
14 | from itertools import pairwise
15 | except ImportError:
16 | def pairwise(it: Iterable):
17 | iterator = iter(it)
18 | a = next(iterator, None)
19 | for b in iterator:
20 | yield a, b
21 | a = b
22 |
23 |
24 | class IntRowItemSelectionModel(QItemSelectionModel):
25 | def selectedRows(self, column=0) -> list[int]:
26 | return [i.row() for i in super().selectedRows(column)]
27 |
28 |
29 | class JobView(QTableView):
30 | def __init__(self, model):
31 | super().__init__()
32 | self.setModel(model)
33 | self.setSelectionModel(IntRowItemSelectionModel(self.model()))
34 | self.setEditTriggers(QTableView.EditTrigger.SelectedClicked | QTableView.EditTrigger.DoubleClicked |
35 | QTableView.EditTrigger.AnyKeyPressed)
36 | self.setHorizontalHeader(ContextHeaderView(Qt.Orientation.Horizontal, self))
37 | self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
38 | self.verticalHeader().hide()
39 | self.verticalHeader().setMinimumSectionSize(12)
40 | self.horizontalHeader().setSectionsMovable(True)
41 | self.horizontalHeader().setMinimumSectionSize(18)
42 | self.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
43 | self.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
44 | self.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
45 |
46 | def keyPressEvent(self, event: QKeyEvent):
47 | if event.modifiers() == Qt.KeyboardModifier.ControlModifier and event.key() == Qt.Key.Key_Tab:
48 | event.ignore()
49 | else:
50 | super().keyPressEvent(event)
51 |
52 |
53 | class ContextHeaderView(QHeaderView):
54 | section_visibility_changed = pyqtSignal(int, bool)
55 |
56 | def __init__(self, orientation, parent):
57 | super().__init__(orientation, parent)
58 | self.section_visibility_changed.connect(self.set_action_checked)
59 | self.section_visibility_changed.connect(self.disable_actions)
60 | self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
61 |
62 | def text(self, section):
63 | return self.model().headerData(section, self.orientation()).strip()
64 |
65 | def setSectionHidden(self, index, hide):
66 | if not self.isSectionHidden(index) == hide:
67 | super().setSectionHidden(index, hide)
68 | self.section_visibility_changed.emit(index, hide)
69 |
70 | def set_section_visible(self, index: int, visible: bool):
71 | self.setSectionHidden(index, not visible)
72 |
73 | def restoreState(self, state):
74 | try:
75 | super().restoreState(state)
76 | except TypeError:
77 | pass
78 | self.context_actions()
79 |
80 | def context_actions(self):
81 | ac_restore_all = QAction(gui_text.header_restore, self)
82 | self.addAction(ac_restore_all)
83 | ac_restore_all.triggered.connect(self.set_all_sections_visible)
84 | ac_restore_all.setEnabled(bool(self.hiddenSectionCount()))
85 |
86 | for i in range(self.model().columnCount()):
87 | action = QAction(self)
88 | action.setText(self.text(i))
89 | action.setCheckable(True)
90 | action.setChecked(not self.isSectionHidden(i))
91 | self.addAction(action)
92 | action.toggled.connect(partial(self.set_section_visible, i))
93 |
94 | def set_all_sections_visible(self):
95 | for i in range(self.count()):
96 | self.actions()[i + 1].setChecked(True)
97 |
98 | def disable_actions(self):
99 | # all visible
100 | if not self.hiddenSectionCount():
101 | self.actions()[0].setEnabled(False)
102 |
103 | # 1 visible
104 | elif self.hiddenSectionCount() == self.count() - 1:
105 | # Disable action for last visible section,
106 | # so it's impossible to hide all sections
107 | section = 0
108 | while self.isSectionHidden(section):
109 | section += 1
110 |
111 | self.actions()[section + 1].setEnabled(False)
112 |
113 | else:
114 | for action in self.actions():
115 | action.setEnabled(True)
116 |
117 | def set_action_checked(self, section: int, hidden: bool):
118 | action = self.actions()[section + 1]
119 | action.setChecked(not hidden)
120 |
121 |
122 | class JobModel(QAbstractTableModel):
123 | layout_changed = pyqtSignal()
124 |
125 | def __init__(self, parentconfig):
126 | super().__init__()
127 | self.jobs = []
128 | self.config = parentconfig
129 | self.headers = gui_text.job_list_headers
130 | self.icons = {t: QIcon(f':/{t.favicon}') for t in TR}
131 | self.rowsInserted.connect(self.layout_changed.emit)
132 | self.rowsRemoved.connect(self.layout_changed.emit)
133 |
134 | def data(self, index: QModelIndex, role: int = 0):
135 | column = index.column()
136 | job = self.jobs[index.row()]
137 | no_icon = self.config.value('looks/chb_no_icon')
138 | torrent_folder = self.config.value('looks/chb_show_tor_folder')
139 |
140 | if role == Qt.ItemDataRole.DisplayRole:
141 | if column == 0:
142 | if job.dtor_dict and torrent_folder:
143 | show_name = job.dtor_dict['info']['name']
144 | else:
145 | show_name = job.display_name or job.tor_id
146 | if no_icon:
147 | show_name = f'{job.src_tr.name} - {show_name}'
148 | return show_name
149 | if column == 1:
150 | return job.dest_group
151 |
152 | if role == Qt.ItemDataRole.EditRole:
153 | return job.dest_group and str(job.dest_group)
154 |
155 | if role == Qt.ItemDataRole.CheckStateRole and column == 2:
156 | return Qt.CheckState(job.new_dtor * 2)
157 |
158 | if role == Qt.ItemDataRole.DecorationRole and column == 0 and not no_icon:
159 | return self.icons[job.src_tr]
160 |
161 | def rowCount(self, parent: QModelIndex = None) -> int:
162 | return len(self.jobs)
163 |
164 | def columnCount(self, parent: QModelIndex = None) -> int:
165 | return len(self.headers)
166 |
167 | def flags(self, index: QModelIndex) -> Qt.ItemFlag:
168 | if index.column() == 1:
169 | return super().flags(index) | Qt.ItemFlag.ItemIsEditable
170 | if index.column() == 2:
171 | return super().flags(index) | Qt.ItemFlag.ItemIsUserCheckable
172 | else:
173 | return super().flags(index)
174 |
175 | def headerData(self, section: int, orientation: Qt.Orientation, role: int = 0):
176 | if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
177 | return self.headers[section]
178 |
179 | if (role == Qt.ItemDataRole.ToolTipRole
180 | and self.config.value('main/chb_show_tips')
181 | and orientation == Qt.Orientation.Horizontal
182 | and section in (1, 2)):
183 | return getattr(gui_text, f'ttm_header{section}')
184 | else:
185 | return super().headerData(section, orientation, role)
186 |
187 | def setData(self, index: QModelIndex, value, role: int = 0) -> bool:
188 | job = self.jobs[index.row()]
189 | column = index.column()
190 |
191 | if column == 1:
192 | if value:
193 | try:
194 | value = int(value)
195 | except ValueError:
196 | return False
197 | job.dest_group = value or None
198 |
199 | if column == 2 and role == Qt.ItemDataRole.CheckStateRole:
200 | value: int
201 | job.new_dtor = value == 2
202 |
203 | return True
204 |
205 | def nt_check_uncheck_all(self):
206 | if not self.jobs:
207 | return
208 | column = 2
209 | was_unchecked = []
210 | for i, job in enumerate(self.jobs):
211 | if job.new_dtor is False:
212 | was_unchecked.append(i)
213 | job.new_dtor = True
214 | if was_unchecked:
215 | for first, last in self.continuous_slices(was_unchecked):
216 | self.dataChanged.emit(self.index(first, column), self.index(last, column), [])
217 | else: # all are checked
218 | for job in self.jobs:
219 | job.new_dtor = False
220 | self.dataChanged.emit(self.index(0, column), self.index(len(self.jobs) - 1, column), [])
221 |
222 | def append_jobs(self, new_jobs: list):
223 | if not new_jobs:
224 | return
225 | first = len(self.jobs)
226 | last = first + len(new_jobs) - 1
227 | self.beginInsertRows(QModelIndex(), first, last)
228 | self.jobs.extend(new_jobs)
229 | self.endInsertRows()
230 |
231 | @staticmethod
232 | def continuous_slices(numbers: Iterable[int], reverse=False) -> Iterator[list[int]]:
233 | numbers = sorted(numbers, reverse=reverse)
234 | if not numbers:
235 | return
236 | start = numbers[0]
237 | for a, b in pairwise(numbers):
238 | if abs(a - b) > 1:
239 | yield sorted((start, a))
240 | start = b
241 | yield sorted((start, numbers[-1]))
242 |
243 | def clear(self):
244 | self.remove_jobs(0, self.rowCount() - 1)
245 |
246 | def remove_jobs(self, first, last):
247 | self.beginRemoveRows(QModelIndex(), first, last)
248 | del self.jobs[first: last + 1]
249 | self.endRemoveRows()
250 |
251 | def remove_this_job(self, job):
252 | i = self.jobs.index(job)
253 | self.remove_jobs(i, i)
254 |
255 | def del_multi(self, indices):
256 | for first, last in self.continuous_slices(indices, reverse=True):
257 | self.remove_jobs(first, last)
258 |
259 | def filter_for_attr(self, attr, value):
260 | indices = [i for i, j in enumerate(self.jobs) if getattr(j, attr) == value]
261 | self.del_multi(indices)
262 |
263 | def __bool__(self):
264 | return bool(self.jobs)
265 |
266 | def __iter__(self):
267 | yield from self.jobs
268 |
269 |
270 | class RehostModel(QAbstractTableModel):
271 | def __init__(self):
272 | super().__init__()
273 | self.column_names = gui_text.rehost_columns
274 |
275 | def rowCount(self, parent: QModelIndex = None) -> int:
276 | return len(IH)
277 |
278 | def columnCount(self, parent: QModelIndex = None) -> int:
279 | return len(self.column_names)
280 |
281 | def data(self, index: QModelIndex, role: int = 0):
282 | column = index.column()
283 | host = IH(index.row())
284 |
285 | if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
286 | if column == 0:
287 | return f' {host.name} '
288 | if column == 1:
289 | return host.key
290 |
291 | if role == Qt.ItemDataRole.CheckStateRole and column == 0:
292 | return Qt.CheckState(host.enabled * 2)
293 |
294 | def flags(self, index: QModelIndex) -> Qt.ItemFlag:
295 | if index.column() == 0:
296 | return super().flags(index) | Qt.ItemFlag.ItemIsUserCheckable
297 | if index.column() == 1:
298 | return super().flags(index) | Qt.ItemFlag.ItemIsEditable
299 | else:
300 | return super().flags(index)
301 |
302 | def headerData(self, section: int, orientation: Qt.Orientation, role: int = 0):
303 | if role == Qt.ItemDataRole.DisplayRole:
304 | if orientation is Qt.Orientation.Horizontal:
305 | return self.column_names[section]
306 | elif orientation is Qt.Orientation.Vertical:
307 | return IH(section).prio + 1
308 | else:
309 | return super().headerData(section, orientation, role)
310 |
311 | def setData(self, index: QModelIndex, value, role: int = 0) -> bool:
312 | host = IH(index.row())
313 | column = index.column()
314 |
315 | if column == 1:
316 | if value == host.key:
317 | return False
318 | host.key = value
319 | if column == 0 and role == Qt.ItemDataRole.CheckStateRole:
320 | value: int
321 | host.enabled = Qt.CheckState(value) is Qt.CheckState.Checked
322 |
323 | self.dataChanged.emit(index, index, [role])
324 | return True
325 |
326 |
327 | class RehostTable(QTableView):
328 | rh_data_changed = pyqtSignal(dict)
329 |
330 | def __init__(self):
331 | super().__init__()
332 | self.setModel(RehostModel())
333 | self.setSelectionMode(QTableView.SelectionMode.NoSelection)
334 | self.verticalHeader().setSectionsMovable(True)
335 | self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
336 | self.verticalHeader().setDefaultSectionSize(30)
337 | self.verticalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignCenter)
338 | self.verticalHeader().setFixedWidth(22)
339 | self.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
340 | self.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
341 |
342 | self.verticalHeader().sectionMoved.connect(self.update_priorities)
343 | self.verticalHeader().sectionMoved.connect(lambda: self.rh_data_changed.emit(IH.get_attrs()))
344 | self.model().dataChanged.connect(lambda: self.rh_data_changed.emit(IH.get_attrs()))
345 |
346 | def move_to_priority(self):
347 | with QSignalBlocker(self.verticalHeader()):
348 | for h in IH.prioritised():
349 | v_index = self.verticalHeader().visualIndex(h.value)
350 | if h.prio != v_index:
351 | self.verticalHeader().moveSection(v_index, h.prio)
352 |
353 | def update_priorities(self):
354 | for host in IH:
355 | host.prio = self.verticalHeader().visualIndex(host.value)
356 |
357 | def set_rh_data(self, rh_data: dict):
358 | if not rh_data:
359 | return
360 | IH.set_attrs(rh_data)
361 | self.move_to_priority()
362 |
363 | def resizeEvent(self, event):
364 | super().resizeEvent(event)
365 | height = self.horizontalHeader().height() + self.verticalHeader().length() + self.frameWidth() * 2
366 |
367 | self.setMaximumHeight(height)
368 |
--------------------------------------------------------------------------------
/GUI/profiles.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from enum import IntFlag, auto
3 |
4 | from PyQt6.QtCore import pyqtSignal
5 | from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QCheckBox, QButtonGroup, QDialog,
6 | QPushButton, QComboBox, QToolButton, QMessageBox)
7 |
8 | from GUI import gui_text
9 |
10 |
11 | class STab(IntFlag):
12 | main = auto()
13 | rehost = auto()
14 | descriptions = auto()
15 | looks = auto()
16 |
17 |
18 | class NewProfile(QDialog):
19 | new_profile = pyqtSignal(str, STab)
20 |
21 | def __init__(self, *args, **kwargs):
22 | super().__init__(*args, **kwargs)
23 | self.setWindowTitle(gui_text.newprof_window)
24 |
25 | self.selected = STab(0)
26 | self.bg = QButtonGroup()
27 | self.bg.setExclusive(False)
28 | self.le_name = QLineEdit()
29 | self.pb_ok = QPushButton(gui_text.pb_ok)
30 | self.pb_cancel = QPushButton(gui_text.pb_cancel)
31 |
32 | for s in STab:
33 | chb = QCheckBox(s.name, self)
34 | self.bg.addButton(chb, id=s.value)
35 |
36 | self.bg.idToggled.connect(self.update_sel)
37 | self.bg.idToggled.connect(self.ok_enabled)
38 | self.pb_ok.clicked.connect(self.okay)
39 | self.pb_cancel.clicked.connect(self.reject)
40 | self.le_name.textChanged.connect(self.ok_enabled)
41 |
42 | self.do_layout()
43 |
44 | def update_sel(self, b_id: int, _: bool):
45 | self.selected ^= b_id
46 |
47 | def ok_enabled(self):
48 | self.pb_ok.setEnabled(bool(self.selected and self.le_name.text()))
49 |
50 | def open(self):
51 | for b in self.bg.buttons():
52 | b.setChecked(True)
53 | self.le_name.clear()
54 | self.le_name.setFocus()
55 | super().open()
56 |
57 | def okay(self):
58 | self.new_profile.emit(self.le_name.text(), self.selected)
59 | self.accept()
60 |
61 | def do_layout(self):
62 | chb_lay = QVBoxLayout()
63 | chb_lay.setSpacing(0)
64 | chb_lay.setContentsMargins(0, 0, 0, 0)
65 | for b in self.bg.buttons():
66 | chb_lay.addWidget(b)
67 |
68 | bottom_buts = QHBoxLayout()
69 | bottom_buts.addStretch()
70 | bottom_buts.addWidget(self.pb_ok)
71 | bottom_buts.addWidget(self.pb_cancel)
72 |
73 | lay = QVBoxLayout(self)
74 | lay.setSpacing(lay.spacing() * 2)
75 | lay.addWidget(QLabel(gui_text.newprof_name_label))
76 | lay.addWidget(self.le_name)
77 | lay.addLayout(chb_lay)
78 | lay.addLayout(bottom_buts)
79 |
80 |
81 | class Profiles(QWidget):
82 | new_profile = pyqtSignal(str, STab)
83 | save_profile = pyqtSignal(str)
84 | load_profile = pyqtSignal(str)
85 |
86 | def __init__(self, *args):
87 | super().__init__(*args)
88 | self.setMaximumHeight(20)
89 | self.combo = QComboBox()
90 | self.combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
91 | self.combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
92 | self._new_prof_diag = None
93 |
94 | self.buttons = []
95 | for txt in gui_text.profile_buttons:
96 | btn = QToolButton()
97 | btn.setText(txt)
98 | btn.clicked.connect(getattr(self, txt.lower()))
99 | self.buttons.append(btn)
100 |
101 | self.new_prof_diag.new_profile.connect(self.new_profile.emit)
102 | self.combo.currentIndexChanged.connect(self.disable_buttons)
103 | self.refresh()
104 | self.disable_buttons(self.combo.currentIndex())
105 |
106 | self.do_layout()
107 |
108 | @property
109 | def new_prof_diag(self):
110 | if not self._new_prof_diag:
111 | self._new_prof_diag = NewProfile(self)
112 | return self._new_prof_diag
113 |
114 | def load(self):
115 | self.load_profile.emit(f'{self.combo.currentText()}.tpp')
116 |
117 | def save(self):
118 | cur_prof = self.combo.currentText()
119 | if not cur_prof:
120 | return
121 |
122 | if self.confirm(cur_prof, gui_text.prof_action_save):
123 | self.save_profile.emit(f'{cur_prof}.tpp')
124 |
125 | def new(self):
126 | self.new_prof_diag.open()
127 |
128 | def confirm(self, profile: str, action: str):
129 | buts = QMessageBox.StandardButton
130 | conf_diag = QMessageBox(self)
131 | conf_diag.setStandardButtons(buts.Ok | buts.Cancel)
132 | conf_diag.setIcon(QMessageBox.Icon.Warning)
133 | conf_diag.setText(gui_text.prof_conf.format(profile=profile, action=action))
134 | return conf_diag.exec() == buts.Ok
135 |
136 | def refresh(self):
137 | if self.combo.count():
138 | self.combo.clear()
139 | self.combo.addItems(map(lambda p: p.stem, Path.cwd().glob('*.tpp')))
140 |
141 | def delete(self):
142 | cur_prof = self.combo.currentText()
143 | if self.confirm(cur_prof, gui_text.prof_action_del):
144 | file = Path(f'{cur_prof}.tpp')
145 | if file.is_file():
146 | file.unlink()
147 | self.combo.removeItem(self.combo.findText(cur_prof))
148 |
149 | def disable_buttons(self, combo_idx: int):
150 | for i in (0, 1, 3):
151 | self.buttons[i].setDisabled(combo_idx == -1)
152 |
153 | def do_layout(self):
154 | lay = QHBoxLayout(self)
155 | lay.setContentsMargins(5, 0, 0, 0)
156 | lay.setSpacing(3)
157 | lay.addWidget(QLabel(gui_text.profiles))
158 | lay.addWidget(self.combo)
159 | for b in self.buttons:
160 | lay.addWidget(b)
161 |
--------------------------------------------------------------------------------
/GUI/resources.py:
--------------------------------------------------------------------------------
1 | # Resource object code (Python 3)
2 | # Created by: object code
3 | # Created by: The Resource Compiler for Qt version 6.5.1
4 | # WARNING! All changes made in this file will be lost!
5 |
6 | from PyQt6 import QtCore
7 |
8 | qt_resource_data = b"\
9 | \x00\x00\x04\xa2\
10 | <\
11 | svg xmlns=\x22http:\
12 | //www.w3.org/200\
13 | 0/svg\x22 viewBox=\x22\
14 | 0 0 512.002 512.\
15 | 002\x22 xmlns:v=\x22ht\
16 | tps://vecta.io/n\
17 | ano\x22>\
86 | \x00\x00\x06\xec\
87 | <\
88 | svg xmlns=\x22http:\
89 | //www.w3.org/200\
90 | 0/svg\x22 viewBox=\x22\
91 | 0 0 512 512\x22 xml\
92 | ns:v=\x22https://ve\
93 | cta.io/nano\x22>\
199 | \x00\x00\x03\x99\
200 | \x00\
201 | \x00\x10\xbex\xda\xed\x97]HSa\x1c\xc6\xdf\xf95\
202 | \xc2\xa2/5-\xfcHQLW\x17\x19\xe8\x85\x8d\x08\
203 | %\xa4\x88.\x0cL\x06\x16D\x81\x14F\x82\xd4M\xae\
204 | \xcf\x11~\xde\x09\xa6\x11v\xa1\x17\xb52\xd3\x98\xa9M\
205 | \xc4L\x9d\xa6hen\xd6\x12\x0d?\xfaP0Ze\
206 | =\xfd\xdf\xe39v\x90\xe96\x9bw\xbe\xf2\x9b\xec9\
207 | \xff\xf7y\xcey\xf7\xdf\xbbs\x18S\xd0_X\x18\xe3\
208 | \xaf\xacj\x03c\x01\x8c\xb1h\x82$\xb6\x8f\xcd\xe9|\
209 | h\xe9Xd\xc0\x1c\x8b\x8cb\xc2D\xfc\x22 \xa3\x85\
210 | P1\xe7\x87J\x9c#\xf7\xf8%z\x17\xdb\xa9W\x12\
211 | ]\x0b\xeaa\xe7\x1c\x9c\x1d-\x0e\xbc\xba\x89\xb5\xb2\xfa\
212 | \xdb\x5c\x8f\x0d\x0fG\xa5\xf6\x12^=~\x84\x91\xceN\
213 | |\xb5X`\x9b\x9c\x94\xcfS\x13yD+1F\xfc\
214 | \x10\x19\x13\xb5<\xb1F\xa8\xc7\xcc\x8c0\x9f\xfbX\x9f\
215 | \xb7B_P\x80]\x91\x11\x92\xd7]Y\xbe\x99k\xf7\
216 | .\xe7\xe2eU%\x06\x0cO\x16\xcbw\x09y\xfeH\
217 | g\xbb\xe0[WX(\x1d\xb7\xca\xf2\x05\xedE\xf9\xad\
218 | \x15\xcf\xe7\xfe\xb2\x9a\xd5\xfc\xd5\xfc\xd5\xfc\xd5\xfc\xef\xae\
219 | \xed\x7f>\x84\x8e\x18\x12\xd1\x89\x9aK\xfb\x9fm\xe1\xf5\
220 | \xc7n\xdf\x8e\xaa+\xb9x]W\xeb \xff\x06\xf1y\
221 | \x01\xb9K\xe6\x0f\xbfh\xc3\x83\xa2|\xa8\x22\x22\xec]\
222 | \xff\xackkk\xb1\x93oY\xceg$\x8dO\xfc\xfd\
223 | \xd1\xf8xD\xd3\x1a\xacT>\xf7NKL\x90\xde\x7f\
224 | \x93\xe5\xf7rM\x7fS\x87\xbe\x07\xf7\xb1;:\xca\x81\
225 | \x97\xbd\xf5\xbf\xb4\xe4\x9c\x18\xca\xe6\xde\xb5EE\x926\
226 | (\xcb\xaf\xe4Z\xe1\xd9L\xbc\xa9\xad\x81f\xbf\xdaA\
227 | \xbe\xd4\x7f\x96%\xfbO\xce\xe1={\x04\xef\xd2\x8b9\
228 | \x92V'\xcb\xcf\xe2\xda\xf1\xe4d\x0c>5\xa0(+\
229 | k\xd9\xdf\xb9\xc5\xd0\x9e8!x\x9fOM\x95\xb4k\
230 | \xb2\xfc8\xae\xa9\x22#\xf1\xae\xd9\x88\x9e\x9ajxy\
231 | y\xb95\xdfXqG\xf0V\xabT\x92v@\x96\xef\
232 | \xc95\x0f\x0f\x0ft\xe8\xf5\x18nk\xc3\xa1\x84\x04\xb7\
233 | e\xc7\xc5\xc4\x08\x9e\xfd\x06\x03||\xe6?\xa75\x0b\
234 | \xee\x19\x85\x1e\xb8\x90\x91\x81\xd1\xee.\xd4\x95\x95A\xa1\
235 | P\xb8%\xbf\xfc\xeaU\xc13/\xfb\x9c\xa4\xd5\xd8\xb9\
236 | g\xe5\xeb\x81-\xfe\xfe\xf8`2a\xbc\xbf\x1fiI\
237 | I\xff\x9d\x1d\xbfs'\xc6\xfa\xfa0\xda\xdb\x8b\xf0\x90\
238 | \x10I\xd7,r\xdfl\xe0\xc7/\x9e:\x85\xcf\x83\x83\
239 | xO\xe7\x11K=\xb1\xdc\xec\xcd\x9b6\xa1\xa7\xa1A\
240 | \xf0*\xc8\x99\xef\xfbgK\xdc\xb7'\xf3\x1a\x1foo\
241 | 4S\x1fL[\xad\x18hm\xc5\x8e\xa8(\x97\xb3\x03\
242 | \xfc\xfc\xe6=\xba\xea\xeb\xe1\xeb\xeb+\x1d;\xe6\xe0\xd9\
243 | \xa1\x8c\xd7m\x0b\x0a\xc2\x1b\xea\x99\x99\xd1Q\x8c\x0f\x0c\
244 | \xe0\x8cF\xe3\xf4w\xe2\x88Z\x0d3\xad\x1d\x9f\xcb\xff\
245 | \x87\xff\xdbS+\x9c|~y\xc4\xeb\x83\x02\x03\xd1\x5c\
246 | ]\x8d\xef\x13\x13\x02\xdc\xebzv6\x0e&&b+\
247 | \x9d\x1f?\x1f%\xf5s\x00\xf5\xcc\xde\xb88\xe4\x9c>\
248 | \x8d\xae\xa6\xa6\xf9\xfa\x0e\xba\xee\xb0\xe0`)\xbb\x9e\xf0\
249 | v2\x7f\x1d\xf1\x90\xcf\xe3\x19\x99\xe9\xe9\xf8\xf8\xf6-\
250 | ~~\xf9\xe2\x14cf3\xb2O\x9e\x84R\xa9\x94\xb2\
251 | \x9f\x10\x1b\x99\xeb\xa3TZS\xee\x95\x9a\x92\x82\xb2\xfc\
252 | |\x98\x8cFL\x0c\x0d\xe1\xf7\xf44f\xa7\xa60N\
253 | \xbf\xaf\xed\x8d\x8d(\xd1\xe9p\x84\xf6PY.\xa7\x9c\
254 | \xfd\xdf\xd8E\xe8\x97\xd1\xff|\xfd\xe2\x99\xfbF \x91\
255 | !\xf6g\xb7\xf4\x9b-2It\x12%D:\xe1\xe7\
256 | \xac\xa95\xd4\xa6\xd4\x86\xda\x14Z\xc6\xdc\x8a\x91\xf6t\
257 | \x0e=d*9S\x8c\xad\xb7\xcd\x11\xfag\xeef\x09\
258 | 0z\xe2/\xfe\x22\xb0W\
259 | \x00\x00\x04~\
260 | \x00\
261 | \x00\x01\x00\x01\x00\x10\x10\x00\x00\x01\x00 \x00h\x04\x00\
262 | \x00\x16\x00\x00\x00(\x00\x00\x00\x10\x00\x00\x00 \x00\x00\
263 | \x00\x01\x00 \x00\x00\x00\x00\x00@\x04\x00\x00\x13\x0b\x00\
264 | \x00\x13\x0b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
265 | \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
266 | \x00\xd5\xd5\xd5\x01\xbe\xbe\xbe\x03\x5c\x5c\x5c\x04\x06\x06\x06\
267 | \x06\x00\x00\x00\x05\x08\x08\x08\x01\x00\x00\x00\x00\x00\x00\x00\
268 | \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
269 | \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa7\xa7\xa7\x10\xaa\xaa\xaa\
270 | <\xad\xad\xada\xae\xae\xaeo\xa2\xa2\xa2tzzz\
271 | \x80GGG\x90***\x88'''U(((\
272 | \x16\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
273 | \x00\x8b\x8b\x8b\x01\x95\x95\x955\x9a\x9a\x9a\x89\x9d\x9d\x9d\
274 | \xa9\x9e\x9e\x9e\xac\x9f\x9f\x9f\xac\x9e\x9e\x9e\xad\x9c\x9c\x9c\
275 | \xae\x8e\x8e\x8e\xb3]]]\xca///\xdf(((\
276 | \xb8)))E)))\x02\x00\x00\x00\x00\x00\x00\x00\
277 | \x00\x84\x84\x84=\x88\x88\x88\xa7\x8a\x8a\x8a\xb6\x88\x88\x88\
278 | \xb5{{{\xbaooo\xc1xxx\xb2\x89\x89\x89\
279 | \xac\x89\x89\x89\xb6\x8a\x8a\x8a\xb4aaa\xc6///\
280 | \xe5***\xd3+++L\x00\x00\x00\x00sss\
281 | \x22www\xa1xxx\xbfrrr\xc0NNN\
282 | \xd0666\xdf000\xa8===9~~~\
283 | 1zzz\x8cxxx\xbewww\xbdLLL\
284 | \xd2---\xe6---\xc1...(fff\
285 | shhh\xc6fff\xc6GGG\xd5111\
286 | \xe3000\xb2...\x1d\x00\x00\x00\x00\x00\x00\x00\
287 | \x00nnn\x19iii\x9bhhh\xc7^^^\
288 | \xc9777\xde111\xe2222\x83ZZZ\
289 | \xb2[[[\xcdRRR\xd0999\xdd777\
290 | \xdb777T\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
291 | \x00\x00\x00\x00\x00\x5c\x5c\x5cMZZZ\xcaYYY\
292 | \xcdBBB\xd8777\xdf888\xc1NNN\
293 | \xcfNNN\xd2HHH\xd5???\xdb???\
294 | \xca???(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
295 | \x00\x00\x00\x00\x00OOO&NNN\xc3NNN\
296 | \xd3FFF\xd6???\xda@@@\xd6DDD\
297 | \xd4DDD\xd7GGG\xd6JJJ\xd5JJJ\
298 | \xc5JJJ'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
299 | \x00\x00\x00\x00\x00DDD(DDD\xc7DDD\
300 | \xd8HHH\xd6JJJ\xd4JJJ\xd1<<<\
301 | \xbf<<<\xddDDD\xd7VVV\xcfVVV\
302 | \xccXXXN\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
303 | \x00\x00\x00\x00\x00;;;S<<<\xd9>>>\
304 | \xdbPPP\xd1VVV\xcfVVV\xb4666\
305 | \x82555\xe0:::\xdc]]]\xcaeee\
306 | \xc8fff\x9ckkk\x19\x00\x00\x00\x00\x00\x00\x00\
307 | \x00222\x1d444\xb0555\xe1III\
308 | \xd4ddd\xc7eee\xc7ccct222\
309 | (111\xc0000\xe4NNN\xd1vvv\
310 | \xbevvv\xbfxxx\x8d}}}1@@@\
311 | 9333\xa7999\xddQQQ\xd0qqq\
312 | \xc0vvv\xc0uuu\xa2ppp\x22\x00\x00\x00\
313 | \x00---K,,,\xd2111\xe4ccc\
314 | \xc6\x8a\x8a\x8a\xb4\x8a\x8a\x8a\xb6\x89\x89\x89\xaczzz\
315 | \xb2qqq\xc0|||\xba\x88\x88\x88\xb5\x8a\x8a\x8a\
316 | \xb6\x88\x88\x88\xa7\x84\x84\x84=\x00\x00\x00\x00\x00\x00\x00\
317 | \x00,,,\x02+++E***\xb7111\
318 | \xdf___\xc9\x90\x90\x90\xb2\x9f\x9f\x9f\xac\xa0\xa0\xa0\
319 | \xab\xa1\xa1\xa1\xab\xa0\xa0\xa0\xab\x9f\x9f\x9f\xa8\x9c\x9c\x9c\
320 | \x88\x97\x97\x974\x8b\x8b\x8b\x01\x00\x00\x00\x00\x00\x00\x00\
321 | \x00\x00\x00\x00\x00\x00\x00\x00\x00)))\x16(((\
322 | U+++\x88III\x90}}}~\xa7\xa7\xa7\
323 | r\xb3\xb3\xb3n\xb1\xb1\xb1_\xae\xae\xae;\xaa\xaa\xaa\
324 | \x10\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
325 | \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
326 | \x00\x06\x06\x06\x01\x00\x00\x00\x05\x07\x07\x07\x06fff\
327 | \x04\xca\xca\xca\x03\xde\xde\xde\x01\x00\x00\x00\x00\x00\x00\x00\
328 | \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xffR\
329 | _\xff\x1fEN\xe0\x07FI\xc0\x03=A\x81\x816\
330 | 4\x83\xc0am\x07\xe0y \x07\xe0 M\x07\xe0e\
331 | l\x07\xe001\x03\xc1te\x81\x81in\xc0\x031\
332 | ,\xe0\x07ut\xf9\xffnt\xff\xffAM\
333 | \x00\x00\x04\xa2\
334 | <\
335 | svg xmlns=\x22http:\
336 | //www.w3.org/200\
337 | 0/svg\x22 viewBox=\x22\
338 | 0 0 512.002 512.\
339 | 002\x22 xmlns:v=\x22ht\
340 | tps://vecta.io/n\
341 | ano\x22>\
410 | \x00\x00\x03\xc0\
411 | <\
412 | svg xmlns=\x22http:\
413 | //www.w3.org/200\
414 | 0/svg\x22 width=\x2253\
415 | 6.461\x22 height=\x225\
416 | 36.46\x22 fill=\x22#33\
417 | 3\x22 xmlns:v=\x22http\
418 | s://vecta.io/nan\
419 | o\x22>\
472 | \x00\x00\x04\x9f\
473 | <\
474 | svg xmlns=\x22http:\
475 | //www.w3.org/200\
476 | 0/svg\x22 viewBox=\x22\
477 | 0 0 512.002 512.\
478 | 002\x22 xmlns:v=\x22ht\
479 | tps://vecta.io/n\
480 | ano\x22>\
548 | \x00\x00\x03\xc3\
549 | <\
550 | svg xmlns=\x22http:\
551 | //www.w3.org/200\
552 | 0/svg\x22 width=\x2253\
553 | 6.461\x22 height=\x225\
554 | 36.46\x22 fill=\x22#4d\
555 | 4d4d\x22 xmlns:v=\x22h\
556 | ttps://vecta.io/\
557 | nano\x22>\
611 | "
612 |
613 | qt_resource_name = b"\
614 | \x00\x08\
615 | \x0b\x85Wg\
616 | \x00g\
617 | \x00e\x00a\x00r\x00.\x00s\x00v\x00g\
618 | \x00\x04\
619 | \x00\x06\xa8\x8b\
620 | \x00d\
621 | \x00a\x00r\x00k\
622 | \x00\x0a\
623 | \x0a\x94\x06\xc7\
624 | \x00s\
625 | \x00w\x00i\x00t\x00c\x00h\x00.\x00s\x00v\x00g\
626 | \x00\x07\
627 | \x07\xabO\x7f\
628 | \x00p\
629 | \x00t\x00h\x00.\x00i\x00c\x00o\
630 | \x00\x07\
631 | \x06vO\x7f\
632 | \x00o\
633 | \x00p\x00s\x00.\x00i\x00c\x00o\
634 | \x00\x05\
635 | \x00r\xfd\xf4\
636 | \x00l\
637 | \x00i\x00g\x00h\x00t\
638 | \x00\x0f\
639 | \x02\xe3/'\
640 | \x00o\
641 | \x00p\x00e\x00n\x00-\x00f\x00o\x00l\x00d\x00e\x00r\x00.\x00s\x00v\x00g\
642 | "
643 |
644 | qt_resource_struct = b"\
645 | \x00\x00\x00\x00\x00\x02\x00\x00\x00\x06\x00\x00\x00\x01\
646 | \x00\x00\x00\x00\x00\x00\x00\x00\
647 | \x00\x00\x00\x16\x00\x02\x00\x00\x00\x02\x00\x00\x00\x09\
648 | \x00\x00\x00\x00\x00\x00\x00\x00\
649 | \x00\x00\x00f\x00\x02\x00\x00\x00\x02\x00\x00\x00\x07\
650 | \x00\x00\x00\x00\x00\x00\x00\x00\
651 | \x00\x00\x00R\x00\x00\x00\x00\x00\x01\x00\x00\x0f3\
652 | \x00\x00\x01\x82d;1:\
653 | \x00\x00\x00>\x00\x01\x00\x00\x00\x01\x00\x00\x0b\x96\
654 | \x00\x00\x01\x82d;1;\
655 | \x00\x00\x00$\x00\x00\x00\x00\x00\x01\x00\x00\x04\xa6\
656 | \x00\x00\x01\x87\xb5V\xc4\xdd\
657 | \x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
658 | \x00\x00\x01\x87\xb5yR\xfc\
659 | \x00\x00\x00v\x00\x00\x00\x00\x00\x01\x00\x00\x18[\
660 | \x00\x00\x01\x87\xb5Z\xacQ\
661 | \x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x13\xb5\
662 | \x00\x00\x01\x87\xb5Zgk\
663 | \x00\x00\x00v\x00\x00\x00\x00\x00\x01\x00\x00 \xc2\
664 | \x00\x00\x01\x87\xb5X\xbe\xf7\
665 | \x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x1c\x1f\
666 | \x00\x00\x01\x87\xb5Xn,\
667 | "
668 |
669 | def qInitResources():
670 | QtCore.qRegisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data)
671 |
672 | def qCleanupResources():
673 | QtCore.qUnregisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data)
674 |
675 | qInitResources()
676 |
--------------------------------------------------------------------------------
/GUI/settings_window.py:
--------------------------------------------------------------------------------
1 | from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QFormLayout, QDialog
2 | from PyQt6.QtGui import QIcon
3 | from PyQt6.QtCore import Qt
4 |
5 | from GUI import gui_text
6 | from GUI.widget_bank import wb
7 |
8 |
9 | class SettingsWindow(QDialog):
10 | def __init__(self, parent):
11 | super().__init__(parent)
12 | self.setWindowTitle(gui_text.settings_window_title)
13 | self.setWindowIcon(QIcon(':/gear.svg'))
14 |
15 | bottom_row = QHBoxLayout()
16 | bottom_row.addStretch()
17 | bottom_row.addWidget(wb.pb_ok)
18 |
19 | # main
20 | data_dir = QVBoxLayout()
21 | data_dir.addWidget(wb.fsb_data_dir)
22 | deep_search = QHBoxLayout()
23 | deep_search.setSpacing(0)
24 | deep_search.addWidget(wb.chb_deep_search)
25 | deep_search.addWidget(wb.spb_deep_search_level)
26 | deep_search.addStretch()
27 | data_dir.addLayout(deep_search)
28 | data_dir.setSpacing(5)
29 |
30 | save_dtor = QHBoxLayout()
31 | save_dtor.addWidget(wb.chb_save_dtors)
32 | save_dtor.addWidget(wb.fsb_dtor_save_dir)
33 |
34 | settings_form = QFormLayout(wb.main_settings)
35 | settings_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
36 | settings_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
37 | settings_form.setVerticalSpacing(15)
38 | settings_form.setHorizontalSpacing(15)
39 | key_1 = QHBoxLayout()
40 | key_1.setSpacing(5)
41 | key_1.addWidget(wb.le_key_1)
42 | key_1.addWidget(wb.tb_key_test1)
43 | key_2 = QHBoxLayout()
44 | key_2.setSpacing(5)
45 | key_2.addWidget(wb.le_key_2)
46 | key_2.addWidget(wb.tb_key_test2)
47 | settings_form.addRow(wb.l_key_1, key_1)
48 | settings_form.addRow(wb.l_key_2, key_2)
49 | settings_form.addRow(wb.l_data_dir, data_dir)
50 | settings_form.addRow(wb.l_scan_dir, wb.fsb_scan_dir)
51 | settings_form.addRow(wb.l_save_dtors, save_dtor)
52 | settings_form.addRow(wb.l_del_dtors, wb.chb_del_dtors)
53 | settings_form.addRow(wb.l_file_check, wb.chb_file_check)
54 | settings_form.addRow(wb.l_post_compare, wb.chb_post_compare)
55 | settings_form.addRow(wb.l_show_tips, wb.chb_show_tips)
56 | settings_form.addRow(wb.l_verbosity, wb.spb_verbosity)
57 |
58 | # rehost
59 | toprow = QFormLayout()
60 | toprow.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
61 | toprow.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
62 | toprow.addRow(wb.l_rehost, wb.chb_rehost)
63 |
64 | white_l_row = QFormLayout()
65 | white_l_row.addRow(wb.l_whitelist, wb.le_whitelist)
66 |
67 | on_off = QVBoxLayout(wb.rh_on_off_container)
68 | on_off.setContentsMargins(0, 0, 0, 0)
69 | on_off.addLayout(white_l_row)
70 | on_off.addSpacing(15 - on_off.spacing())
71 | on_off.addWidget(wb.l_rehost_table)
72 | on_off.addWidget(wb.rht_rehost_table)
73 |
74 | rh_layout = QVBoxLayout(wb.rehost)
75 | rh_layout.setSpacing(15)
76 | rh_layout.addLayout(toprow)
77 | rh_layout.addWidget(wb.rh_on_off_container)
78 | rh_layout.addStretch()
79 |
80 | # descr
81 | top_left_descr = QVBoxLayout()
82 | top_left_descr.addStretch()
83 | top_left_descr.addWidget(wb.pb_def_descr)
84 |
85 | top_row_descr = QHBoxLayout()
86 | top_row_descr.addWidget(wb.l_variables)
87 | top_row_descr.addStretch()
88 | top_row_descr.addLayout(top_left_descr)
89 |
90 | desc_layout = QVBoxLayout(wb.cust_descr)
91 | desc_layout.addLayout(top_row_descr)
92 | desc_layout.addWidget(wb.te_rel_descr_templ)
93 | desc_layout.addWidget(wb.l_own_uploads)
94 | desc_layout.addWidget(wb.te_rel_descr_own_templ)
95 | desc_layout.addWidget(wb.chb_add_src_descr)
96 | desc_layout.addWidget(wb.te_src_descr_templ)
97 |
98 | # Looks
99 | main = QFormLayout()
100 | main.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
101 | main.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
102 | job_list = QFormLayout()
103 | job_list.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
104 |
105 | main.addRow(wb.l_style_selector, wb.sty_style_selector)
106 | main.addRow(wb.l_theme_selector, wb.thm_theme_selector)
107 | main.addRow(wb.l_toolbar_loc, wb.chb_toolbar_loc)
108 | main.addRow(wb.l_show_add_dtors, wb.chb_show_add_dtors)
109 | main.addRow(wb.l_show_rem_tr1, wb.chb_show_rem_tr1)
110 | main.addRow(wb.l_show_rem_tr2, wb.chb_show_rem_tr2)
111 |
112 | job_list.addRow(wb.l_no_icon, wb.chb_no_icon)
113 | job_list.addRow(wb.l_show_tor_folder, wb.chb_show_tor_folder)
114 | job_list.addRow(wb.l_alt_row_colour, wb.chb_alt_row_colour)
115 | job_list.addRow(wb.l_show_grid, wb.chb_show_grid)
116 | job_list.addRow(wb.l_row_height, wb.spb_row_height)
117 |
118 | color_form = QFormLayout()
119 | color_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
120 |
121 | color_form.addRow(wb.l_warning_color, wb.ple_warning_color)
122 | color_form.addRow(wb.l_error_color, wb.ple_error_color)
123 | color_form.addRow(wb.l_success_color, wb.ple_success_color)
124 | color_form.addRow(wb.l_link_color, wb.ple_link_color)
125 |
126 | colors = QHBoxLayout()
127 | colors.addLayout(color_form, stretch=0)
128 | colors.addWidget(wb.color_examples, stretch=2)
129 |
130 | looks = QVBoxLayout(wb.looks)
131 | looks.addLayout(main)
132 | looks.addSpacing(looks.spacing() * 3)
133 | looks.addWidget(wb.l_job_list)
134 | looks.addLayout(job_list)
135 | looks.addSpacing(looks.spacing() * 3)
136 | looks.addWidget(wb.l_colors)
137 | looks.addSpacing(looks.spacing())
138 | looks.addLayout(colors)
139 | looks.addStretch()
140 |
141 | # Total
142 | total_layout = QVBoxLayout(self)
143 | total_layout.setContentsMargins(5, 5, 10, 10)
144 | total_layout.addWidget(wb.config_tabs)
145 | total_layout.addSpacing(20)
146 | total_layout.addLayout(bottom_row)
147 |
--------------------------------------------------------------------------------
/GUI/widget_bank.py:
--------------------------------------------------------------------------------
1 | from functools import partial
2 |
3 | from PyQt6.QtWidgets import (QApplication, QWidget, QTextEdit, QPushButton, QToolButton, QRadioButton, QButtonGroup,
4 | QSplitter, QLabel, QTabWidget, QLineEdit, QSpinBox, QCheckBox, QStackedLayout,
5 | QTextBrowser, QSizePolicy, QToolBar)
6 | from PyQt6.QtCore import Qt
7 | from PyQt6.QtGui import QIcon
8 |
9 | from gazelle.tracker_data import TR
10 | from core.tp_text import tp_version
11 | from core.img_rehost import IH
12 | from GUI import gui_text
13 | from GUI.misc_classes import (TPTextEdit, CyclingTabBar, FolderSelectBox, IniSettings, TempPopUp, TTfilter,
14 | ColorExample, PatientLineEdit, ThemeIcon, StyleSelector, ThemeSelector, ClickableLabel,
15 | PButton)
16 | from GUI.mv_classes import JobModel, JobView, RehostTable
17 | from GUI.profiles import Profiles, STab
18 |
19 | TYPE_MAP = {
20 | 'le': QLineEdit,
21 | 'ple': PatientLineEdit,
22 | 'te': TPTextEdit,
23 | 'chb': QCheckBox,
24 | 'spb': QSpinBox,
25 | 'fsb': FolderSelectBox,
26 | 'sty': StyleSelector,
27 | 'thm': ThemeSelector,
28 | 'rht': RehostTable,
29 | }
30 | ACTION_MAP = {
31 | QLineEdit: (lambda x: x.textChanged, lambda x, y: x.setText(y)),
32 | PatientLineEdit: (lambda x: x.text_changed, lambda x, y: x.setText(y)),
33 | TPTextEdit: (lambda x: x.plain_text_changed, lambda x, y: x.setText(y)),
34 | QCheckBox: (lambda x: x.toggled, lambda x, y: x.setChecked(y)),
35 | QSpinBox: (lambda x: x.valueChanged, lambda x, y: x.setValue(y)),
36 | FolderSelectBox: (lambda x: x.list_changed, lambda x, y: x.set_list(y)),
37 | StyleSelector: (lambda x: x.currentTextChanged, lambda x, y: x.setCurrentText(y)),
38 | ThemeSelector: (lambda x: x.currentTextChanged, lambda x, y: x.setCurrentText(y)),
39 | RehostTable: (lambda x: x.rh_data_changed, lambda x, y: x.set_rh_data(y))
40 | }
41 | # name: (default value, make label)
42 | CONFIG_NAMES = {
43 | STab.main: {
44 | 'le_key_1': (None, True),
45 | 'le_key_2': (None, True),
46 | 'fsb_data_dir': ([], True),
47 | 'chb_deep_search': (False, False),
48 | 'spb_deep_search_level': (2, False),
49 | 'fsb_scan_dir': ([], True),
50 | 'fsb_dtor_save_dir': ([], False),
51 | 'chb_save_dtors': (False, True),
52 | 'chb_del_dtors': (False, True),
53 | 'chb_file_check': (True, True),
54 | 'chb_post_compare': (False, True),
55 | 'chb_show_tips': (True, True),
56 | 'spb_verbosity': (2, True),
57 | },
58 | STab.rehost: {
59 | 'chb_rehost': (False, True),
60 | 'le_whitelist': (gui_text.default_whitelist, True),
61 | 'rht_rehost_table': ({}, True),
62 | },
63 | STab.descriptions: {
64 | 'te_rel_descr_templ': (gui_text.def_rel_descr, False),
65 | 'te_rel_descr_own_templ': (gui_text.def_rel_descr_own, False),
66 | 'te_src_descr_templ': (gui_text.def_src_descr, False),
67 | 'chb_add_src_descr': (True, False),
68 | },
69 | STab.looks: {
70 | 'sty_style_selector': ('Fusion', True),
71 | 'thm_theme_selector': ('System', True),
72 | 'chb_toolbar_loc': (False, True),
73 | 'chb_show_add_dtors': (True, True),
74 | 'chb_show_rem_tr1': (False, True),
75 | 'chb_show_rem_tr2': (False, True),
76 | 'chb_no_icon': (False, True),
77 | 'chb_show_tor_folder': (False, True),
78 | 'chb_alt_row_colour': (True, True),
79 | 'chb_show_grid': (False, True),
80 | 'spb_row_height': (20, True),
81 | 'ple_warning_color': ('orange', True),
82 | 'ple_error_color': ('crimson', True),
83 | 'ple_success_color': ('forestgreen', True),
84 | 'ple_link_color': ('dodgerblue', True),
85 | },
86 | }
87 |
88 |
89 | class WidgetBank:
90 | def __init__(self):
91 | super().__init__()
92 | self.app = QApplication.instance()
93 | self.config = IniSettings("Transplant.ini")
94 | self.config_update()
95 | self.fsbs = []
96 | self.theme_writable = hasattr(self.app.styleHints(), 'setColorScheme')
97 | self.user_input_elements()
98 | self.main_window = None
99 | self.settings_window = None
100 | self.tt_filter = TTfilter()
101 | self.main_widgets()
102 | self.settings_window_widgets()
103 |
104 | self._pop_up = None
105 | self.thread = None
106 |
107 | @property
108 | def pop_up(self):
109 | if not self._pop_up:
110 | self._pop_up = TempPopUp(self.main_window)
111 |
112 | return self._pop_up
113 |
114 | def config_update(self):
115 | config_version = self.config.value('config_version')
116 | if config_version == tp_version:
117 | return
118 | if config_version is None:
119 | self.config.setValue('config_version', tp_version)
120 | return
121 | if isinstance(config_version, str):
122 | config_version = tuple(map(int, config_version.split('.')))
123 |
124 | changes = (
125 | ('te_rel_descr', 'te_rel_descr_templ', None),
126 | ('te_src_descr', 'te_src_descr_templ', None),
127 | ('le_scandir', 'le_scan_dir', None),
128 | ('geometry/header', 'geometry/job_view_header', None),
129 | ('le_data_dir', 'fsb_data_dir', lambda x: [x]),
130 | ('le_scan_dir', 'fsb_scan_dir', lambda x: [x]),
131 | ('le_dtor_save_dir', 'fsb_dtor_save_dir', lambda x: [x]),
132 | ('sty_style_selecter', 'sty_style_selector', None),
133 | ('rehost_data', 'rht_rehost_table', None),
134 | )
135 | for old, new, conversion in changes:
136 | if self.config.contains(old):
137 | value = self.config.value(old)
138 | if conversion:
139 | value = conversion(value)
140 | self.config.setValue(new, value)
141 | self.config.remove(old)
142 |
143 | for key in self.config.allKeys():
144 | if key.startswith('chb_'):
145 | value = self.config.value(key)
146 | if value in (0, 1, 2):
147 | value = bool(value)
148 | self.config.setValue(key, value)
149 | elif key.startswith('spb_'):
150 | value = self.config.value(key)
151 | if not type(value) == int:
152 | self.config.setValue(key, int(value))
153 | if key == 'spb_splitter_weight':
154 | self.config.remove(key)
155 | if key == 'bg_source' and self.config.value(key) == 0:
156 | self.config.setValue(key, 1)
157 | if key == 'le_ptpimg_key':
158 | value = self.config.value(key)
159 | if value:
160 | IH.PTPimg.key = value
161 | if self.config.value('chb_rehost') is True:
162 | IH.PTPimg.enabled = True
163 | self.config.remove(key)
164 | if key.startswith('te_rel_descr'):
165 | value = self.config.value(key).replace('[url=%src_url%torrents.php?id=%tor_id%]',
166 | '[url=%src_url%torrents.php?torrentid=%tor_id%]')
167 | self.config.setValue(key, value)
168 |
169 | if (g := {'main', 'rehost', 'looks'}) & set(self.config.childGroups()) != g:
170 | for tab, sd in CONFIG_NAMES.items():
171 | for el_name in sd:
172 | if not self.config.contains(el_name):
173 | print('not in config', el_name)
174 | continue
175 | value = self.config.value(el_name)
176 | self.config.setValue(f'{tab.name}/{el_name}', value)
177 | self.config.remove(el_name)
178 | if self.config.contains(el_name):
179 | print('not removed', el_name)
180 |
181 | if config_version < (2, 5, 2) <= tp_version:
182 | self.config.remove('geometry/job_view_header')
183 |
184 | self.config.setValue('config_version', tp_version)
185 |
186 | def main_widgets(self):
187 | self.topwidget = QWidget()
188 | self.bottomwidget = QWidget()
189 | self.splitter = QSplitter(Qt.Orientation.Vertical)
190 | self.splitter_handle = None
191 |
192 | self.toolbar = QToolBar()
193 | self.toolbar.setContextMenuPolicy(Qt.ContextMenuPolicy.PreventContextMenu)
194 | self.toolbar.setMovable(False)
195 | self.profiles = Profiles()
196 | self.tb_spacer = QWidget()
197 | self.tb_spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
198 |
199 | self.tb_open_config = QToolButton()
200 | self.tb_open_config.setIcon(ThemeIcon('gear.svg'))
201 |
202 | self.te_paste_box = TPTextEdit()
203 | self.te_paste_box.setAcceptDrops(False)
204 | self.te_paste_box.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
205 | self.te_paste_box.setPlaceholderText(gui_text.pb_placeholder)
206 |
207 | self.rb_tracker1 = QRadioButton(TR.RED.name)
208 | self.rb_tracker2 = QRadioButton(TR.OPS.name)
209 | self.bg_source = QButtonGroup()
210 | self.bg_source.addButton(self.rb_tracker1, 1)
211 | self.bg_source.addButton(self.rb_tracker2, 2)
212 |
213 | self.pb_add = QPushButton(gui_text.pb_add)
214 | self.pb_add.setEnabled(False)
215 |
216 | self.pb_open_dtors = QPushButton(gui_text.open_dtors)
217 |
218 | self.pb_scan = QPushButton(gui_text.pb_scan)
219 | self.pb_scan.setEnabled(False)
220 |
221 | self.job_data = JobModel(self.config)
222 | self.job_view = JobView(self.job_data)
223 | self.selection = self.job_view.selectionModel()
224 | self.result_view = QTextBrowser()
225 | self.result_view.setOpenExternalLinks(True)
226 |
227 | self.button_stack = QStackedLayout()
228 | self.go_stop_stack = QStackedLayout()
229 | self.view_stack = QStackedLayout()
230 |
231 | self.tabs = CyclingTabBar()
232 | self.tabs.setDrawBase(False)
233 | self.tabs.setExpanding(False)
234 | self.tabs.addTab(gui_text.tab_joblist)
235 |
236 | self.job_buttons = QWidget()
237 | self.result_buttons = QWidget()
238 | self.result_buttons.hide()
239 | self.pb_clear_j = PButton(gui_text.pb_clear)
240 | self.pb_clear_j.setEnabled(False)
241 | self.pb_clear_r = PButton(gui_text.pb_clear)
242 | self.pb_clear_r.setEnabled(False)
243 | self.pb_rem_sel = PButton(gui_text.pb_rem_sel)
244 | self.pb_rem_sel.setEnabled(False)
245 | self.pb_crop = PButton(gui_text.pb_crop)
246 | self.pb_crop.setEnabled(False)
247 | self.pb_del_sel = PButton(gui_text.pb_del_sel)
248 | self.pb_del_sel.setEnabled(False)
249 | self.pb_rem_tr1 = PButton(gui_text.pb_rem_tr1)
250 | self.pb_rem_tr1.setEnabled(False)
251 | self.pb_rem_tr2 = PButton(gui_text.pb_rem_tr2)
252 | self.pb_rem_tr2.setEnabled(False)
253 | self.pb_open_tsavedir = PButton(gui_text.pb_open_tsavedir)
254 | self.pb_open_tsavedir.setEnabled(False)
255 | self.pb_open_upl_urls = PButton(gui_text.pb_open_upl_urls)
256 | self.pb_open_upl_urls.setEnabled(False)
257 | self.pb_go = QPushButton()
258 | self.pb_go.setEnabled(False)
259 | self.pb_go.setIcon(QIcon(':/switch.svg'))
260 |
261 | self.pb_stop = QPushButton(gui_text.pb_stop)
262 | self.pb_stop.hide()
263 |
264 | def settings_window_widgets(self):
265 | self.config_tabs = QTabWidget()
266 | self.config_tabs.setDocumentMode(True)
267 | self.main_settings = QWidget()
268 | self.rehost = QWidget()
269 | self.cust_descr = QWidget()
270 | self.looks = QWidget()
271 | self.config_tabs.addTab(self.main_settings, gui_text.main_tab)
272 | self.config_tabs.addTab(self.rehost, gui_text.rehost_tab)
273 | self.config_tabs.addTab(self.cust_descr, gui_text.desc_tab)
274 | self.config_tabs.addTab(self.looks, gui_text.looks_tab)
275 |
276 | self.pb_ok = QPushButton(gui_text.pb_ok)
277 |
278 | # main tab
279 | self.tb_key_test1 = QToolButton()
280 | self.tb_key_test1.setText(gui_text.tb_test)
281 | self.tb_key_test1.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum)
282 | self.tb_key_test2 = QToolButton()
283 | self.tb_key_test2.setText(gui_text.tb_test)
284 | self.tb_key_test2.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum)
285 |
286 | # rehost tab
287 | self.rh_on_off_container = QWidget()
288 |
289 | # descr tab
290 | self.l_variables = QLabel(gui_text.l_placeholders)
291 | self.l_own_uploads = QLabel(gui_text.l_own_uploads)
292 | self.pb_def_descr = QPushButton()
293 | self.pb_def_descr.setText(gui_text.pb_def_descr)
294 | self.l_variables.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
295 |
296 | # looks tab
297 | self.l_job_list = QLabel(gui_text.l_job_list)
298 | self.l_colors = QLabel(gui_text.l_colors)
299 | self.l_colors.setOpenExternalLinks(True)
300 | self.color_examples = ColorExample(self.config)
301 | self.color_examples.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
302 | self.color_examples.setSizeAdjustPolicy(QTextEdit.SizeAdjustPolicy.AdjustToContents)
303 |
304 | def user_input_elements(self):
305 | for tab, sd in CONFIG_NAMES.items():
306 | self.config.beginGroup(tab.name)
307 | for el_name, (df, mk_lbl) in sd.items():
308 | typ_str, name = el_name.split('_', maxsplit=1)
309 |
310 | # instantiate
311 | obj_type = TYPE_MAP[typ_str]
312 | obj = obj_type()
313 | setattr(self, el_name, obj)
314 |
315 | # set values from config
316 | if not self.config.contains(el_name):
317 | self.config.setValue(el_name, df)
318 |
319 | change_sig, set_value_func = ACTION_MAP[obj_type]
320 | set_value_func(obj, self.config.value(el_name))
321 |
322 | # make Label
323 | if mk_lbl:
324 | label_name = 'l_' + name
325 | if obj_type == QCheckBox:
326 | lbl = ClickableLabel()
327 | lbl.clicked.connect(obj.click)
328 | else:
329 | lbl = QLabel()
330 | lbl.setText(getattr(gui_text, label_name))
331 | setattr(self, label_name, lbl)
332 |
333 | if obj_type == FolderSelectBox:
334 | obj.dialog_caption = gui_text.tooltips[el_name]
335 | self.fsbs.append(obj)
336 |
337 | self.config.endGroup()
338 |
339 | if not self.theme_writable:
340 | self.thm_theme_selector.setEnabled(False)
341 |
342 | self.le_key_1.setCursorPosition(0)
343 | self.le_key_2.setCursorPosition(0)
344 |
345 | self.chb_deep_search.setText(gui_text.chb_deep_search)
346 | self.spb_deep_search_level.setMinimum(2)
347 | self.spb_verbosity.setMaximum(3)
348 | self.spb_verbosity.setMaximumWidth(40)
349 |
350 | self.chb_add_src_descr.setText(gui_text.chb_add_src_descr)
351 |
352 | self.spb_row_height.setMinimum(12)
353 | self.spb_row_height.setMaximum(99)
354 | self.spb_row_height.setMaximumWidth(40)
355 |
356 | def emit_state(self):
357 | for tab, sd in CONFIG_NAMES.items():
358 | self.config.beginGroup(tab.name)
359 | for el_name in sd:
360 | obj = getattr(self, el_name)
361 | signal_func, _ = ACTION_MAP[type(obj)]
362 | value = self.config.value(el_name)
363 | signal_func(obj).emit(value)
364 | signal_func(obj).connect(partial(self.config.setValue, f'{tab.name}/{el_name}'))
365 |
366 | self.config.endGroup()
367 |
368 |
369 | wb = WidgetBank()
370 |
--------------------------------------------------------------------------------
/changelog:
--------------------------------------------------------------------------------
1 | 2.6.0
2 | - Minimum PyQt version = 6.7
3 | - RED domain updated
4 | - New: OPS image proxy is used for rehosting
5 | - New: Set Dark/Light theme independent from OS (requires PyQt 6.8+)
6 | - New: Profiles
7 | - Fix: cover image was not transplanted when rehosting was not selected
8 | 2.5.6
9 | - New: Can use RED's new log download
10 | - New: Buttons for testing API-keys
11 | - New: delete.this.tag is not transplanted + warning if new upload has it
12 | - New: Improvements to progress messages
13 | - New: Warning for artist count mismatch
14 | - Old: Settings window cancel button retired
15 | - Fix: Deep search crashed on folders without access permission (they are skipped now)
16 | 2.5.5
17 | - New: Deal with unicode directional markers in folder and file names
18 | - New: Warning if log count doesn't match site log count (OPS > RED only)
19 | - Fix: Regression, all files were added as log
20 | 2.5.4
21 | - New: Fail torrents without folder
22 | - New: Check for low bitrates to RED
23 | - New: Splits are uploaded as Unknown on RED + warning to edit
24 | - Fix: Regression, Samplers and Concert Recordings failed
25 | 2.5.3
26 | - Fix: ptpimg rehost returned wrong url
27 | - Fix: CLI batch command was evaluated as URL
28 | - Fix: 'Open urls' button works again
29 | - Useful error message when deep search doesn't find torrent folder
30 | - Improved error messaging for CLI
31 | - Downloaded .torrent is reused when upload fails
32 | 2.5.2
33 | - New: Ra and imgBB added to image hosts
34 | - New: Selectable GUI style
35 | - New: GUI icons adapt to light/dark mode
36 | - New: Option to show torrent folder name instead of .torrent file name in job list
37 | - New: Deep search optimisation + 'depth' levels
38 | - Fix: url for default descriptions (id= > torrentid=)
39 | - Requests module: minimum version 2.27 requirement
40 | - Qt 6.5+ requirement
41 | 2.5.1
42 | - New: Fusion style by default
43 | - New: coloured output (GUI and CLI)
44 | - New: OPS artist disambiguation numbers are stripped
45 | - Fix: Crash when checking nt checkbox (pyqt5 > pyqt6 leftover)
46 | - Fix: CLI will continue scanning when unknown .torrent is found
47 | - Icons are now packed in a resource file. No more subdir with icon files
48 | - Dottorrent is no longer a requirement
49 | - More usefull error message when server doesn't return json (status 500)
50 | - File not found during file check will not print full traceback anymore, just simple message
51 | 2.5
52 | - GUI Qt5 > Qt6
53 | - New: Input for api keys is now checked for leading and trailing spaces
54 | - New: Pop-up for empty scans
55 | - Fix: non-expanding input boxes on MAC
56 | 2.4.2
57 | - New: GUI crop button
58 | - New: OPS sampler > compilation transplant
59 | - Fix: Dest. group regression from 2.3
60 | - Fix: RED unconfirmed was uploaded as ori release without label catno info
61 | - Fix: Proper job removal when some uploads of batch fail
62 | - Always use local riplogs when files are checked
63 | - Get local riplog paths from api file list (except for 'nt')
64 | 2.4.1
65 | - Fix: last forgotten remnants of switch to bcoding removed
66 | 2.4
67 | - Switch bencode module: bencoder.pyx > bcoding
68 | - GUI files are now stored in .exe, config.ini not in subfolder anymore and renamed to Transplant.ini
69 | - Searching for torrent folder in subfolders of data folder (deep search) is now optional
70 | - Separate release description for own uploads
71 | - Use http tracker for bymblyboo's
72 | - Check for merged uploads and different log scores
73 | - Able to transplant Blu-Ray / BD
74 | 2.3.3
75 | - Data folder is searched in subfolders
76 | - More hotkeys
77 | 2.3.2
78 | - Scan button always visible
79 | - Expanding folder select boxes on MAC
80 | - Doubleclick opens torrent page
81 | - Hotkeys backspace, ctrl-S
82 | 2.3.1
83 | - Scan folder selection to settings window
84 | - Folder selection boxes now have history
85 | 2.3
86 | - Major refactoring of gazelle api and transplant.
87 | - Logging instead of callback
88 | - Handle OPS api change, tags are now dict
89 | 2.2.5
90 | - Deal with OPS bug - no wiki info in torrent info
91 | 2.2.4
92 | - New: Optional buttons to remove jobs per source tracker
93 | - Refactoring to facilitate testing of upload data
94 | 2.2.3
95 | - Fix, regression with OPS original release failure
96 | 2.2.2
97 | - Fix, 16 bit uploads uploaded as 'other'
98 | 2.2.1
99 | - New: Doubleclick new torrent header (de)selects all
100 | - New: Button for opening all new upload urls in browser
101 | - Fix, deal with RED unconfirmed releases
102 | - Fix, deal with 'other' bitrates
103 | 2.2
104 | - New: Job list Headers context menu
105 | - Direct config interactions
106 | - Major refactoring: autogenerate user input elements
107 | - New: GUI, 'Looks' settings
108 | - GUI, Use stylesheet (for splitter en headers)
109 | - Refactoring of upload data code
110 | - Fix, failure when transplanting CD's with log OPS > RED with .torrent input
111 | - New: Create new torrent
112 | - New: Upload to specified group
113 | - Job list switch from list to table model/view
114 | 2.1
115 | - New: Customisable release desription
116 | - New: Button to delete selected .torrent files from scan dir
117 | - New: Option to rehost images to ptpimg
118 | - Handle unknown releases
119 | 2.0
120 | - Cut off tags at 200 character limit.
121 | - Workaround for change in OPS api return
122 | 2.0 beta
123 | - A GUI appears
124 | 1.2
125 | - Major refactoring
126 | - batch mode added
127 | - Destination is now inferred from source. No longer needs to be supplied in command line
128 | - adjustable verbosity
129 | 1.1
130 | - Added filecheck
131 | - Now also takes full url as input besides torrentid
132 | - Fix, bug with multiple artists
133 | 1.0:
134 | - Fix, All uploads are marked 'scene' (at least on OPS)
135 | - Fix, did not html.unescape(base_path) for RED log fie loading
136 | - Removed Python 3.8 features, now 3.6 compatible. (bye bye walruses)
137 | 0.99
138 | - initial release
--------------------------------------------------------------------------------
/cli_config/cli_config.py.example:
--------------------------------------------------------------------------------
1 | # Copy this file and rename to cli_config.py
2 |
3 | # API keys:
4 | api_key_RED = "123456"
5 | api_key_OPS = "654321"
6 |
7 | # for Windows paths, double up the backslashes
8 | # Music files can be found here:
9 | data_dir = "D:\\Test"
10 | # Set to True if torrent folders can be deeper than a direct subfolder(=level 1) of data_dir
11 | # Level determines how deep the torrent folder can be found
12 | deep_search = False
13 | deep_search_level = 2
14 |
15 | # .torrent is saved here
16 | # Put 'None' (without quotes) if you don't want the .torrent to be saved to disc:
17 | torrent_save_dir = "D:\\Test\\Torrents"
18 |
19 | # will be scanned for .torrent files in batchmode
20 | scan_dir = "D:\\Test\\Torrents\\tBatch"
21 |
22 | # delete the .torrents from batch folder after successful upload. True/False
23 | del_dtors = False
24 |
25 | # check if torrent content can be found before uploading. True/False
26 | # Be very careful when setting this to False. It will allow you to transplant torrents you can't seed.
27 | file_check = True
28 |
29 | # Check if the upload was merged into an existing group or if the log scores are different.
30 | post_upload_checks = False
31 |
32 | # level of feedback.
33 | # 0: silent, 1: only errors, 2: normal, 3: debugging
34 | verbosity = 2
35 |
36 | # rehost non-whitelisted images
37 | img_rehost = False
38 | whitelist = ["ptpimg.me", "thesungod.xyz"]
39 | image_hosts = {
40 | # 'name': (enabled, 'api key', priority)
41 | # Enabled image hosts will be tried in 'priority' order
42 | 'Ra': (False, '123345', 1),
43 | 'PTPimg': (False, '12345', 2),
44 | 'ImgBB': (False, '12345', 3),
45 | }
46 |
47 | # Set a custom release description.
48 |
49 | # You can use these placeholders:"
50 | # %src_id% : Source id (OPS/RED)
51 | # "%src_url% : Source url (eg https://redacted.ch/)
52 | # "%ori_upl% : Name of uploader
53 | # "%upl_id% : id of uploader
54 | # "%tor_id% : Source torrent id
55 | # "%gr_id% : Source torrent group id
56 |
57 | rel_descr = "Transplanted from %src_id%, thanks to the original uploader."
58 | rel_descr_own_uploads = "Transplant of my own upload on %src_id%"
59 |
60 | # Add release description from source if present
61 | add_src_descr = True
62 |
63 | # Must contain %src_descr%
64 | src_descr = '[hide=source description:]%src_descr%[/hide]'
65 |
66 | # give output colours based on status: error > red, warning > yellow, success > green
67 | coloured_output = False
68 |
--------------------------------------------------------------------------------
/core/img_rehost.py:
--------------------------------------------------------------------------------
1 | from enum import Enum, member
2 | import requests
3 |
4 |
5 | def ra_rehost(img_link, key):
6 | url = "https://thesungod.xyz/api/image/rehost_new"
7 | data = {'api_key': key,
8 | 'link': img_link}
9 | r = requests.post(url, data=data)
10 | return r.json()['link']
11 |
12 |
13 | def ptpimg_rehost(img_link, key):
14 | url = "https://ptpimg.me/"
15 | data = {'api_key': key,
16 | 'link-upload': img_link}
17 | r = requests.post(url + 'upload.php', data=data)
18 | rj = r.json()[0]
19 | return f"{url}{rj['code']}.{rj['ext']}"
20 |
21 |
22 | def imgbb_rehost(img_link, key):
23 | url = 'https://api.imgbb.com/1/upload'
24 | data = {'key': key,
25 | 'image': img_link}
26 | r = requests.post(url, data=data)
27 | return r.json()['data']['url']
28 |
29 |
30 | class IH(Enum):
31 | Ra = member(ra_rehost)
32 | PTPimg = member(ptpimg_rehost)
33 | ImgBB = member(imgbb_rehost)
34 |
35 | def __new__(cls, func):
36 | obj = object.__new__(cls)
37 | obj._value_ = len(cls.__members__)
38 | return obj
39 |
40 | def __init__(self, func):
41 | self.key = ''
42 | self.enabled = False
43 | self.value: int
44 | self.prio: int = self.value
45 | self.func = func
46 |
47 | @property
48 | def key(self):
49 | return self._key
50 |
51 | @key.setter
52 | def key(self, key: str):
53 | self._key = key.strip()
54 |
55 | def extra_attrs(self):
56 | return self.enabled, self.key, self.prio
57 |
58 | def set_extras(self, enabled, key, prio):
59 | self.enabled = enabled
60 | self.key = key
61 | self.prio = prio
62 |
63 | @classmethod
64 | def set_attrs(cls, attr_dict: dict):
65 | for name, attrs in attr_dict.items():
66 | mem = cls[name]
67 | if mem:
68 | mem.set_extras(*attrs)
69 |
70 | @classmethod
71 | def get_attrs(cls) -> dict:
72 | attr_dict = {}
73 | for mem in cls:
74 | attr_dict[mem.name] = mem.extra_attrs()
75 | return attr_dict
76 |
77 | @classmethod
78 | def prioritised(cls) -> list:
79 | return sorted(cls, key=lambda m: m.prio)
80 |
--------------------------------------------------------------------------------
/core/info_2_upl.py:
--------------------------------------------------------------------------------
1 | import re
2 | import logging
3 | from typing import Iterable
4 | from collections import defaultdict
5 |
6 | from core.img_rehost import IH
7 | from core import utils, tp_text
8 | from gazelle.upload import UploadData
9 | from gazelle.tracker_data import ReleaseType
10 | from gazelle.torrent_info import TorrentInfo
11 |
12 | report = logging.getLogger('tr.inf2upl')
13 |
14 |
15 | class TorInfo2UplData:
16 | group = ('rel_type', 'title', 'o_year', 'vanity', 'alb_descr')
17 | torrent = ('medium', 'format', 'rem_year', 'rem_title', 'rem_label',
18 | 'rem_cat_nr', 'unknown', 'encoding', 'other_bitrate', 'vbr', 'scene', 'src_tr')
19 |
20 | def __init__(self,
21 | rehost_img: bool,
22 | whitelist: Iterable,
23 | rel_descr_templ: str,
24 | rel_descr_own_templ: str,
25 | add_src_descr: bool,
26 | src_descr_templ: str,
27 | ):
28 | self.rehost_img = rehost_img
29 | self.whitelist = whitelist
30 | self.rel_descr_templ = rel_descr_templ
31 | self.rel_descr_own_templ = rel_descr_own_templ
32 | self.add_src_descr = add_src_descr
33 | self.src_descr_templ = src_descr_templ
34 |
35 | def field_gen(self, dest_grp):
36 | if not dest_grp:
37 | yield from self.group
38 |
39 | yield from self.torrent
40 |
41 | def translate(self, tor_info: TorrentInfo, user_id: int, dest_group: int) -> UploadData:
42 | u_data = UploadData()
43 |
44 | for name in self.field_gen(dest_group):
45 | setattr(u_data, name, getattr(tor_info, name))
46 |
47 | self.release_description(tor_info, u_data, user_id)
48 | if not dest_group:
49 | self.parse_artists(tor_info, u_data)
50 | self.tags_to_string(tor_info, u_data)
51 | self.do_img(tor_info, u_data)
52 |
53 | return u_data
54 |
55 | def release_description(self, tor_info, u_data, user_id):
56 | descr_placeholders = {
57 | '%src_id%': tor_info.src_tr.name,
58 | '%src_url%': tor_info.src_tr.site,
59 | '%ori_upl%': tor_info.uploader,
60 | '%upl_id%': str(tor_info.uploader_id),
61 | '%tor_id%': str(tor_info.tor_id),
62 | '%gr_id%': str(tor_info.grp_id)
63 | }
64 | if user_id == tor_info.uploader_id:
65 | templ = self.rel_descr_own_templ
66 | else:
67 | templ = self.rel_descr_templ
68 |
69 | rel_descr = utils.multi_replace(templ, descr_placeholders)
70 |
71 | src_descr = tor_info.rel_descr
72 | if src_descr and self.add_src_descr:
73 | rel_descr += '\n\n' + utils.multi_replace(self.src_descr_templ, descr_placeholders,
74 | {'%src_descr%': src_descr})
75 | u_data.rel_descr = rel_descr
76 |
77 | @staticmethod
78 | def parse_artists(tor_info, u_data):
79 | artists = defaultdict(list)
80 | for a_type, artist_list in tor_info.artist_data.items():
81 | # a_dict: {'id': int, 'name': str}
82 | for a_dict in artist_list:
83 | artists[a_dict['name']].append(a_type)
84 |
85 | u_data.artists = dict(artists)
86 |
87 | DECADE_REX = re.compile(r'((19|20)\d)0s')
88 |
89 | def tag_gen(self, tor_info):
90 | skip_decade = tor_info.rel_type in (ReleaseType.Album, ReleaseType.EP, ReleaseType.Single)
91 | for tag in tor_info.tags:
92 | if tag == 'delete.this.tag':
93 | continue
94 | if skip_decade and (m := self.DECADE_REX.fullmatch(tag)):
95 | if m.group(1) == str(tor_info.o_year)[:3]:
96 | continue
97 | yield tag
98 |
99 | def tags_to_string(self, tor_info, u_data):
100 | tag_list = list(self.tag_gen(tor_info)) or tor_info.tags
101 | tag_string = ",".join(tag_list)
102 |
103 | if len(tag_string) > 200:
104 | tag_string = tag_string[:tag_string.rfind(',', 0, 201)]
105 |
106 | u_data.tags = tag_string
107 |
108 | def do_img(self, tor_info, u_data):
109 | src_img_url = tor_info.img_url
110 | if not self.rehost_img:
111 | u_data.upl_img_url = src_img_url
112 | return
113 |
114 | report.info(tp_text.rehost)
115 | proxy = tor_info.proxy_img
116 | if not src_img_url and not proxy:
117 | report.log(32, tp_text.no_img)
118 | return
119 |
120 | if any(w in src_img_url for w in self.whitelist):
121 | u_data.upl_img_url = src_img_url
122 | report.log(22, tp_text.img_white)
123 | return
124 |
125 | rehost_url = proxy or src_img_url
126 | u_data.upl_img_url = self.rehost(rehost_url) or src_img_url
127 |
128 | @staticmethod
129 | def rehost(src_img_url: str):
130 | report.log(22, tp_text.trying)
131 | for host in IH.prioritised():
132 | if not host.enabled:
133 | continue
134 | report.log(22, f'{host.name}...')
135 | try:
136 | rehosted_img = host.func(src_img_url, host.key)
137 | except Exception:
138 | continue
139 | else:
140 | report.log(22, rehosted_img)
141 | return rehosted_img
142 |
143 | report.log(32, tp_text.rehost_failed)
144 |
--------------------------------------------------------------------------------
/core/lean_torrent.py:
--------------------------------------------------------------------------------
1 | import math
2 | from hashlib import sha1
3 | from pathlib import Path
4 | from multiprocessing import pool
5 |
6 | from core.utils import scantree
7 |
8 |
9 | class Torrent:
10 | def __init__(self, path: Path):
11 | self.path = path
12 | self._file_list = []
13 | self._total_size = 0
14 | self._piece_size = None
15 | self.data = None
16 | self._pool = pool.ThreadPool()
17 | self.generate_data()
18 |
19 | def scan_files(self):
20 | if self.path.is_dir():
21 | for p in scantree(self.path):
22 | fsize = p.stat().st_size
23 | self._total_size += fsize
24 | self._file_list.append((p, fsize))
25 |
26 | @property
27 | def file_list(self) -> list[tuple[Path, int]]:
28 | if not self._file_list:
29 | self.scan_files()
30 | return self._file_list
31 |
32 | @property
33 | def total_size(self):
34 | if not self._total_size:
35 | self.scan_files()
36 | return self._total_size
37 |
38 | @property
39 | def piece_size(self):
40 | if not self._piece_size:
41 | min_piece_size = 2 ** 14
42 | max_piece_size = 2 ** 26
43 |
44 | piece_size = 2 ** math.ceil(math.log2(self.total_size / 1500))
45 | if piece_size < min_piece_size:
46 | piece_size = min_piece_size
47 | elif piece_size > max_piece_size:
48 | piece_size = max_piece_size
49 | self._piece_size = piece_size
50 |
51 | return self._piece_size
52 |
53 | def file_objects(self):
54 | for path, _ in self.file_list:
55 | with path.open('rb') as f:
56 | yield f
57 |
58 | def file_chunks(self):
59 | ps = self.piece_size
60 | read_size = ps
61 | chunks = []
62 | chunks_size = 0
63 | for f in self.file_objects():
64 | for chunk in iter(lambda: f.read(read_size), b''):
65 | chunks_size += len(chunk)
66 | chunks.append(chunk)
67 | if chunks_size == ps:
68 | yield chunks
69 | chunks_size = 0
70 | read_size = ps
71 | chunks = []
72 | else:
73 | read_size = ps - chunks_size
74 | if chunks_size:
75 | yield chunks
76 |
77 | @staticmethod
78 | def list_hasher(chunks: list[bytes]):
79 | h = sha1()
80 | for chunk in chunks:
81 | h.update(chunk)
82 | return h.digest()
83 |
84 | def file_hashes(self):
85 | for chsum in self._pool.imap(self.list_hasher, self.file_chunks(), 10):
86 | yield chsum
87 |
88 | def generate_data(self):
89 | info = {
90 | 'files': [],
91 | 'name': self.path.name,
92 | 'pieces': b''.join(self.file_hashes()),
93 | 'piece length': self.piece_size,
94 | 'private': 1
95 | }
96 | for path, size in self.file_list:
97 | fx = {'length': size,
98 | 'path': path.relative_to(self.path).parts}
99 | info['files'].append(fx)
100 |
101 | self.data = {'info': info}
102 |
--------------------------------------------------------------------------------
/core/tp_text.py:
--------------------------------------------------------------------------------
1 | tp_version = (2, 6, 0)
2 | # progress report
3 | requesting = "Requesting torrent info:"
4 | done = 'Done'
5 | fail = 'Fail'
6 | no_torfolder = 'Torrent has no folder'
7 | uploading = 'Uploading to'
8 | upl_success = 'Upload successful:'
9 | upl_fail = 'Upload failed:'
10 | bad_bitr = 'bitrate too low for RED'
11 | split_warn = 'Split -> Unknown. Please edit on RED'
12 | dtor_saved = 'New .torrent saved to:'
13 | dtor_deleted = '.torrent deleted from scan dir'
14 | missing = "Can't find:"
15 | no_log = "No logs found"
16 | log_count_wrong = 'Torrent has {} logs, {} found'
17 | new_tor = 'Generating new torrent'
18 | tor_downed = '.torrent downloaded from {}'
19 | f_checked = 'Files checked'
20 | rehost = 'Img rehost:'
21 | no_img = 'No img in source'
22 | img_white = 'source img whitelisted'
23 | trying = 'trying'
24 | rehost_failed = "Failed. Using source url"
25 | permission_error = 'Permission error. Folder skipped: '
26 | # post check
27 | log_score_dif = 'Log scores different: {} - {}'
28 | merged = 'Probably merged into an existing group'
29 | delete_this_tag = "Group has a 'delete.this.tag'"
30 | artist_mism = 'Artist count mismatch in'
31 |
32 | # Job
33 | no_src_tr = 'No valid source tracker could be established'
34 | id_xor_hash = 'Tor id XOR hash fail'
35 | not_dtor = 'Not a proper .torrent file'
36 |
37 | # CLI
38 | start = 'Starting\n'
39 | skip = 'Skipping'
40 | batch = 'Batch mode:'
41 |
42 | # gazelle
43 | upl_to_unkn = "Upload edited to 'Unknown Release'"
44 | edit_fail = "Failed to edit to 'Unknown Release' because of: "
45 |
--------------------------------------------------------------------------------
/core/transplant.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from pathlib import Path
3 | from hashlib import sha1
4 | from typing import Iterator
5 | from urllib.parse import urlparse
6 |
7 | from bcoding import bencode, bdecode
8 |
9 | from gazelle import upload
10 | from gazelle.tracker_data import TR, Encoding, BAD_RED_ENCODINGS, ArtistType
11 | from gazelle.api_classes import sleeve, BaseApi, OpsApi
12 | from gazelle.torrent_info import TorrentInfo
13 | from core import utils, tp_text
14 | from core.info_2_upl import TorInfo2UplData
15 | from core.lean_torrent import Torrent
16 |
17 | report = logging.getLogger('tr.core')
18 |
19 |
20 | def subdirs_gen(path: Path, maxlevel=1, level=1) -> Iterator[Path]:
21 | for p in path.iterdir():
22 | if p.is_dir():
23 | yield p
24 | if level < maxlevel:
25 | try:
26 | yield from subdirs_gen(p, maxlevel=maxlevel, level=level + 1)
27 | except PermissionError:
28 | report.debug(f'{tp_text.permission_error} {p}')
29 | continue
30 |
31 |
32 | class JobCreationError(Exception):
33 | pass
34 |
35 |
36 | class Job:
37 | def __init__(self, src_tr=None, tor_id=None, src_dom=None, dtor_path=None, scanned=False, dest_group=None,
38 | new_dtor=False, dest_trs=None):
39 |
40 | self.src_tr = src_tr
41 | self.tor_id = tor_id
42 | self.scanned = scanned
43 | self.dtor_path: Path = dtor_path
44 | self.dest_group = dest_group
45 | self.new_dtor = new_dtor
46 | self.dest_trs = dest_trs
47 |
48 | self.info_hash = None
49 | self.display_name = None
50 | self.dtor_dict = None
51 |
52 | if self.dtor_path:
53 | self.parse_dtorrent(self.dtor_path)
54 | self.display_name = self.dtor_path.stem
55 |
56 | if src_dom:
57 | for t in TR:
58 | if src_dom in t.site:
59 | self.src_tr = t
60 | break
61 |
62 | if not self.src_tr:
63 | raise JobCreationError(tp_text.no_src_tr)
64 |
65 | if (self.tor_id is None) is (self.info_hash is None):
66 | raise JobCreationError(tp_text.id_xor_hash)
67 |
68 | if not self.dest_trs:
69 | self.dest_trs = ~self.src_tr
70 |
71 | def parse_dtorrent(self, path: Path):
72 | torbytes = path.read_bytes()
73 | try:
74 | self.dtor_dict = bdecode(torbytes)
75 | info = self.dtor_dict['info']
76 | except (KeyError, TypeError):
77 | raise JobCreationError(tp_text.not_dtor)
78 |
79 | source = info.get('source', '').replace('PTH', 'RED')
80 | self.info_hash = sha1(bencode(info)).hexdigest()
81 |
82 | if source:
83 | try:
84 | self.src_tr = TR[source]
85 | return
86 | except KeyError:
87 | pass
88 |
89 | announce = self.dtor_dict.get('announce')
90 | if not announce:
91 | return
92 | parsed = urlparse(announce)
93 | if parsed.hostname:
94 | for t in TR:
95 | if parsed.hostname in t.tracker.lower():
96 | self.src_tr = t
97 | break
98 |
99 | def __hash__(self):
100 | return int(self.info_hash or f'{hash((self.src_tr, self.tor_id)):x}', 16)
101 |
102 | def __eq__(self, other):
103 | return (self.info_hash or (self.src_tr, self.tor_id)) == (other.info_hash or (other.src_tr, other.tor_id))
104 |
105 |
106 | class Transplanter:
107 | def __init__(self, key_dict, data_dir=None, deep_search=False, deep_search_level=None, dtor_save_dir=None,
108 | save_dtors=False, del_dtors=False, file_check=True, rel_descr_templ=None, rel_descr_own_templ=None,
109 | add_src_descr=True, src_descr_templ=None, img_rehost=False, whitelist=None, post_compare=False):
110 |
111 | self.api_map = {trckr: sleeve(trckr, key=key_dict[trckr]) for trckr in TR}
112 | self.data_dir: Path = data_dir
113 | self.deep_search = deep_search
114 | self.deep_search_level = deep_search_level
115 | self.dtor_save_dir: Path | None = dtor_save_dir
116 | self.save_dtors = save_dtors
117 | self.del_dtors = del_dtors
118 | self.file_check = file_check
119 | self.post_compare = post_compare
120 |
121 | if self.deep_search:
122 | self.subdir_store = {}
123 | self.subdir_gen = subdirs_gen(self.data_dir, maxlevel=self.deep_search_level)
124 |
125 | self.inf_2_upl = TorInfo2UplData(img_rehost, whitelist, rel_descr_templ, rel_descr_own_templ,
126 | add_src_descr, src_descr_templ)
127 | self.job = None
128 | self.tor_info: TorrentInfo | None = None
129 | self._torrent_folder_path = None
130 | self.lrm = False
131 | self.local_is_stripped = False
132 |
133 | def do_your_job(self, job: Job) -> bool:
134 | self.reset()
135 | self.job = job
136 |
137 | report.info(f"{self.job.src_tr.name} {self.job.display_name or self.job.tor_id}")
138 |
139 | src_api = self.api_map[self.job.src_tr]
140 | if not self.get_torinfo(src_api):
141 | return False
142 |
143 | if not self.job.display_name:
144 | self.job.display_name = self.tor_info.folder_name
145 | report.info(self.job.display_name)
146 |
147 | if self.fail_conditions():
148 | return False
149 |
150 | upl_files = upload.Files()
151 |
152 | if (self.tor_info.haslog or self.job.new_dtor) and not self.get_logs(upl_files, src_api):
153 | return False
154 | upl_data = self.inf_2_upl.translate(self.tor_info, src_api.account_info['id'], self.job.dest_group)
155 | self.get_dtor(upl_files, src_api)
156 |
157 | saul_goodman = True
158 | for dest_tr in self.job.dest_trs:
159 |
160 | dest_api = self.api_map[dest_tr]
161 | data_dict = upl_data.upl_dict(dest_tr, self.job.dest_group)
162 |
163 | files_list = upl_files.files_list(dest_api.announce, dest_tr.name, u_strip=self.strip_tor)
164 |
165 | report.info(f"{tp_text.uploading} {dest_tr.name}")
166 | try:
167 | new_id, new_group, new_url = dest_api.upload(data_dict, files_list)
168 | report.log(25, f"{tp_text.upl_success} {new_url}")
169 | except Exception:
170 | saul_goodman = False
171 | report.exception(f"{tp_text.upl_fail}")
172 | continue
173 |
174 | if self.post_compare:
175 | self.compare_upl_info(src_api, dest_api, new_id)
176 |
177 | if self.save_dtors:
178 | self.save_dtorrent(upl_files, new_url)
179 | report.info(f"{tp_text.dtor_saved} {self.dtor_save_dir}")
180 |
181 | if not saul_goodman:
182 | return False
183 |
184 | if self.del_dtors and self.job.scanned:
185 | self.job.dtor_path.unlink()
186 | report.info(tp_text.dtor_deleted)
187 |
188 | return True
189 |
190 | def reset(self):
191 | self.tor_info = None
192 | self._torrent_folder_path = None
193 | self.lrm = False
194 | self.local_is_stripped = False
195 |
196 | def get_torinfo(self, src_api):
197 | report.info(tp_text.requesting)
198 | if self.job.tor_id:
199 | info_kwarg = {'id': self.job.tor_id}
200 | elif self.job.info_hash:
201 | info_kwarg = {'hash': self.job.info_hash}
202 | else:
203 | return
204 | try:
205 | self.tor_info = src_api.torrent_info(**info_kwarg)
206 | except Exception:
207 | report.log(42, tp_text.fail, exc_info=True)
208 | else:
209 | report.log(22, tp_text.done)
210 | return True
211 |
212 | def fail_conditions(self) -> bool:
213 | if not self.tor_info.folder_name:
214 | report.error(tp_text.no_torfolder)
215 | return True
216 |
217 | if self.job.dest_trs is TR.RED:
218 | bad_bitrate = None
219 | if self.tor_info.encoding in BAD_RED_ENCODINGS:
220 | bad_bitrate = self.tor_info.encoding.name
221 | elif self.tor_info.encoding is Encoding.Other and self.tor_info.other_bitrate < 192:
222 | bad_bitrate = f'{self.tor_info.other_bitrate}' + (' (VBR)' if self.tor_info.vbr else '')
223 | if bad_bitrate:
224 | report.error(f'{tp_text.bad_bitr}: {bad_bitrate}')
225 | return True
226 |
227 | folder_needed = self.file_check or self.job.new_dtor
228 | if folder_needed and self.torrent_folder_path is None:
229 | report.error(f"{tp_text.missing} {self.tor_info.folder_name}")
230 | return True
231 |
232 | if self.file_check and not self.check_files():
233 | return True
234 |
235 | return False
236 |
237 | @property
238 | def strip_tor(self) -> bool:
239 | return self.lrm is self.local_is_stripped is True
240 |
241 | @property
242 | def torrent_folder_path(self) -> Path:
243 | if not self._torrent_folder_path:
244 | tor_folder_name: str = self.tor_info.folder_name
245 | stripped_folder = tor_folder_name.translate(utils.uni_t_table)
246 | if stripped_folder != tor_folder_name:
247 | self.lrm = True
248 |
249 | if (p := self.data_dir / tor_folder_name).exists():
250 | self._torrent_folder_path = p
251 | elif self.lrm and (p := self.data_dir / stripped_folder).exists():
252 | self._torrent_folder_path = p
253 | self.local_is_stripped = True
254 |
255 | elif self.deep_search:
256 | self.search_deep(tor_folder_name, stripped_folder)
257 |
258 | return self._torrent_folder_path
259 |
260 | def search_deep(self, tor_folder_name: str, stripped_folder: str):
261 | if tor_folder_name in self.subdir_store:
262 | self._torrent_folder_path = self.subdir_store[tor_folder_name]
263 | return
264 | if self.lrm and stripped_folder in self.subdir_store:
265 | self._torrent_folder_path = self.subdir_store[stripped_folder]
266 | self.local_is_stripped = True
267 | return
268 |
269 | for p in self.subdir_gen:
270 | if p.name == tor_folder_name:
271 | self._torrent_folder_path = p
272 | break
273 | elif self.lrm and p.name == stripped_folder:
274 | self._torrent_folder_path = p
275 | self.local_is_stripped = True
276 | break
277 | else:
278 | self.subdir_store[p.name] = p
279 |
280 | def compare_upl_info(self, src_api: BaseApi, dest_api: BaseApi, new_id: int):
281 | new_tor_info = dest_api.torrent_info(id=new_id)
282 |
283 | if self.tor_info.haslog:
284 | score_1 = self.tor_info.log_score
285 | score_2 = new_tor_info.log_score
286 | if not score_1 == score_2:
287 | report.warning(tp_text.log_score_dif.format(score_1, score_2))
288 |
289 | src_descr = self.tor_info.alb_descr.replace(src_api.url, '')
290 | dest_descr = new_tor_info.alb_descr.replace(dest_api.url, '')
291 |
292 | if src_descr != dest_descr or self.tor_info.title != new_tor_info.title:
293 | report.warning(tp_text.merged)
294 | if 'delete.this.tag' in new_tor_info.tags:
295 | report.warning(tp_text.delete_this_tag)
296 |
297 | red_info = None
298 | for i in (self.tor_info, new_tor_info):
299 | if i.src_tr is TR.RED:
300 | red_info = i
301 | break
302 | assert red_info
303 |
304 | mismatch = []
305 | for a_type in ArtistType:
306 | if a_type is ArtistType.Arranger and ArtistType.Arranger not in red_info.artist_data:
307 | continue
308 | if len(self.tor_info.artist_data[a_type]) != len(new_tor_info.artist_data[a_type]):
309 | mismatch.append(a_type.name)
310 |
311 | if mismatch:
312 | report.warning(f"{tp_text.artist_mism} {', '.join(mismatch)}")
313 |
314 | def get_dtor(self, files: upload.Files, src_api: BaseApi):
315 | if self.job.new_dtor:
316 | files.add_dtor(self.create_new_torrent())
317 |
318 | elif self.job.dtor_dict:
319 | files.add_dtor(self.job.dtor_dict)
320 | else:
321 | dtor_bytes = src_api.request('download', id=self.tor_info.tor_id)
322 | report.info(tp_text.tor_downed.format(self.job.src_tr.name))
323 | dtor_dict = bdecode(dtor_bytes)
324 | self.job.dtor_dict = dtor_dict
325 | files.add_dtor(dtor_dict)
326 |
327 | NOT_RIPLOG = ('audiochecker', 'aucdtect', 'accurip')
328 |
329 | def is_riplog(self, fn: str) -> bool:
330 | return not any(x in fn.lower() for x in self.NOT_RIPLOG)
331 |
332 | def get_logs(self, files: upload.Files, src_api: OpsApi) -> bool:
333 | if self.job.new_dtor:
334 | for p in self.torrent_folder_path.rglob('*.log'):
335 | if self.is_riplog(p.name):
336 | files.add_log(p)
337 |
338 | return True # new torrent may have no log while original had one
339 |
340 | elif not self.file_check and self.tor_info.log_ids:
341 | for i in self.tor_info.log_ids:
342 | files.add_log(src_api.get_riplog(self.tor_info.tor_id, i))
343 | else:
344 | for p in self.tor_info.glob('*.log'):
345 | if not self.is_riplog(p.name):
346 | continue
347 | found = self.check_path(p)
348 | if found is None:
349 | report.error(f"{tp_text.missing} {p}")
350 | return False
351 |
352 | files.add_log(found)
353 |
354 | if self.tor_info.log_ids:
355 | tor_log_count = len(self.tor_info.log_ids)
356 | found = len(files.logs)
357 | if tor_log_count != found:
358 | report.warning(tp_text.log_count_wrong.format(tor_log_count, found))
359 |
360 | elif not files.logs:
361 | report.error(tp_text.no_log)
362 | return False
363 |
364 | return True
365 |
366 | def create_new_torrent(self) -> dict:
367 | report.info(tp_text.new_tor)
368 | t = Torrent(self.torrent_folder_path)
369 |
370 | return t.data
371 |
372 | def check_path(self, rel_path: Path) -> Path | None:
373 | stripped = Path(str(rel_path).translate(utils.uni_t_table))
374 |
375 | has_lrm = rel_path != stripped
376 | if has_lrm:
377 | self.lrm = True
378 |
379 | full_p = self.torrent_folder_path / rel_path
380 | if full_p.exists():
381 | return full_p
382 | elif has_lrm:
383 | fp_stripped = self.torrent_folder_path / stripped
384 | if fp_stripped.exists():
385 | self.local_is_stripped = True
386 | return fp_stripped
387 |
388 | def check_files(self) -> bool:
389 | if self.job.new_dtor:
390 | return True
391 |
392 | for info_path in self.tor_info.file_paths():
393 | if self.check_path(info_path) is None:
394 | report.error(f"{tp_text.missing} {info_path}")
395 | return False
396 |
397 | report.info(tp_text.f_checked)
398 | return True
399 |
400 | def save_dtorrent(self, files: upload.Files, comment: str = None):
401 | dtor = files.dtors[0].as_dict(u_strip=self.strip_tor)
402 | if comment:
403 | dtor['comment'] = comment
404 | file_path = (self.dtor_save_dir / self.tor_info.folder_name).with_suffix('.torrent')
405 | file_path.write_bytes(bencode(dtor))
406 |
--------------------------------------------------------------------------------
/core/utils.py:
--------------------------------------------------------------------------------
1 | import re
2 | import traceback
3 | from pathlib import Path
4 | from typing import Iterator
5 |
6 |
7 | def scantree(path: Path) -> Iterator[Path]:
8 | for p in path.iterdir():
9 | if p.is_dir() and not p.name.startswith('.'):
10 | yield from scantree(p)
11 | else:
12 | yield p
13 |
14 |
15 | def multi_replace(src_txt, replace_map, *extra_maps):
16 | txt = src_txt
17 | if extra_maps:
18 | replace_map = replace_map.copy()
19 | for x in extra_maps:
20 | replace_map.update(x)
21 |
22 | for k, v in replace_map.items():
23 | txt = txt.replace(k, v)
24 | return txt
25 |
26 |
27 | STUPID_3_11_TB = re.compile(r'[\s^~]+')
28 |
29 |
30 | def tb_line_gen(tb):
31 | for line in traceback.format_tb(tb):
32 | for sub_line in line.splitlines():
33 | if STUPID_3_11_TB.fullmatch(sub_line):
34 | continue
35 | yield sub_line
36 |
37 |
38 | unicode_directional_markers = ('\u202a', '\u202b', '\u202c', '\u202d', '\u202e', '\u200e', '\u200f')
39 | uni_t_table = str.maketrans(dict.fromkeys(unicode_directional_markers))
40 |
--------------------------------------------------------------------------------
/gazelle/api_classes.py:
--------------------------------------------------------------------------------
1 | import re
2 | import time
3 | import base64
4 | import logging
5 | from hashlib import sha256
6 | from collections import deque
7 | from http.cookiejar import LWPCookieJar, LoadError
8 |
9 | import requests
10 | from requests.exceptions import JSONDecodeError
11 |
12 | from core import tp_text
13 | from gazelle.torrent_info import TorrentInfo
14 | from gazelle.tracker_data import TR
15 |
16 |
17 | class RequestFailure(Exception):
18 | pass
19 |
20 | report = logging.getLogger('tr.api')
21 |
22 |
23 | class BaseApi:
24 | def __init__(self, tracker: TR, **kwargs):
25 | assert tracker in TR, 'Unknown Tracker' # TODO uitext
26 | self.tr = tracker
27 | self.url = self.tr.site
28 | self.session = requests.Session()
29 | self.last_x_reqs = deque([.0], maxlen=self.tr.req_limit)
30 | self.authenticate(**kwargs)
31 | self._account_info = None
32 |
33 | def _rate_limit(self):
34 | t = time.time() - self.last_x_reqs[0]
35 | if t <= 10:
36 | time.sleep(10 - t)
37 |
38 | def authenticate(self, _):
39 | return NotImplementedError
40 |
41 | @property
42 | def announce(self):
43 | return self.tr.tracker.format(**self.account_info)
44 |
45 | @ property
46 | def account_info(self):
47 | if not self._account_info:
48 | self._account_info = self.get_account_info()
49 |
50 | return self._account_info
51 |
52 | def get_account_info(self):
53 | r = self.request('index')
54 | return {k: r[k] for k in ('authkey', 'passkey', 'id', 'username')}
55 |
56 | def request(self, url_suffix: str, data=None, files=None, **kwargs) -> dict | bytes:
57 | url = self.url + url_suffix + '.php'
58 | report.debug(f'{self.tr.name} {url_suffix} {kwargs}')
59 | req_method = 'POST' if data or files else 'GET'
60 |
61 | self._rate_limit()
62 | r = self.session.request(req_method, url, params=kwargs, data=data, files=files)
63 | self.last_x_reqs.append(time.time())
64 |
65 | try:
66 | r_dict = r.json()
67 | except JSONDecodeError:
68 | if 'application/x-bittorrent' in r.headers['content-type']:
69 | return r.content
70 | else:
71 | raise RequestFailure(f'no json, no torrent. {r.status_code}')
72 |
73 | status = r_dict.get('status')
74 | if status == 'success':
75 | return r_dict['response']
76 | elif status == 'failure':
77 | raise RequestFailure(r_dict['error'])
78 |
79 | raise RequestFailure(r_dict)
80 |
81 | def torrent_info(self, **kwargs) -> TorrentInfo:
82 | r = self.request('torrent', **kwargs)
83 | return TorrentInfo(r, self.tr)
84 |
85 | def upload(self, upl_data: dict, files: list):
86 | return self._uploader(upl_data, files)
87 |
88 | def _uploader(self, data: dict, files: list) -> dict:
89 | r = self.request('upload', data=data, files=files)
90 |
91 | return self.upl_response_handler(r)
92 |
93 | def upl_response_handler(self, r):
94 | raise NotImplementedError
95 |
96 |
97 | class KeyApi(BaseApi):
98 |
99 | def authenticate(self, **kwargs):
100 | key = kwargs['key']
101 | self.session.headers.update({"Authorization": key})
102 |
103 | def request(self, action: str, data=None, files=None, **kwargs):
104 | kwargs.update(action=action)
105 | return super().request('ajax', data=data, files=files, **kwargs)
106 |
107 | def upl_response_handler(self, r):
108 | raise NotImplementedError
109 |
110 | def get_riplog(self, tor_id: int, log_id: int):
111 | r: dict = self.request('riplog', id=tor_id, logid=log_id)
112 | log_bytes = base64.b64decode(r['log'])
113 | log_checksum = sha256(log_bytes).hexdigest()
114 | assert log_checksum == r['log_sha256']
115 | return log_bytes
116 |
117 |
118 | class CookieApi(BaseApi):
119 |
120 | def authenticate(self, **kwargs):
121 | self.session.cookies = LWPCookieJar(f'cookie{self.tr.name}.txt')
122 | if not self._load_cookie():
123 | self._login(**kwargs)
124 |
125 | def _load_cookie(self) -> bool:
126 | jar = self.session.cookies
127 | try:
128 | jar.load()
129 | session_cookie = [c for c in jar if c.name == "session"][0]
130 | assert not session_cookie.is_expired()
131 | except (FileNotFoundError, LoadError, IndexError, AssertionError):
132 | return False
133 |
134 | return True
135 |
136 | def _login(self, **kwargs):
137 | username, password = kwargs['f']()
138 | data = {'username': username,
139 | 'password': password,
140 | 'keeplogged': '1'}
141 | self.session.cookies.clear()
142 | self.request('login', data=data)
143 | assert [c for c in self.session.cookies if c.name == 'session']
144 | self.session.cookies.save()
145 |
146 | def request(self, action: str, data=None, files=None, **kwargs):
147 | if action in ('upload', 'login'): # TODO download?
148 | url_addon = action
149 | else:
150 | url_addon = 'ajax'
151 | kwargs.update(action=action)
152 |
153 | return super().request(url_addon, data=data, files=files, **kwargs)
154 |
155 | def _uploader(self, data: dict, files: list):
156 | data['submit'] = True
157 | super()._uploader(data, files)
158 |
159 | def upl_response_handler(self, r: requests.Response):
160 | if 'torrents.php' not in r.url:
161 | warning = re.search(r'(.+?)
', r.text)
162 | raise RequestFailure(f"{warning.group(1) if warning else r.url}")
163 | return r.url # TODO re torrentid from url and return
164 |
165 |
166 | class HtmlApi(CookieApi):
167 |
168 | def get_account_info(self):
169 | r = self.session.get(self.url + 'index.php')
170 | return {
171 | 'authkey': re.search(r"authkey=(.+?)[^a-zA-Z0-9]", r.text).group(1),
172 | 'passkey': re.search(r"passkey=(.+?)[^a-zA-Z0-9]", r.text).group(1),
173 | 'id': int(re.search(r"useri?d?=(.+?)[^0-9]", r.text).group(1))
174 | }
175 |
176 | def torrent_info(self, **kwargs):
177 | raise AttributeError(f'{self.tr.name} does not provide torrent info')
178 |
179 |
180 | class RedApi(KeyApi):
181 | def __init__(self, key=None):
182 | super().__init__(TR.RED, key=key)
183 |
184 | def _uploader(self, data: dict, files: list) -> (int, int, str):
185 | try:
186 | unknown = data.pop('unknown')
187 | except KeyError:
188 | unknown = False
189 |
190 | torrent_id, group_id = super()._uploader(data, files)
191 |
192 | if unknown:
193 | try:
194 | self.request('torrentedit', id=torrent_id, data={'unknown': True})
195 | report.info(tp_text.upl_to_unkn)
196 | except (RequestFailure, requests.HTTPError) as e:
197 | report.warning(f'{tp_text.edit_fail}{str(e)}')
198 | return torrent_id, group_id, self.url + f"torrents.php?id={group_id}&torrentid={torrent_id}"
199 |
200 | def upl_response_handler(self, r: dict) -> (int, int):
201 | return r.get('torrentid'), r.get('groupid')
202 |
203 |
204 | class OpsApi(KeyApi):
205 | def __init__(self, key=None):
206 | super().__init__(TR.OPS, key=f"token {key}")
207 |
208 | def upl_response_handler(self, r):
209 | group_id = r.get('groupId')
210 | torrent_id = r.get('torrentId')
211 |
212 | return torrent_id, group_id, self.url + f"torrents.php?id={group_id}&torrentid={torrent_id}"
213 |
214 |
215 | def sleeve(trckr: TR, **kwargs) -> RedApi | OpsApi:
216 | api_map = {
217 | TR.RED: RedApi,
218 | TR.OPS: OpsApi
219 | }
220 | return api_map[trckr](**kwargs)
221 |
--------------------------------------------------------------------------------
/gazelle/torrent_info.py:
--------------------------------------------------------------------------------
1 | import html
2 | import re
3 | from pathlib import Path
4 | from typing import Iterator, Any
5 |
6 | from gazelle.tracker_data import TR, ReleaseType, ArtistType, Encoding
7 |
8 | FIELD_MAP = {
9 | 'group': {
10 | 'id': 'grp_id',
11 | 'wikiImage': 'img_url',
12 | 'name': 'title',
13 | 'year': 'o_year',
14 | 'vanityHouse': 'vanity',
15 | 'tags': 'tags',
16 | },
17 | 'torrent': {
18 | 'id': 'tor_id',
19 | 'media': 'medium',
20 | 'format': 'format',
21 | 'remasterYear': 'rem_year',
22 | 'remasterTitle': 'rem_title',
23 | 'remasterRecordLabel': 'rem_label',
24 | 'remasterCatalogueNumber': 'rem_cat_nr',
25 | 'scene': 'scene',
26 | 'hasLog': 'haslog',
27 | 'logScore': 'log_score',
28 | 'ripLogIds': 'log_ids',
29 | 'description': 'rel_descr',
30 | 'filePath': 'folder_name',
31 | 'userId': 'uploader_id',
32 | 'username': 'uploader',
33 | }
34 | }
35 |
36 | ARTTIST_STRIP_REGEX = re.compile(r'(.+)\s\(\d+\)$')
37 |
38 |
39 | def unexape(thing: Any) -> Any:
40 | if isinstance(thing, list):
41 | for i, x in enumerate(thing):
42 | thing[i] = unexape(x)
43 | elif isinstance(thing, dict):
44 | for k, v in thing.items():
45 | thing[k] = unexape(v)
46 | try:
47 | return html.unescape(thing)
48 | except TypeError:
49 | return thing
50 |
51 |
52 | class TorrentInfo:
53 | def __init__(self, tr_resp: dict, src_tr: TR):
54 | self.grp_id: int | None = None
55 | self.img_url: str | None = None
56 | self.proxy_img: str | None = None
57 | self.title: str | None = None
58 | self.o_year: int | None = None
59 | self.rel_type: ReleaseType | None = None
60 | self.vanity: bool = False
61 | self.artist_data: dict | None = None
62 | self.tags: list | None = None
63 | self.alb_descr: str | None = None
64 |
65 | self.tor_id: int | None = None
66 | self.medium: str | None = None
67 | self.format: str | None = None
68 | self.encoding: Encoding | None = None
69 | self.other_bitrate: int | None = None
70 | self.vbr: bool = False
71 | self.rem_year: int | None = None
72 | self.rem_title: str | None = None
73 | self.rem_label: str | None = None
74 | self.rem_cat_nr: str | None = None
75 | self.scene: bool = False
76 | self.haslog: bool = False
77 | self.log_score: int | None = None
78 | self.log_ids: list | None = None
79 | self.rel_descr: str | None = None
80 | self.folder_name: str | None = None
81 | self.uploader_id: int | None = None
82 | self.uploader: str | None = None
83 |
84 | self.file_list: list | None = None
85 | self.unknown: bool = False
86 | self.src_tr: TR | None = src_tr
87 |
88 | if src_tr is TR.RED:
89 | self.set_red_info(tr_resp)
90 | elif src_tr is TR.OPS:
91 | self.set_ops_info(tr_resp)
92 |
93 | def set_common_gazelle(self, tr_resp: dict):
94 | for sub_name, sub_dict in FIELD_MAP.items():
95 | for gaz_name, torinfo_name in sub_dict.items():
96 | value = tr_resp[sub_name][gaz_name]
97 | if value:
98 | setattr(self, torinfo_name, value)
99 |
100 | enc_str = tr_resp['torrent']['encoding']
101 | self.encoding = Encoding[enc_str]
102 | if self.encoding is Encoding.Other:
103 | bitr, vbr, _ = enc_str.partition(' (VBR)')
104 | self.other_bitrate = int(bitr)
105 | self.vbr = bool(vbr)
106 |
107 | files = []
108 | for s in tr_resp['torrent']['fileList'].split("|||"):
109 | path, size = s.removesuffix('}}}').split('{{{')
110 | files.append({'path': Path(path),
111 | 'size': int(size)})
112 | self.file_list = files
113 |
114 | artists = {}
115 | for a_type, artist_list in tr_resp['group']['musicInfo'].items():
116 | artists[ArtistType(a_type)] = artist_list
117 | self.artist_data = artists
118 |
119 | def set_red_info(self, tr_resp: dict):
120 | tr_resp: dict = unexape(tr_resp)
121 | self.set_common_gazelle(tr_resp)
122 |
123 | self.alb_descr = tr_resp['group']['bbBody']
124 |
125 | rel_type: int = tr_resp['group']['releaseType']
126 | self.rel_type = ReleaseType.mem_from_tr_value(rel_type, TR.RED)
127 |
128 | if not self.rem_year:
129 | if tr_resp['torrent']['remastered']:
130 | self.unknown = True
131 | else:
132 | # unconfirmed
133 | self.rem_year = self.o_year
134 | self.rem_label = tr_resp['group']['recordLabel']
135 | self.rem_cat_nr = tr_resp['group']['catalogueNumber']
136 |
137 | def set_ops_info(self, tr_resp: dict):
138 | self.set_common_gazelle(tr_resp)
139 | self.rel_type = ReleaseType[tr_resp['group']['releaseTypeName']]
140 | self.alb_descr = tr_resp['group']['wikiBBcode']
141 | self.proxy_img = tr_resp['group']['proxyImage']
142 |
143 | # strip disambiguation nr from artists
144 | self.strip_artists()
145 |
146 | if tr_resp['torrent']['remastered']:
147 | if not self.rem_year:
148 | self.unknown = True
149 | else:
150 | # get rid of original release
151 | self.rem_year = self.o_year
152 | self.rem_label = tr_resp['group']['recordLabel']
153 | self.rem_cat_nr = tr_resp['group']['catalogueNumber']
154 |
155 | if self.medium == 'BD':
156 | self.medium = 'Blu-Ray'
157 |
158 | def strip_artists(self):
159 | for a_type, artists in self.artist_data.items():
160 | for a in artists:
161 | if match := ARTTIST_STRIP_REGEX.match(a['name']):
162 | stripped = match.group(1)
163 | a['name'] = stripped
164 |
165 | def file_paths(self) -> Iterator[Path]:
166 | for fd in self.file_list:
167 | yield fd['path']
168 |
169 | def glob(self, pattern: str) -> Iterator[Path]:
170 | # todo 3.12: pattern = Path(pattern)
171 | for p in self.file_paths():
172 | if p.match(pattern):
173 | yield p
174 |
--------------------------------------------------------------------------------
/gazelle/tracker_data.py:
--------------------------------------------------------------------------------
1 | from enum import Enum, Flag, EnumMeta
2 |
3 |
4 | class TR(Flag):
5 | RED = {
6 | 'site': 'https://redacted.sh/',
7 | 'tracker': 'https://flacsfor.me/{passkey}/announce',
8 | 'favicon': 'pth.ico',
9 | 'req_limit': 10
10 | }
11 | OPS = {
12 | 'site': 'https://orpheus.network/',
13 | 'tracker': 'https://home.opsfet.ch/{passkey}/announce',
14 | 'favicon': 'ops.ico',
15 | 'req_limit': 5,
16 | }
17 |
18 | def __new__(cls, value: dict):
19 | obj = object.__new__(cls)
20 | obj._value_ = 2 ** len(cls.__members__)
21 | for k, v in value.items():
22 | setattr(obj, k, v)
23 | return obj
24 |
25 |
26 | class RelTypeMeta(EnumMeta):
27 | tr_val_mem_map = {t: {} for t in TR}
28 |
29 | def __getitem__(cls, item):
30 | try:
31 | item = item.replace(' ', '_')
32 | except AttributeError:
33 | pass
34 | return cls._member_map_[item]
35 |
36 |
37 | class ReleaseType(Enum, metaclass=RelTypeMeta):
38 | Album = 1
39 | Soundtrack = 3
40 | EP = 5
41 | Anthology = 6
42 | Compilation = 7
43 | Sampler = None, 8
44 | Single = 9
45 | Demo = 17, 10
46 | Live_album = 11
47 | Split = None, 12
48 | Remix = 13
49 | Bootleg = 14
50 | Interview = 15
51 | Mixtape = 16
52 | DJ_Mix = 19, 17
53 | Concert_recording = 18
54 | Unknown = 21
55 |
56 | def __new__(cls, *args):
57 | obj = object.__new__(cls)
58 | obj._value_ = len(cls.__members__) + 1
59 | if len(args) != len(TR):
60 | args *= len(TR)
61 | obj._tracker_values = {}
62 | for t, val in zip(TR, args):
63 | obj._tracker_values[t] = val
64 | cls.tr_val_mem_map[t][val] = obj
65 | return obj
66 |
67 | @property
68 | def name(self):
69 | return self._name_.replace('_', ' ')
70 |
71 | def tracker_value(self, t: TR):
72 | return self._tracker_values[t]
73 |
74 | @classmethod
75 | def mem_from_tr_value(cls, val: int, t: TR):
76 | return cls.tr_val_mem_map[t][val]
77 |
78 |
79 | class ArtistType(Enum):
80 | Main = 1, 'artists'
81 | Guest = 2, 'with'
82 | Remixer = 3, 'remixedBy'
83 | Composer = 4, 'composers'
84 | Conductor = 5, 'conductor'
85 | DJ_Comp = 6, 'dj'
86 | Producer = 7, 'producer'
87 | Arranger = 8, 'arranger'
88 |
89 | def __new__(cls, int_val, str_val):
90 | obj = object.__new__(cls)
91 | obj._value_ = str_val
92 | obj.nr = int_val
93 | return obj
94 |
95 |
96 | class EncMeta(EnumMeta):
97 | alt_names_map = {}
98 |
99 | def __getitem__(cls, item):
100 | return cls.alt_names_map.get(item) or cls._member_map_['Other']
101 |
102 |
103 | class Encoding(Flag, metaclass=EncMeta):
104 | Lossless = 'Lossless'
105 | Lossless_24 = '24bit Lossless'
106 | C320 = '320'
107 | C256 = '256'
108 | C192 = '192'
109 | C160 = '160'
110 | C128 = '128'
111 | V0 = 'V0 (VBR)'
112 | V1 = 'V1 (VBR)'
113 | V2 = 'V2 (VBR)'
114 | APS = 'APS (VBR)'
115 | APX = 'APX (VBR)'
116 | Other = 'Other'
117 |
118 | def __new__(cls, arg):
119 | obj = object.__new__(cls)
120 | obj._value_ = 2 ** len(cls.__members__)
121 | obj.alt_name = arg
122 | cls.alt_names_map[arg] = obj
123 | return obj
124 |
125 | @property
126 | def name(self):
127 | return self.alt_name
128 |
129 |
130 | BAD_RED_ENCODINGS = Encoding.C128 | Encoding.C160
131 |
--------------------------------------------------------------------------------
/gazelle/upload.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from pathlib import Path
3 | from bcoding import bencode, bdecode
4 | from gazelle.tracker_data import TR, ReleaseType, ArtistType, Encoding
5 | from core import tp_text
6 | from core .utils import uni_t_table
7 |
8 | report = logging.getLogger('tr.upl')
9 |
10 |
11 | FIELD_MAPPING = {
12 | 'edition': {
13 | 'unknown': 'unknown',
14 | 'remastered': 'remaster',
15 | 'rem_year': 'remaster_year',
16 | 'rem_title': 'remaster_title',
17 | 'rem_label': 'remaster_record_label',
18 | 'rem_cat_nr': 'remaster_catalogue_number',
19 | 'scene': 'scene',
20 | 'medium': 'media',
21 | 'format': 'format',
22 | 'encoding': 'bitrate',
23 | 'other_bitrate': 'other_bitrate',
24 | 'vbr': 'vbr',
25 | 'rel_descr': 'release_desc',
26 | 'request_id': 'requestid',
27 | 'extra_format': 'extra_format[]',
28 | 'extra_encoding': 'extra_bitrate[]',
29 | 'extra_rel_descr': 'extra_release_desc[]'
30 | },
31 | 'group': {
32 | 'rel_type': 'releasetype',
33 | 'title': 'title',
34 | 'o_year': 'year',
35 | 'tags': 'tags',
36 | 'upl_img_url': 'image',
37 | 'vanity': 'vanity_house',
38 | 'alb_descr': 'album_desc'
39 | }
40 | }
41 |
42 |
43 | class UploadData:
44 | def __init__(self):
45 | self.rel_type: ReleaseType | None = None
46 | self.artists: dict[str, list[ArtistType]] | None = None
47 | self.title: str | None = None
48 | self.o_year: int | None = None
49 | self.unknown: bool = False
50 | self.remastered: bool = True
51 | self.rem_year: int | None = None
52 | self.rem_title: str | None = None
53 | self.rem_label: str | None = None
54 | self.rem_cat_nr: str | None = None
55 | self.scene: bool = False
56 | self.medium: str | None = None
57 | self.format: str | None = None
58 | self.encoding: Encoding | None = None
59 | self.other_bitrate: int | None = None
60 | self.vbr: bool = False
61 | self.vanity: bool = False
62 | self.tags: str | None = None
63 | self.upl_img_url: str | None = None
64 | self.alb_descr: str | None = None
65 | self.rel_descr: str | None = None
66 | self.request_id: int | None = None
67 | self.extra_format: str | None = None
68 | self.extra_encoding: str | None = None
69 | self.extra_rel_descr: str | None = None
70 | self.src_tr: TR | None = None
71 |
72 | def _get_field(self, name: str, dest: TR):
73 | if name == 'rel_type':
74 | return self.rel_type.tracker_value(dest)
75 | if name == 'encoding':
76 | return self.encoding.name
77 | if name == 'alb_descr':
78 | return self.alb_descr.replace(self.src_tr.site, dest.site)
79 |
80 | return getattr(self, name)
81 |
82 | def upl_dict(self, dest: TR, dest_group=None):
83 | field_map = FIELD_MAPPING['edition'].copy()
84 | upl_data = {'type': 0}
85 |
86 | if dest_group:
87 | upl_data['groupid'] = dest_group
88 | else:
89 | field_map.update(FIELD_MAPPING['group'])
90 |
91 | artists = []
92 | importances = []
93 | for artist_name, a_types in self.artists.items():
94 | for a_type in a_types:
95 | artists.append(artist_name)
96 | importances.append(a_type.nr)
97 | upl_data['artists[]'] = artists
98 | upl_data['importance[]'] = importances
99 |
100 | for k, v in field_map.items():
101 | value = self._get_field(k, dest)
102 | if value:
103 | upl_data[v] = value
104 |
105 | if dest is TR.RED:
106 | if self.rel_type is ReleaseType.Sampler:
107 | upl_data['releasetype'] = 7
108 | if self.rel_type is ReleaseType.Split:
109 | upl_data['releasetype'] = 21
110 | report.warning(tp_text.split_warn)
111 | if self.unknown:
112 | upl_data['remaster_year'] = '1990'
113 | upl_data['remaster_title'] = 'Unknown release year'
114 |
115 | elif dest is TR.OPS:
116 | upl_data['workaround_broken_html_entities'] = 0
117 | if self.medium == 'Blu-Ray':
118 | upl_data['media'] = 'BD'
119 |
120 | return upl_data
121 |
122 |
123 | class Dtor:
124 | def __init__(self, tor: bytes | dict | Path):
125 | self.announce = None
126 | self.source = None
127 |
128 | if isinstance(tor, bytes):
129 | self.t_info = bdecode(tor)['info']
130 | elif isinstance(tor, dict):
131 | self.t_info = tor['info']
132 | elif isinstance(tor, Path):
133 | self.t_info = bdecode(tor.read_bytes())['info']
134 | else:
135 | raise TypeError
136 |
137 | if 'source' in self.t_info:
138 | del self.t_info['source']
139 |
140 | self.lrm = False
141 |
142 | self.stripped_info = self.t_info.copy()
143 | self.stripped_info['name'] = self.t_info['name'].translate(uni_t_table)
144 | for fd in self.stripped_info['files']:
145 | p_elements: list = fd['path']
146 | fd['path'] = [e.translate(uni_t_table) for e in p_elements]
147 | if self.stripped_info != self.t_info:
148 | self.lrm = True
149 |
150 | def as_bytes(self, u_strip=False):
151 | return bencode(self.as_dict(u_strip))
152 |
153 | def as_dict(self, u_strip=False):
154 | tordict = {}
155 | if self.announce:
156 | tordict['announce'] = self.announce
157 | if not self.lrm or not u_strip:
158 | info = self.t_info
159 | else:
160 | info = self.stripped_info
161 | if self.source:
162 | info['source'] = self.source
163 |
164 | tordict['info'] = info
165 | return tordict
166 |
167 | def trackerise(self, announce=None, source=None):
168 | self.announce = announce
169 | self.source = source
170 |
171 |
172 | class Files:
173 | def __init__(self):
174 | self.dtors: list[Dtor] = []
175 | self.logs: list[bytes] = []
176 |
177 | def add_log(self, log: Path | bytes):
178 | if isinstance(log, Path):
179 | log = log.read_bytes()
180 | elif isinstance(log, bytes):
181 | pass
182 | else:
183 | raise TypeError
184 | if log not in self.logs:
185 | self.logs.append(log)
186 |
187 | def add_dtor(self, dtor):
188 | self.dtors.append(Dtor(dtor))
189 |
190 | @staticmethod
191 | def tor_field_names():
192 | index = 0
193 | while True:
194 | if not index:
195 | yield 'file_input', index
196 | else:
197 | yield f'extra_file_{index}', index
198 | index += 1
199 |
200 | def files_list(self, announce=None, source=None, u_strip=False) -> list:
201 | files = []
202 | for (field_name, i), dtor in zip(self.tor_field_names(), self.dtors):
203 | dtor.trackerise(announce, source)
204 | files.append((field_name, (f'blabla{i}.torrent', dtor.as_bytes(u_strip), 'application/x-bittorrent')))
205 |
206 | for log in self.logs:
207 | files.append(('logfiles[]', ('log.log', log, 'application/octet-stream')))
208 |
209 | return files
210 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | pyqt6>=6.7
2 | requests>=2.27.0
3 | bcoding
--------------------------------------------------------------------------------
/transplant_GUI.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import logging
3 | from GUI import resources
4 | from GUI.misc_classes import Application
5 |
6 |
7 | if __name__ == "__main__":
8 | sys.excepthook = lambda cls, ex, tb: logger.error('', exc_info=(cls, ex, tb))
9 |
10 | logger = logging.getLogger('tr.GUI')
11 | logger.setLevel(logging.INFO)
12 |
13 | if hasattr(sys, 'frozen'):
14 | if '-log' in sys.argv:
15 | sys.argv.remove('-log')
16 | handler = logging.FileHandler('transplant.log')
17 | handler.setFormatter(logging.Formatter(fmt='%(asctime)s'))
18 | logger.addHandler(handler)
19 | else:
20 | logger.addHandler(logging.StreamHandler(stream=sys.stdout))
21 |
22 | Application.setStyle('Fusion') # prevent windows11 default style which leads to blank button bug on pyqt 6.8
23 | app = Application(sys.argv)
24 |
25 | from GUI.control_room import start_up, save_state
26 |
27 | start_up()
28 | app.aboutToQuit.connect(save_state)
29 | sys.exit(app.exec())
30 |
--------------------------------------------------------------------------------
/transplant_cli.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import re
3 | import logging
4 | from pathlib import Path
5 | from urllib.parse import urlparse, parse_qs
6 | from typing import Iterator
7 |
8 | from core.transplant import Transplanter, Job, JobCreationError
9 | from core import tp_text
10 | from cli_config import cli_config
11 | from core.utils import tb_line_gen
12 | from core.img_rehost import IH
13 | from gazelle.tracker_data import TR
14 |
15 |
16 | class SlStreamHandler(logging.StreamHandler):
17 | @staticmethod
18 | def make_msg(msg: str):
19 | return msg
20 |
21 | def emit(self, record: logging.LogRecord):
22 | same_line = record.levelno % 5
23 | prefix = ' ' if same_line else '\n'
24 | if record.msg:
25 | msg = prefix + self.make_msg(record.msg)
26 | else:
27 | msg = prefix
28 |
29 | self.stream.write(msg)
30 |
31 | if record.exc_info and None not in record.exc_info:
32 | cls, ex, tb = record.exc_info
33 |
34 | if self.level < logging.INFO:
35 | self.stream.write('\n' + 'Traceback (most recent call last):')
36 | self.stream.write('\n' + '\n'.join(tb_line_gen(tb)))
37 |
38 | self.stream.write('\n' + self.make_msg(f'{cls.__name__}: {ex}'))
39 | self.stream.flush()
40 |
41 |
42 | class SLColorStreamHandler(SlStreamHandler):
43 | LEVEL_COLORS = {
44 | 40: "\x1b[0;31m", # Error
45 | 30: "\x1b[0;33m", # Warning
46 | 25: "\x1b[0;32m", # Success
47 | }
48 |
49 | def __init__(self, stream=None):
50 | super().__init__(stream)
51 | self.color = None
52 |
53 | def make_msg(self, msg):
54 | if self.color:
55 | msg = self.color + msg + "\x1b[0m"
56 | return msg
57 |
58 | def emit(self, record: logging.LogRecord):
59 | level = (record.levelno // 5) * 5
60 | self.color = self.LEVEL_COLORS.get(level)
61 | super().emit(record)
62 |
63 |
64 | verb_map = {
65 | 0: logging.CRITICAL,
66 | 1: logging.ERROR,
67 | 2: logging.INFO,
68 | 3: logging.DEBUG
69 | }
70 |
71 | report = logging.getLogger('tr')
72 | report.setLevel(verb_map[cli_config.verbosity])
73 | if cli_config.coloured_output:
74 | handler = SLColorStreamHandler()
75 | else:
76 | handler = SlStreamHandler()
77 | handler.setStream(sys.stdout)
78 | report.addHandler(handler)
79 | handler.setLevel(verb_map[cli_config.verbosity])
80 |
81 |
82 | def parse_input() -> Iterator[tuple[str, dict]]:
83 | args = sys.argv[1:]
84 | batchmode = False
85 |
86 | for arg in args:
87 | if arg.lower() == "batch":
88 | batchmode = True
89 | continue
90 |
91 | match_id = re.fullmatch(r"(RED|OPS)(\d+)", arg)
92 | if match_id:
93 | yield arg, {'src_tr': TR[match_id.group(1)], 'tor_id': match_id.group(2)}
94 | continue
95 |
96 | parsed = urlparse(arg)
97 | hostname = parsed.hostname
98 | tor_id = parse_qs(parsed.query).get('torrentid')
99 | if tor_id and hostname:
100 | yield arg, {'src_dom': hostname, 'tor_id': tor_id[0]}
101 | else:
102 | report.info(arg)
103 | report.warning(tp_text.skip)
104 |
105 | if batchmode:
106 | report.info(tp_text.batch)
107 | for p in Path(cli_config.scan_dir).glob('*.torrent'):
108 | yield p.name, {'dtor_path': p, 'scanned': True}
109 |
110 |
111 | def get_jobs() -> Iterator[Job]:
112 | for arg, kwarg_dict in parse_input():
113 | try:
114 | yield Job(**kwarg_dict)
115 | except JobCreationError as e:
116 | report.info(arg)
117 | report.warning(f'{tp_text.skip}: {e}\n')
118 | continue
119 |
120 |
121 | def main():
122 | report.info(tp_text.start)
123 |
124 | trpl_settings = {
125 | 'data_dir': Path(cli_config.data_dir),
126 | 'deep_search': cli_config.deep_search,
127 | 'deep_search_level': cli_config.deep_search_level,
128 | 'dtor_save_dir': Path(tsd) if (tsd := cli_config.torrent_save_dir) else None,
129 | 'save_dtors': bool(cli_config.torrent_save_dir),
130 | 'del_dtors': cli_config.del_dtors,
131 | 'file_check': cli_config.file_check,
132 | 'rel_descr_templ': cli_config.rel_descr,
133 | 'rel_descr_own_templ': cli_config.rel_descr_own_uploads,
134 | 'add_src_descr': cli_config.add_src_descr,
135 | 'src_descr_templ': cli_config.src_descr,
136 | 'img_rehost': cli_config.img_rehost,
137 | 'whitelist': cli_config.whitelist,
138 | 'post_compare': cli_config.post_upload_checks,
139 | }
140 | if cli_config.img_rehost:
141 | IH.set_attrs(cli_config.image_hosts)
142 |
143 | key_dict = {trckr: getattr(cli_config, f'api_key_{trckr.name}') for trckr in TR}
144 |
145 | transplanter = Transplanter(key_dict, **trpl_settings)
146 | for job in get_jobs():
147 | try:
148 | transplanter.do_your_job(job)
149 | except Exception:
150 | report.exception('')
151 | continue
152 | finally:
153 | report.info('')
154 |
155 |
156 | if __name__ == "__main__":
157 | main()
158 |
--------------------------------------------------------------------------------