├── 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 | --------------------------------------------------------------------------------