gui
1import os 2import sys 3import json 4import time 5import queue 6import shutil 7import gettext 8import argparse 9import threading 10import subprocess 11import tkinter as tk 12from tkinter import ttk 13from src.link_to_file import * 14from src.downloader.sdilej import Sdilej_downloader 15from src.downloader.datoid import Datoid_downloader 16from src.downloader.prehrajto import Prehrajto_downloader 17from main import download_folder, JSON_FILE 18from src.downloader.page_search import Download_page_search, InsufficientTimeoutError 19 20CONFIG_FILE = "config.json" 21DEFAULT_LANGUAGE = "en" 22TIME_OUT = 50 23 24LANGUAGES = { 25 'EN': 'en', 26 'CZ': 'cs' 27} 28 29SOURCES = [ 30 # {"name": "Sdilej.cz", "class": Sdilej_downloader, "timeout": TIME_OUT}, 31 {"name": "Datoid.cz", "class": Datoid_downloader, "timeout": TIME_OUT}, 32 {"name": "Prehraj.to", "class": Prehrajto_downloader, "timeout": TIME_OUT}, 33] 34 35GEOMETRY = (800, 600) 36GEOMETRY = (1300, 800) 37APP_NAME = "Universal Downloader" 38 39# map source class -> display name for quick lookup 40CLASS_NAME_MAP = {s["class"]: s["name"] for s in SOURCES} 41 42DOMAIN = 'universal_downloader' 43ICON_FILE = 'icon.png' 44ASSETS_DIR = 'assets' 45 46def get_resource_path(relative_path): 47 """ 48 Get absolute path to resource, works for dev and for PyInstaller 49 """ 50 if hasattr(sys, "_MEIPASS"): 51 # Cesta k dočasné složce při spuštění .exe 52 return os.path.join(sys._MEIPASS, relative_path) 53 return os.path.join(os.path.abspath("."), relative_path) 54 55def compile_mo_files(): 56 """ 57 Compiles .po files to .mo files using msgfmt. 58 If msgfmt is not found, skips compilation (Typically on target machine with .exe). 59 """ 60 localedir = get_resource_path("locales") 61 msgfmt_path = shutil.which('msgfmt') 62 if not msgfmt_path: 63 print("msgfmt not found in PATH — skipping .po -> .mo compilation. Ensure .mo files are included in the build.") 64 return 65 66 for lang in LANGUAGES.values(): 67 po_file = os.path.join(localedir, lang, 'LC_MESSAGES', DOMAIN + '.po') 68 mo_file = os.path.join(localedir, lang, 'LC_MESSAGES', DOMAIN + '.mo') 69 if os.path.exists(po_file): 70 if not os.path.exists(mo_file) or os.path.getmtime(po_file) > os.path.getmtime(mo_file): 71 print(f"Compiling {po_file} to {mo_file}") 72 try: 73 subprocess.run([msgfmt_path, '-o', mo_file, po_file], check=True) 74 except subprocess.CalledProcessError as e: 75 print(f"msgfmt failed: {e}") 76 77class DownloaderGUI(tk.Tk): 78 lang_codes = LANGUAGES.values() 79 80 def __init__(self): 81 super().__init__() 82 83 dpi = self.winfo_fpixels('1i') 84 self.scaling_factor = dpi / 96.0 85 86 # Nastavení škálování pro Tkinter engine 87 self.tk.call('tk', 'scaling', self.scaling_factor) 88 # --------------------------- 89 90 # Dynamický výpočet geometrie místo hardcodovaného "1300x800" 91 width = int(GEOMETRY[0] * self.scaling_factor) 92 height = int(GEOMETRY[1] * self.scaling_factor) 93 94 self.geometry(f"{width}x{height}") 95 96 icon_path = get_resource_path(os.path.join(ASSETS_DIR, "icon.png")) 97 if os.path.isfile(icon_path): 98 icon = tk.PhotoImage(file=icon_path) 99 self.iconphoto(True, icon) 100 else: 101 print(f"Icon: '{icon_path}' not found.") 102 103 self.settings = self.load_config() 104 self.current_language = tk.StringVar(value=self.settings.get("language", DEFAULT_LANGUAGE)) 105 self.remove_successful_var = tk.BooleanVar(value=self.settings.get("remove_successful", False)) 106 self.remove_successful_var.trace_add("write", self.update_remove_successful) 107 self.add_files_with_failed_timeout_var = tk.BooleanVar(value=self.settings.get("add_files_with_failed_timeout", False)) 108 self.add_files_with_failed_timeout_var.trace_add("write", self.update_add_files_with_failed_timeout) 109 110 self.source_vars = [] 111 for source in SOURCES: 112 var = tk.BooleanVar(value=self.settings.get(source["name"], True)) 113 # Save changes to config on change 114 var.trace_add("write", lambda *args, name=source["name"], var=var: self.settings.update({name: var.get()}) or self.save_config()) 115 self.source_vars.append(var) 116 117 self.link_map = {} # detail_url -> Link_to_file (mapping with result treeview) 118 119 self.setup_translation() 120 self.title(_(APP_NAME)) 121 self.create_widgets() 122 123 def load_config(self, config_file=CONFIG_FILE) -> dict: 124 """ 125 Load configuration from a JSON file. 126 If the file does not exist, returns an empty dictionary. 127 """ 128 try: 129 with open(config_file, "r") as file: 130 return json.load(file) 131 except FileNotFoundError: 132 return {} 133 134 def save_config(self, config_file=CONFIG_FILE): 135 """ 136 Save configuration to a JSON file. 137 """ 138 with open(config_file, "w") as file: 139 json.dump(self.settings, file) 140 141 def setup_translation(self, domain=DOMAIN): 142 lang_code = self.current_language.get() 143 global _ 144 localedir = get_resource_path("locales") 145 try: 146 lang = gettext.translation(domain, localedir=localedir, languages=[lang_code]) 147 lang.install() 148 if DEBUG: 149 print_success(f"Translation loaded for {lang_code}.") 150 _ = lang.gettext 151 except Exception as e: 152 print_error(f"Translation not found for {lang_code}, falling back to default. Error: {e}") 153 gettext.install(domain, localedir=localedir) 154 _ = gettext.gettext 155 156 def create_widgets(self): 157 # Menu bar 158 menubar = tk.Menu(self) 159 self.config(menu=menubar) 160 161 # File menu 162 file_menu = tk.Menu(menubar, tearoff=0) 163 file_menu.add_command(label=_("Save Selected"), command=self.save_selected) 164 file_menu.add_command(label=_("Load from file"), command=self.load_from_file) 165 menubar.add_cascade(label=_("File"), menu=file_menu) 166 167 # Settings menu 168 settings_menu = tk.Menu(menubar, tearoff=0) 169 lang_menu = tk.Menu(settings_menu, tearoff=0) 170 for lang in self.lang_codes: 171 lang_menu.add_radiobutton(label=lang, variable=self.current_language, value=lang, command=self.change_language) 172 settings_menu.add_cascade(label=_("Language"), menu=lang_menu) 173 settings_menu.add_separator() 174 settings_menu.add_checkbutton(label=_("Remove successful from json"), variable=self.remove_successful_var) 175 settings_menu.add_checkbutton(label=_("Add back files with failed timeout"), variable=self.add_files_with_failed_timeout_var) 176 menubar.add_cascade(label=_("Settings"), menu=settings_menu) 177 178 # Zdroje menu 179 sources_menu = tk.Menu(menubar, tearoff=0) 180 for i, source in enumerate(SOURCES): 181 sources_menu.add_checkbutton( 182 label=source["name"], 183 variable=self.source_vars[i], 184 onvalue=True, 185 offvalue=False 186 ) 187 menubar.add_cascade(label=_("Sources"), menu=sources_menu) 188 189 190 # Search frame 191 search_frame = ttk.Frame(self) 192 search_frame.pack(pady=5, padx=5, fill=tk.X) 193 194 self.search_label = ttk.Label(search_frame, text=_("Search:")) 195 self.search_label.pack(side=tk.LEFT, padx=5) 196 197 self.search_entry = ttk.Entry(search_frame) 198 self.search_entry.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True) 199 200 # file type: show translated labels but keep internal key 201 # internal vars (hold keys used by search) 202 self.file_type_var = tk.StringVar(value="all") 203 self.search_type_var = tk.StringVar(value="relevance") 204 # display vars (hold translated label shown in OptionMenu) 205 self.file_type_display_var = tk.StringVar(value=_(self.file_type_var.get())) 206 self.search_type_display_var = tk.StringVar(value=_(self.search_type_var.get())) 207 208 # build OptionMenus using display vars; commands set both internal key and display label 209 self.file_type_menu = ttk.OptionMenu(search_frame, self.file_type_display_var, self.file_type_display_var.get()) 210 menu = self.file_type_menu["menu"] 211 menu.delete(0, "end") 212 for key in Download_page_search.file_types.keys(): 213 label = _(key) 214 menu.add_command(label=label, command=lambda k=key, l=label: (self.file_type_var.set(k), self.file_type_display_var.set(l))) 215 self.file_type_menu.pack(side=tk.LEFT, padx=5) 216 217 self.search_type_menu = ttk.OptionMenu(search_frame, self.search_type_display_var, self.search_type_display_var.get()) 218 menu2 = self.search_type_menu["menu"] 219 menu2.delete(0, "end") 220 for key in Download_page_search.search_types.keys(): 221 label = _(key) 222 menu2.add_command(label=label, command=lambda k=key, l=label: (self.search_type_var.set(k), self.search_type_display_var.set(l))) 223 self.search_type_menu.pack(side=tk.LEFT, padx=5) 224 225 self.max_results_label = ttk.Label(search_frame, text=_("Max Results:")) 226 self.max_results_label.pack(side=tk.LEFT, padx=5) 227 228 self.max_results_entry = ttk.Entry(search_frame, width=5) 229 self.max_results_entry.pack(side=tk.LEFT, padx=5) 230 self.max_results_entry.insert(0, "100") 231 232 self.search_button = ttk.Button(search_frame, text=_("Search"), command=self.start_search_thread) 233 self.search_button.pack(side=tk.LEFT, padx=5) 234 235 # Action frame 236 action_frame = ttk.Frame(self) 237 action_frame.pack(pady=5, padx=5, fill=tk.X) 238 239 self.select_all_button = ttk.Button(action_frame, text=_("Select/Deselect All"), command=self.toggle_select_all) 240 self.select_all_button.pack(side=tk.LEFT, padx=5) 241 242 self.clear_button = ttk.Button(action_frame, text=_("Clear All"), command=self.clear_all) 243 self.clear_button.pack(side=tk.LEFT, padx=5) 244 245 self.clear_not_selected_button = ttk.Button(action_frame, text=_("Clear Not Selected"), command=self.clear_not_selected) 246 self.clear_not_selected_button.pack(side=tk.LEFT, padx=5) 247 248 self.download_button = ttk.Button(action_frame, text=_("Download Selected"), command=self.start_download_thread) 249 self.download_button.pack(side=tk.LEFT, padx=5) 250 251 # Results frame 252 self.results_frame = ttk.Frame(self) 253 self.results_frame.pack(pady=10, fill=tk.BOTH, expand=True) 254 255 style = ttk.Style() 256 row_h = int(32 * self.scaling_factor) 257 style.configure("Treeview", rowheight=row_h) 258 default_size = int(10 * self.scaling_factor) 259 style.configure("Treeview", font=('TkDefaultFont', default_size)) 260 style.configure("Treeview.Heading", font=('TkDefaultFont', default_size, 'bold')) 261 262 self.results_tree = ttk.Treeview(self.results_frame, columns=("check", "Title", "Size", "Source"), show="headings") 263 self.results_tree.heading("check", text=_("Select"), command=lambda: self.sort_treeview("check", False)) 264 self.results_tree.heading("Title", text=_("Title"), command=lambda: self.sort_treeview("Title", False)) 265 self.results_tree.heading("Size", text=_("Size"), command=lambda: self.sort_treeview("Size", False)) 266 self.results_tree.heading("Source", text=_("Source")) 267 self.results_tree.column("check", width=10, anchor="center") 268 self.results_tree.column("Title", width=240) 269 self.results_tree.column("Size", width=20) 270 self.results_tree.column("Source", width=140) 271 272 # Scrollbar 273 scrollbar = ttk.Scrollbar(self.results_frame, orient="vertical", command=self.results_tree.yview) 274 self.results_tree.configure(yscrollcommand=scrollbar.set) 275 scrollbar.pack(side=tk.RIGHT, fill=tk.Y) 276 277 self.results_tree.pack(fill=tk.BOTH, expand=True) 278 self.results_tree.bind("<Double-1>", self.toggle_check) 279 # Right-click on Link column copies the download URL to clipboard (Windows: Button-3) 280 self.results_tree.bind("<Button-3>", self.on_right_click_copy_link) 281 282 # Log frame 283 self.log_frame = ttk.Frame(self) 284 self.log_frame.pack(pady=10, fill=tk.BOTH, expand=True) 285 286 self.log_text = tk.Text(self.log_frame, height=10, state=tk.DISABLED) 287 self.log_text.pack(fill=tk.BOTH, expand=True) 288 289 # map item iid (detail_url) -> checked(bool). Keeps state independent of row ordering. 290 self.checked_map = {} 291 292 # Define tags for colored text 293 self.log_text.tag_config("info", foreground="blue") 294 self.log_text.tag_config("warning", foreground="orange") 295 self.log_text.tag_config("error", foreground="red") 296 self.log_text.tag_config("success", foreground="green") 297 298 def get_check_symbol(self, checked): 299 return "✓" if checked else "✗" 300 301 def toggle_check(self, event): 302 sel = self.results_tree.selection() 303 if not sel: 304 return 305 item = sel[0] 306 current = self.checked_map.get(item, False) 307 new = not current 308 self.checked_map[item] = new 309 self.results_tree.item(item, values=(self.get_check_symbol(new), *self.results_tree.item(item)["values"][1:])) 310 311 def toggle_select_all(self): 312 children = list(self.results_tree.get_children()) 313 select_all = not all(self.checked_map.get(item, False) for item in children) 314 for item in children: 315 self.checked_map[item] = select_all 316 self.results_tree.item(item, values=(self.get_check_symbol(select_all), *self.results_tree.item(item)["values"][1:])) 317 318 319 def on_right_click_copy_link(self, event): 320 """ 321 Right-click handler: if clicked cell is in the Source column, copy underlying detail URL to clipboard. 322 """ 323 try: 324 region = self.results_tree.identify_region(event.x, event.y) 325 col = self.results_tree.identify_column(event.x) # e.g. "#4" for 4th column 326 # Source is the 4th column (#4) 327 if region != "cell" or col != "#4": 328 return 329 # identify_row returns the item id (we store detail_url as iid) 330 row = self.results_tree.identify_row(event.y) 331 if not row: 332 return 333 # use iid (row) as the real detail_url 334 link = row 335 # copy to clipboard 336 self.clipboard_clear() 337 self.clipboard_append(link) 338 self.log(_("Link copied to clipboard: {}").format(link), "info") 339 except Exception as e: 340 self.log(_("Failed to copy link: {}").format(e), "error") 341 342 def start_search_thread(self): 343 """ 344 Starts the search in a separate thread for each selected source. 345 Initializes a queue to collect results and starts processing the queue. 346 """ 347 self.searching = True 348 self.result_queue = queue.Queue() 349 self.threads = [] 350 self.link_2_files = [] 351 self.max_results = int(self.max_results_entry.get()) 352 prompt = self.search_entry.get() 353 file_type = self.file_type_var.get() 354 search_type = self.search_type_var.get() 355 selected_sources = [source["class"] for i, source in enumerate(SOURCES) if self.source_vars[i].get()] 356 self.stop_event = threading.Event() 357 358 def search_source(source_class): 359 try: 360 results = source_class().search(prompt, file_type, search_type) 361 for r in results: 362 if self.stop_event.is_set(): 363 break 364 self.result_queue.put(r) 365 except Exception as e: 366 self.log(_("Error in source {}: {}").format(source_class.__name__, e), "error") 367 368 for source_class in selected_sources: 369 t = threading.Thread(target=search_source, args=(source_class,)) 370 t.start() 371 self.threads.append(t) 372 373 self.log(_("Search initiated..."), "info", end="") 374 self.after(100, self.process_search_queue) 375 376 def process_search_queue(self): 377 """ 378 Processes the result queue, adding unique results to the treeview. 379 Stops adding results if max_results is reached and sets the `stop_event`. 380 """ 381 added = 0 382 while not self.result_queue.empty() and (not self.max_results or len(self.link_2_files) < self.max_results): 383 link_2_file = self.result_queue.get() 384 self.add_unique_to_results([link_2_file]) 385 self.link_2_files.append(link_2_file) 386 self.log(".", "info", end="") 387 added += 1 388 389 # Pokud jsme dosáhli max_results, nastav stop_event 390 if self.max_results and len(self.link_2_files) >= self.max_results: 391 self.stop_event.set() 392 393 if (any(t.is_alive() for t in self.threads) or not self.result_queue.empty()) and not self.stop_event.is_set(): 394 self.after(100, self.process_search_queue) 395 elif any(t.is_alive() for t in self.threads) or not self.result_queue.empty(): 396 # Po dosažení max_results ještě necháme doběhnout frontu 397 self.after(100, self.process_search_queue) 398 else: 399 self.log(_("\nNumber of files found: {}").format(len(self.link_2_files)), "success") 400 self.searching = False 401 402 def start_download_thread(self): 403 threading.Thread(target=self.download_selected, daemon=True).start() 404 405 def download_worker(self, q: queue.Queue, timeout: int, success_list: list, success_lock: threading.Lock): 406 """ 407 Worker function to download files from the queue. 408 409 Args: 410 q (queue.Queue): Queue containing Link_to_file objects to download. 411 timeout (int): Timeout between downloads. 412 success_list (list): Shared list to store successfully downloaded files. 413 success_lock (threading.Lock): Lock to synchronize access to success_list. 414 """ 415 while not q.empty(): 416 link_2_file = q.get() 417 418 # test if file exists 419 target_path = f"{download_folder}/{link_2_file.title}" 420 if os.path.exists(target_path): 421 self.log(_("File {} already exists.").format(link_2_file.title), "warning") 422 with success_lock: 423 success_list.append(link_2_file) 424 continue 425 426 self.log(_("Downloading file: {} of size {}...").format(link_2_file.title, link_2_file.size), "info") 427 428 try: 429 link_2_file.download(download_folder) 430 431 file_size = os.path.getsize(target_path) 432 if link_2_file.source_class.test_downloaded_file(link_2_file, download_folder): 433 with success_lock: 434 success_list.append(link_2_file) 435 self.log(_("File {} of size {} was downloaded.").format(link_2_file.title, size_int_2_string(file_size)), "success") 436 self.remove_from_results([link_2_file]) 437 except ValueError as e: 438 self.log(_("Error: {}").format(e), "error") 439 self.log(_("File {} was not downloaded correctly.").format(link_2_file.title), "error") 440 if os.path.exists(target_path): 441 os.remove(target_path) 442 self.log(_("File {} was removed.").format(link_2_file.title), "info") 443 except InsufficientTimeoutError as e: 444 self.log(_("Error: {}").format(e), "error") 445 self.log(_("File {} was not downloaded at all.").format(link_2_file.title), "error") 446 if os.path.exists(target_path): 447 os.remove(target_path) 448 self.log(_("File {} was removed.").format(link_2_file.title), "info") 449 if self.add_files_with_failed_timeout_var.get(): 450 q.put(link_2_file) 451 self.log(_("File {} was added back to the list.").format(link_2_file.title), "info") 452 except Exception as e: 453 self.log(_("Error: {}").format(e), "error") 454 if os.path.exists(target_path): 455 os.remove(target_path) 456 self.log(_("File {} was removed.").format(link_2_file.title), "info") 457 458 if not q.empty(): 459 self.log(_("Waiting for {} seconds...").format(timeout), "info") 460 time.sleep(timeout) 461 462 def download_selected(self): 463 """ 464 Downloads all selected `Link_to_file` objects using worker threads. 465 466 1. loads selected `Link_to_file` objects, 467 2. groups them by `source_class`, 468 3. starts a worker thread for each group to download files with appropriate timeout, 469 4. waits for all threads to complete, 470 5. removes successfully downloaded files from the results and JSON file if configured. 471 472 """ 473 self.log(_("Download initiated..."), "info") 474 475 selected = self.get_selected_link_2_files() 476 if not selected: 477 self.log(_("No files selected for download."), "warning") 478 return 479 480 self.log(_("Number of files to download: {}").format(len(selected)), "info") 481 482 # Seskupit podle source_class 483 groups: dict = {} 484 for l in selected: 485 groups.setdefault(l.source_class, []).append(l) 486 487 threads = [] 488 successfull_files: list = [] 489 success_lock = threading.Lock() 490 491 for source_class, items in groups.items(): 492 q = queue.Queue() 493 for it in items: 494 q.put(it) 495 496 # najít timeout pro daný zdroj 497 timeout = next((s["timeout"] for s in SOURCES if s["class"] == source_class), TIME_OUT) 498 499 t = threading.Thread(target=self.download_worker, args=(q, timeout, successfull_files, success_lock), daemon=True) 500 t.start() 501 threads.append(t) 502 503 # počkat na dokončení všech worker vláken 504 for t in threads: 505 t.join() 506 507 self.log(_("Downloaded files: {}").format(len(successfull_files)), "success") 508 509 if self.remove_successful_var.get(): 510 self.log(_("Removing successful downloads from the list..."), "info") 511 remove_links_from_file(successfull_files, JSON_FILE) 512 513 def result_tree_2_link_2_files(self): 514 """ 515 Yields Link_to_file objects from the results treeview. 516 """ 517 for item in self.results_tree.get_children(): 518 # item is iid == detail_url 519 detail_url = item 520 l2f = self.link_map.get(detail_url) 521 if l2f is not None: 522 yield l2f 523 else: 524 vals = self.results_tree.item(item)["values"] 525 title = vals[1] if len(vals) > 1 else detail_url 526 size = vals[2] if len(vals) > 2 else "unknown" 527 self.log(_("Warning: Link not found in map, creating new Link_to_file object."), "warning") 528 yield Link_to_file(title, detail_url, size, Download_page_search) # Fallback 529 530 def replace_results(self, link_2_files): 531 """ 532 Replaces all items in the results treeview with new Link_to_file objects. 533 And updates the link_map accordingly. 534 """ 535 self.results_tree.delete(*self.results_tree.get_children()) 536 self.checked_map.clear() 537 self.link_map.clear() 538 for i, link_2_file in enumerate(link_2_files): 539 source_name = CLASS_NAME_MAP.get(link_2_file.source_class, getattr(link_2_file.source_class, "__name__", "Unknown")) 540 # store detail_url as iid so we can later retrieve the real link even if we display Source 541 self.results_tree.insert( 542 "", "end", iid=link_2_file.detail_url, 543 values=(self.get_check_symbol(False), link_2_file.title, link_2_file.size, source_name), 544 ) 545 self.checked_map[link_2_file.detail_url] = False 546 self.link_map[link_2_file.detail_url] = link_2_file 547 548 def add_unique_to_results(self, link_2_files): 549 """ 550 Adds only unique Link_to_file objects to the results treeview. 551 And updates the link_map accordingly. 552 """ 553 existing_links = set(self.link_map.keys()) 554 for link_2_file in link_2_files: 555 if link_2_file.detail_url not in existing_links: 556 source_name = CLASS_NAME_MAP.get(link_2_file.source_class, getattr(link_2_file.source_class, "__name__", "Unknown")) 557 self.results_tree.insert( 558 "", "end", iid=link_2_file.detail_url, 559 values=(self.get_check_symbol(False), link_2_file.title, link_2_file.size, source_name), 560 ) 561 self.checked_map[link_2_file.detail_url] = False 562 self.link_map[link_2_file.detail_url] = link_2_file 563 existing_links.add(link_2_file.detail_url) 564 565 def remove_from_results(self, link_2_files): 566 links_to_remove = {l.detail_url for l in link_2_files} 567 for item in list(self.results_tree.get_children()): 568 detail_url = item 569 if detail_url in links_to_remove: 570 self.results_tree.delete(item) 571 self.link_map.pop(detail_url, None) 572 self.checked_map.pop(detail_url, None) 573 574 def get_selected_link_2_files(self) -> list[Link_to_file]: 575 """ 576 Returns a list of Link_to_file objects corresponding to currently checked items in the results treeview. 577 If a selected link is missing from link_map, creates a safe fallback Link_to_file object. 578 """ 579 selected_links = [item for item in self.results_tree.get_children() if self.checked_map.get(item, False)] 580 581 result = [] 582 for link in selected_links: 583 l2f = self.link_map.get(link) 584 if l2f is None: 585 # Fallback: create a minimal Link_to_file object if map is missing entry 586 # Use link as title and unknown size; Download_page_search as a neutral source_class 587 self.log(_("Warning: Selected link {} not found in map, creating fallback object.").format(link), "warning") 588 try: 589 fallback = Link_to_file(link, link, "unknown", Download_page_search) 590 result.append(fallback) 591 except Exception: 592 # If construction fails, skip the entry 593 self.log(_("Failed to create fallback for {}").format(link), "error") 594 else: 595 result.append(l2f) 596 return result 597 598 def save_selected(self): 599 """ 600 Loads selected items from the results treeview. 601 Maps them to Link_to_file objects using link_map. 602 Saves the Link_to_file objects to JSON_FILE. 603 """ 604 self.log(_("Saving selected items..."), "info") 605 606 link_2_files = self.get_selected_link_2_files() 607 save_links_to_file(link_2_files, JSON_FILE) 608 609 self.log(_("Saved items: {}").format(len(link_2_files)), "success") 610 611 def load_from_file(self): 612 self.log(_("Loading selected items..."), "info") 613 link_2_files = load_links_from_file(JSON_FILE) 614 self.replace_results(link_2_files) # automatically updates link_map 615 self.log(_("Loaded items: {}").format(len(link_2_files)), "success") 616 617 def clear_all(self): 618 """ 619 Clears all items from the: 620 - results treeview, 621 - link_map. 622 """ 623 self.results_tree.delete(*self.results_tree.get_children()) 624 self.checked_map.clear() 625 self.link_map.clear() 626 self.log(_("Cleared all displayed files."), "info") 627 628 def clear_not_selected(self): 629 # keep only items that are checked (based on checked_map) 630 items_to_keep = [(self.results_tree.item(item)["values"], item) for item in self.results_tree.get_children() if self.checked_map.get(item, False)] 631 self.results_tree.delete(*self.results_tree.get_children()) 632 self.checked_map.clear() 633 for values, iid in items_to_keep: 634 self.results_tree.insert("", "end", iid=iid, values=values) 635 self.checked_map[iid] = True 636 self.log(_("Cleared not selected files."), "info") 637 638 def log(self, message, tag="info", end="\n"): 639 self.log_text.config(state=tk.NORMAL) 640 self.log_text.insert(tk.END, message + end, tag) 641 self.log_text.config(state=tk.DISABLED) 642 self.log_text.see(tk.END) 643 644 def sort_treeview(self, col, reverse): 645 items = [(self.results_tree.set(k, col), k) for k in self.results_tree.get_children('')] 646 if col == "Size": 647 items.sort(key=lambda t: size_string_2_bytes(t[0]), reverse=reverse) 648 elif col == "check": 649 # sort by checked state stored per-iid 650 items.sort(key=lambda t: self.checked_map.get(t[1], False), reverse=reverse) 651 else: 652 items.sort(reverse=reverse) 653 654 for index, (val, k) in enumerate(items): 655 self.results_tree.move(k, '', index) 656 657 self.results_tree.heading(col, command=lambda: self.sort_treeview(col, not reverse)) 658 659 def change_language(self, *args): 660 self.setup_translation() 661 self.update_ui_texts() 662 self.settings["language"] = self.current_language.get() 663 self.save_config() 664 665 def update_remove_successful(self, *args): 666 self.settings["remove_successful"] = self.remove_successful_var.get() 667 self.save_config() 668 669 def update_add_files_with_failed_timeout(self, *args): 670 self.settings["add_files_with_failed_timeout"] = self.add_files_with_failed_timeout_var.get() 671 self.save_config() 672 673 def update_ui_texts(self): 674 self.title(_("Universal Downloader")) 675 676 self.search_label.config(text=_("Search:")) 677 self.search_button.config(text=_("Search")) 678 self.max_results_label.config(text=_("Max Results:")) 679 680 self.download_button.config(text=_("Download Selected")) 681 self.clear_button.config(text=_("Clear All")) 682 self.clear_not_selected_button.config(text=_("Clear Not Selected")) 683 self.select_all_button.config(text=_("Select/Deselect All")) 684 685 self.results_tree.heading("check", text=_("Select")) 686 self.results_tree.heading("Title", text=_("Title")) 687 self.results_tree.heading("Size", text=_("Size")) 688 self.results_tree.heading("Source", text=_("Source")) 689 self._rebuild_type_menus() 690 self.log(_("Language changed to {}.").format(self.current_language.get()), "info") 691 692 def _rebuild_type_menus(self): 693 # rebuild file type menu 694 try: 695 menu = self.file_type_menu["menu"] 696 menu.delete(0, "end") 697 for key in Download_page_search.file_types.keys(): 698 label = _(key) 699 menu.add_command(label=label, command=lambda k=key, l=label: (self.file_type_var.set(k), self.file_type_display_var.set(l))) 700 # update displayed label to translated value for current key 701 self.file_type_display_var.set(_(self.file_type_var.get())) 702 except Exception: 703 print_error("Error rebuilding {Blue}file type{NC} menus.") 704 pass 705 706 # rebuild search type menu 707 try: 708 menu2 = self.search_type_menu["menu"] 709 menu2.delete(0, "end") 710 for key in Download_page_search.search_types.keys(): 711 label = _(key) 712 menu2.add_command(label=label, command=lambda k=key, l=label: (self.search_type_var.set(k), self.search_type_display_var.set(l))) 713 self.search_type_display_var.set(_(self.search_type_var.get())) 714 except Exception: 715 print_error("Error rebuilding {Blue}search type{NC} menus.") 716 pass 717 718def main(): 719 if not os.path.exists(JSON_FILE): 720 open(JSON_FILE, 'w').close() 721 print_info(f"Created empty JSON file at {JSON_FILE}") 722 723 if not os.path.exists(download_folder): 724 os.makedirs(download_folder) 725 print_info(f"Created download folder at {download_folder}") 726 727 compile_mo_files() 728 localedir = get_resource_path("locales") 729 if not os.path.exists(os.path.join(localedir, "cs", "LC_MESSAGES", DOMAIN + ".mo")): 730 print_error("Translation file not found!") 731 732 app = DownloaderGUI() 733 app.mainloop() 734 735if __name__ == "__main__": 736 parser = argparse.ArgumentParser(description="Download files from internet.") 737 parser.add_argument("-v", "--verbose", action="store_true", help="Verbose mode.") 738 parser.add_argument("-D", "--debug", action="store_true", help="Debug mode.") 739 args = parser.parse_args() 740 741 main()
47def get_resource_path(relative_path): 48 """ 49 Get absolute path to resource, works for dev and for PyInstaller 50 """ 51 if hasattr(sys, "_MEIPASS"): 52 # Cesta k dočasné složce při spuštění .exe 53 return os.path.join(sys._MEIPASS, relative_path) 54 return os.path.join(os.path.abspath("."), relative_path)
Get absolute path to resource, works for dev and for PyInstaller
56def compile_mo_files(): 57 """ 58 Compiles .po files to .mo files using msgfmt. 59 If msgfmt is not found, skips compilation (Typically on target machine with .exe). 60 """ 61 localedir = get_resource_path("locales") 62 msgfmt_path = shutil.which('msgfmt') 63 if not msgfmt_path: 64 print("msgfmt not found in PATH — skipping .po -> .mo compilation. Ensure .mo files are included in the build.") 65 return 66 67 for lang in LANGUAGES.values(): 68 po_file = os.path.join(localedir, lang, 'LC_MESSAGES', DOMAIN + '.po') 69 mo_file = os.path.join(localedir, lang, 'LC_MESSAGES', DOMAIN + '.mo') 70 if os.path.exists(po_file): 71 if not os.path.exists(mo_file) or os.path.getmtime(po_file) > os.path.getmtime(mo_file): 72 print(f"Compiling {po_file} to {mo_file}") 73 try: 74 subprocess.run([msgfmt_path, '-o', mo_file, po_file], check=True) 75 except subprocess.CalledProcessError as e: 76 print(f"msgfmt failed: {e}")
Compiles .po files to .mo files using msgfmt. If msgfmt is not found, skips compilation (Typically on target machine with .exe).
78class DownloaderGUI(tk.Tk): 79 lang_codes = LANGUAGES.values() 80 81 def __init__(self): 82 super().__init__() 83 84 dpi = self.winfo_fpixels('1i') 85 self.scaling_factor = dpi / 96.0 86 87 # Nastavení škálování pro Tkinter engine 88 self.tk.call('tk', 'scaling', self.scaling_factor) 89 # --------------------------- 90 91 # Dynamický výpočet geometrie místo hardcodovaného "1300x800" 92 width = int(GEOMETRY[0] * self.scaling_factor) 93 height = int(GEOMETRY[1] * self.scaling_factor) 94 95 self.geometry(f"{width}x{height}") 96 97 icon_path = get_resource_path(os.path.join(ASSETS_DIR, "icon.png")) 98 if os.path.isfile(icon_path): 99 icon = tk.PhotoImage(file=icon_path) 100 self.iconphoto(True, icon) 101 else: 102 print(f"Icon: '{icon_path}' not found.") 103 104 self.settings = self.load_config() 105 self.current_language = tk.StringVar(value=self.settings.get("language", DEFAULT_LANGUAGE)) 106 self.remove_successful_var = tk.BooleanVar(value=self.settings.get("remove_successful", False)) 107 self.remove_successful_var.trace_add("write", self.update_remove_successful) 108 self.add_files_with_failed_timeout_var = tk.BooleanVar(value=self.settings.get("add_files_with_failed_timeout", False)) 109 self.add_files_with_failed_timeout_var.trace_add("write", self.update_add_files_with_failed_timeout) 110 111 self.source_vars = [] 112 for source in SOURCES: 113 var = tk.BooleanVar(value=self.settings.get(source["name"], True)) 114 # Save changes to config on change 115 var.trace_add("write", lambda *args, name=source["name"], var=var: self.settings.update({name: var.get()}) or self.save_config()) 116 self.source_vars.append(var) 117 118 self.link_map = {} # detail_url -> Link_to_file (mapping with result treeview) 119 120 self.setup_translation() 121 self.title(_(APP_NAME)) 122 self.create_widgets() 123 124 def load_config(self, config_file=CONFIG_FILE) -> dict: 125 """ 126 Load configuration from a JSON file. 127 If the file does not exist, returns an empty dictionary. 128 """ 129 try: 130 with open(config_file, "r") as file: 131 return json.load(file) 132 except FileNotFoundError: 133 return {} 134 135 def save_config(self, config_file=CONFIG_FILE): 136 """ 137 Save configuration to a JSON file. 138 """ 139 with open(config_file, "w") as file: 140 json.dump(self.settings, file) 141 142 def setup_translation(self, domain=DOMAIN): 143 lang_code = self.current_language.get() 144 global _ 145 localedir = get_resource_path("locales") 146 try: 147 lang = gettext.translation(domain, localedir=localedir, languages=[lang_code]) 148 lang.install() 149 if DEBUG: 150 print_success(f"Translation loaded for {lang_code}.") 151 _ = lang.gettext 152 except Exception as e: 153 print_error(f"Translation not found for {lang_code}, falling back to default. Error: {e}") 154 gettext.install(domain, localedir=localedir) 155 _ = gettext.gettext 156 157 def create_widgets(self): 158 # Menu bar 159 menubar = tk.Menu(self) 160 self.config(menu=menubar) 161 162 # File menu 163 file_menu = tk.Menu(menubar, tearoff=0) 164 file_menu.add_command(label=_("Save Selected"), command=self.save_selected) 165 file_menu.add_command(label=_("Load from file"), command=self.load_from_file) 166 menubar.add_cascade(label=_("File"), menu=file_menu) 167 168 # Settings menu 169 settings_menu = tk.Menu(menubar, tearoff=0) 170 lang_menu = tk.Menu(settings_menu, tearoff=0) 171 for lang in self.lang_codes: 172 lang_menu.add_radiobutton(label=lang, variable=self.current_language, value=lang, command=self.change_language) 173 settings_menu.add_cascade(label=_("Language"), menu=lang_menu) 174 settings_menu.add_separator() 175 settings_menu.add_checkbutton(label=_("Remove successful from json"), variable=self.remove_successful_var) 176 settings_menu.add_checkbutton(label=_("Add back files with failed timeout"), variable=self.add_files_with_failed_timeout_var) 177 menubar.add_cascade(label=_("Settings"), menu=settings_menu) 178 179 # Zdroje menu 180 sources_menu = tk.Menu(menubar, tearoff=0) 181 for i, source in enumerate(SOURCES): 182 sources_menu.add_checkbutton( 183 label=source["name"], 184 variable=self.source_vars[i], 185 onvalue=True, 186 offvalue=False 187 ) 188 menubar.add_cascade(label=_("Sources"), menu=sources_menu) 189 190 191 # Search frame 192 search_frame = ttk.Frame(self) 193 search_frame.pack(pady=5, padx=5, fill=tk.X) 194 195 self.search_label = ttk.Label(search_frame, text=_("Search:")) 196 self.search_label.pack(side=tk.LEFT, padx=5) 197 198 self.search_entry = ttk.Entry(search_frame) 199 self.search_entry.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True) 200 201 # file type: show translated labels but keep internal key 202 # internal vars (hold keys used by search) 203 self.file_type_var = tk.StringVar(value="all") 204 self.search_type_var = tk.StringVar(value="relevance") 205 # display vars (hold translated label shown in OptionMenu) 206 self.file_type_display_var = tk.StringVar(value=_(self.file_type_var.get())) 207 self.search_type_display_var = tk.StringVar(value=_(self.search_type_var.get())) 208 209 # build OptionMenus using display vars; commands set both internal key and display label 210 self.file_type_menu = ttk.OptionMenu(search_frame, self.file_type_display_var, self.file_type_display_var.get()) 211 menu = self.file_type_menu["menu"] 212 menu.delete(0, "end") 213 for key in Download_page_search.file_types.keys(): 214 label = _(key) 215 menu.add_command(label=label, command=lambda k=key, l=label: (self.file_type_var.set(k), self.file_type_display_var.set(l))) 216 self.file_type_menu.pack(side=tk.LEFT, padx=5) 217 218 self.search_type_menu = ttk.OptionMenu(search_frame, self.search_type_display_var, self.search_type_display_var.get()) 219 menu2 = self.search_type_menu["menu"] 220 menu2.delete(0, "end") 221 for key in Download_page_search.search_types.keys(): 222 label = _(key) 223 menu2.add_command(label=label, command=lambda k=key, l=label: (self.search_type_var.set(k), self.search_type_display_var.set(l))) 224 self.search_type_menu.pack(side=tk.LEFT, padx=5) 225 226 self.max_results_label = ttk.Label(search_frame, text=_("Max Results:")) 227 self.max_results_label.pack(side=tk.LEFT, padx=5) 228 229 self.max_results_entry = ttk.Entry(search_frame, width=5) 230 self.max_results_entry.pack(side=tk.LEFT, padx=5) 231 self.max_results_entry.insert(0, "100") 232 233 self.search_button = ttk.Button(search_frame, text=_("Search"), command=self.start_search_thread) 234 self.search_button.pack(side=tk.LEFT, padx=5) 235 236 # Action frame 237 action_frame = ttk.Frame(self) 238 action_frame.pack(pady=5, padx=5, fill=tk.X) 239 240 self.select_all_button = ttk.Button(action_frame, text=_("Select/Deselect All"), command=self.toggle_select_all) 241 self.select_all_button.pack(side=tk.LEFT, padx=5) 242 243 self.clear_button = ttk.Button(action_frame, text=_("Clear All"), command=self.clear_all) 244 self.clear_button.pack(side=tk.LEFT, padx=5) 245 246 self.clear_not_selected_button = ttk.Button(action_frame, text=_("Clear Not Selected"), command=self.clear_not_selected) 247 self.clear_not_selected_button.pack(side=tk.LEFT, padx=5) 248 249 self.download_button = ttk.Button(action_frame, text=_("Download Selected"), command=self.start_download_thread) 250 self.download_button.pack(side=tk.LEFT, padx=5) 251 252 # Results frame 253 self.results_frame = ttk.Frame(self) 254 self.results_frame.pack(pady=10, fill=tk.BOTH, expand=True) 255 256 style = ttk.Style() 257 row_h = int(32 * self.scaling_factor) 258 style.configure("Treeview", rowheight=row_h) 259 default_size = int(10 * self.scaling_factor) 260 style.configure("Treeview", font=('TkDefaultFont', default_size)) 261 style.configure("Treeview.Heading", font=('TkDefaultFont', default_size, 'bold')) 262 263 self.results_tree = ttk.Treeview(self.results_frame, columns=("check", "Title", "Size", "Source"), show="headings") 264 self.results_tree.heading("check", text=_("Select"), command=lambda: self.sort_treeview("check", False)) 265 self.results_tree.heading("Title", text=_("Title"), command=lambda: self.sort_treeview("Title", False)) 266 self.results_tree.heading("Size", text=_("Size"), command=lambda: self.sort_treeview("Size", False)) 267 self.results_tree.heading("Source", text=_("Source")) 268 self.results_tree.column("check", width=10, anchor="center") 269 self.results_tree.column("Title", width=240) 270 self.results_tree.column("Size", width=20) 271 self.results_tree.column("Source", width=140) 272 273 # Scrollbar 274 scrollbar = ttk.Scrollbar(self.results_frame, orient="vertical", command=self.results_tree.yview) 275 self.results_tree.configure(yscrollcommand=scrollbar.set) 276 scrollbar.pack(side=tk.RIGHT, fill=tk.Y) 277 278 self.results_tree.pack(fill=tk.BOTH, expand=True) 279 self.results_tree.bind("<Double-1>", self.toggle_check) 280 # Right-click on Link column copies the download URL to clipboard (Windows: Button-3) 281 self.results_tree.bind("<Button-3>", self.on_right_click_copy_link) 282 283 # Log frame 284 self.log_frame = ttk.Frame(self) 285 self.log_frame.pack(pady=10, fill=tk.BOTH, expand=True) 286 287 self.log_text = tk.Text(self.log_frame, height=10, state=tk.DISABLED) 288 self.log_text.pack(fill=tk.BOTH, expand=True) 289 290 # map item iid (detail_url) -> checked(bool). Keeps state independent of row ordering. 291 self.checked_map = {} 292 293 # Define tags for colored text 294 self.log_text.tag_config("info", foreground="blue") 295 self.log_text.tag_config("warning", foreground="orange") 296 self.log_text.tag_config("error", foreground="red") 297 self.log_text.tag_config("success", foreground="green") 298 299 def get_check_symbol(self, checked): 300 return "✓" if checked else "✗" 301 302 def toggle_check(self, event): 303 sel = self.results_tree.selection() 304 if not sel: 305 return 306 item = sel[0] 307 current = self.checked_map.get(item, False) 308 new = not current 309 self.checked_map[item] = new 310 self.results_tree.item(item, values=(self.get_check_symbol(new), *self.results_tree.item(item)["values"][1:])) 311 312 def toggle_select_all(self): 313 children = list(self.results_tree.get_children()) 314 select_all = not all(self.checked_map.get(item, False) for item in children) 315 for item in children: 316 self.checked_map[item] = select_all 317 self.results_tree.item(item, values=(self.get_check_symbol(select_all), *self.results_tree.item(item)["values"][1:])) 318 319 320 def on_right_click_copy_link(self, event): 321 """ 322 Right-click handler: if clicked cell is in the Source column, copy underlying detail URL to clipboard. 323 """ 324 try: 325 region = self.results_tree.identify_region(event.x, event.y) 326 col = self.results_tree.identify_column(event.x) # e.g. "#4" for 4th column 327 # Source is the 4th column (#4) 328 if region != "cell" or col != "#4": 329 return 330 # identify_row returns the item id (we store detail_url as iid) 331 row = self.results_tree.identify_row(event.y) 332 if not row: 333 return 334 # use iid (row) as the real detail_url 335 link = row 336 # copy to clipboard 337 self.clipboard_clear() 338 self.clipboard_append(link) 339 self.log(_("Link copied to clipboard: {}").format(link), "info") 340 except Exception as e: 341 self.log(_("Failed to copy link: {}").format(e), "error") 342 343 def start_search_thread(self): 344 """ 345 Starts the search in a separate thread for each selected source. 346 Initializes a queue to collect results and starts processing the queue. 347 """ 348 self.searching = True 349 self.result_queue = queue.Queue() 350 self.threads = [] 351 self.link_2_files = [] 352 self.max_results = int(self.max_results_entry.get()) 353 prompt = self.search_entry.get() 354 file_type = self.file_type_var.get() 355 search_type = self.search_type_var.get() 356 selected_sources = [source["class"] for i, source in enumerate(SOURCES) if self.source_vars[i].get()] 357 self.stop_event = threading.Event() 358 359 def search_source(source_class): 360 try: 361 results = source_class().search(prompt, file_type, search_type) 362 for r in results: 363 if self.stop_event.is_set(): 364 break 365 self.result_queue.put(r) 366 except Exception as e: 367 self.log(_("Error in source {}: {}").format(source_class.__name__, e), "error") 368 369 for source_class in selected_sources: 370 t = threading.Thread(target=search_source, args=(source_class,)) 371 t.start() 372 self.threads.append(t) 373 374 self.log(_("Search initiated..."), "info", end="") 375 self.after(100, self.process_search_queue) 376 377 def process_search_queue(self): 378 """ 379 Processes the result queue, adding unique results to the treeview. 380 Stops adding results if max_results is reached and sets the `stop_event`. 381 """ 382 added = 0 383 while not self.result_queue.empty() and (not self.max_results or len(self.link_2_files) < self.max_results): 384 link_2_file = self.result_queue.get() 385 self.add_unique_to_results([link_2_file]) 386 self.link_2_files.append(link_2_file) 387 self.log(".", "info", end="") 388 added += 1 389 390 # Pokud jsme dosáhli max_results, nastav stop_event 391 if self.max_results and len(self.link_2_files) >= self.max_results: 392 self.stop_event.set() 393 394 if (any(t.is_alive() for t in self.threads) or not self.result_queue.empty()) and not self.stop_event.is_set(): 395 self.after(100, self.process_search_queue) 396 elif any(t.is_alive() for t in self.threads) or not self.result_queue.empty(): 397 # Po dosažení max_results ještě necháme doběhnout frontu 398 self.after(100, self.process_search_queue) 399 else: 400 self.log(_("\nNumber of files found: {}").format(len(self.link_2_files)), "success") 401 self.searching = False 402 403 def start_download_thread(self): 404 threading.Thread(target=self.download_selected, daemon=True).start() 405 406 def download_worker(self, q: queue.Queue, timeout: int, success_list: list, success_lock: threading.Lock): 407 """ 408 Worker function to download files from the queue. 409 410 Args: 411 q (queue.Queue): Queue containing Link_to_file objects to download. 412 timeout (int): Timeout between downloads. 413 success_list (list): Shared list to store successfully downloaded files. 414 success_lock (threading.Lock): Lock to synchronize access to success_list. 415 """ 416 while not q.empty(): 417 link_2_file = q.get() 418 419 # test if file exists 420 target_path = f"{download_folder}/{link_2_file.title}" 421 if os.path.exists(target_path): 422 self.log(_("File {} already exists.").format(link_2_file.title), "warning") 423 with success_lock: 424 success_list.append(link_2_file) 425 continue 426 427 self.log(_("Downloading file: {} of size {}...").format(link_2_file.title, link_2_file.size), "info") 428 429 try: 430 link_2_file.download(download_folder) 431 432 file_size = os.path.getsize(target_path) 433 if link_2_file.source_class.test_downloaded_file(link_2_file, download_folder): 434 with success_lock: 435 success_list.append(link_2_file) 436 self.log(_("File {} of size {} was downloaded.").format(link_2_file.title, size_int_2_string(file_size)), "success") 437 self.remove_from_results([link_2_file]) 438 except ValueError as e: 439 self.log(_("Error: {}").format(e), "error") 440 self.log(_("File {} was not downloaded correctly.").format(link_2_file.title), "error") 441 if os.path.exists(target_path): 442 os.remove(target_path) 443 self.log(_("File {} was removed.").format(link_2_file.title), "info") 444 except InsufficientTimeoutError as e: 445 self.log(_("Error: {}").format(e), "error") 446 self.log(_("File {} was not downloaded at all.").format(link_2_file.title), "error") 447 if os.path.exists(target_path): 448 os.remove(target_path) 449 self.log(_("File {} was removed.").format(link_2_file.title), "info") 450 if self.add_files_with_failed_timeout_var.get(): 451 q.put(link_2_file) 452 self.log(_("File {} was added back to the list.").format(link_2_file.title), "info") 453 except Exception as e: 454 self.log(_("Error: {}").format(e), "error") 455 if os.path.exists(target_path): 456 os.remove(target_path) 457 self.log(_("File {} was removed.").format(link_2_file.title), "info") 458 459 if not q.empty(): 460 self.log(_("Waiting for {} seconds...").format(timeout), "info") 461 time.sleep(timeout) 462 463 def download_selected(self): 464 """ 465 Downloads all selected `Link_to_file` objects using worker threads. 466 467 1. loads selected `Link_to_file` objects, 468 2. groups them by `source_class`, 469 3. starts a worker thread for each group to download files with appropriate timeout, 470 4. waits for all threads to complete, 471 5. removes successfully downloaded files from the results and JSON file if configured. 472 473 """ 474 self.log(_("Download initiated..."), "info") 475 476 selected = self.get_selected_link_2_files() 477 if not selected: 478 self.log(_("No files selected for download."), "warning") 479 return 480 481 self.log(_("Number of files to download: {}").format(len(selected)), "info") 482 483 # Seskupit podle source_class 484 groups: dict = {} 485 for l in selected: 486 groups.setdefault(l.source_class, []).append(l) 487 488 threads = [] 489 successfull_files: list = [] 490 success_lock = threading.Lock() 491 492 for source_class, items in groups.items(): 493 q = queue.Queue() 494 for it in items: 495 q.put(it) 496 497 # najít timeout pro daný zdroj 498 timeout = next((s["timeout"] for s in SOURCES if s["class"] == source_class), TIME_OUT) 499 500 t = threading.Thread(target=self.download_worker, args=(q, timeout, successfull_files, success_lock), daemon=True) 501 t.start() 502 threads.append(t) 503 504 # počkat na dokončení všech worker vláken 505 for t in threads: 506 t.join() 507 508 self.log(_("Downloaded files: {}").format(len(successfull_files)), "success") 509 510 if self.remove_successful_var.get(): 511 self.log(_("Removing successful downloads from the list..."), "info") 512 remove_links_from_file(successfull_files, JSON_FILE) 513 514 def result_tree_2_link_2_files(self): 515 """ 516 Yields Link_to_file objects from the results treeview. 517 """ 518 for item in self.results_tree.get_children(): 519 # item is iid == detail_url 520 detail_url = item 521 l2f = self.link_map.get(detail_url) 522 if l2f is not None: 523 yield l2f 524 else: 525 vals = self.results_tree.item(item)["values"] 526 title = vals[1] if len(vals) > 1 else detail_url 527 size = vals[2] if len(vals) > 2 else "unknown" 528 self.log(_("Warning: Link not found in map, creating new Link_to_file object."), "warning") 529 yield Link_to_file(title, detail_url, size, Download_page_search) # Fallback 530 531 def replace_results(self, link_2_files): 532 """ 533 Replaces all items in the results treeview with new Link_to_file objects. 534 And updates the link_map accordingly. 535 """ 536 self.results_tree.delete(*self.results_tree.get_children()) 537 self.checked_map.clear() 538 self.link_map.clear() 539 for i, link_2_file in enumerate(link_2_files): 540 source_name = CLASS_NAME_MAP.get(link_2_file.source_class, getattr(link_2_file.source_class, "__name__", "Unknown")) 541 # store detail_url as iid so we can later retrieve the real link even if we display Source 542 self.results_tree.insert( 543 "", "end", iid=link_2_file.detail_url, 544 values=(self.get_check_symbol(False), link_2_file.title, link_2_file.size, source_name), 545 ) 546 self.checked_map[link_2_file.detail_url] = False 547 self.link_map[link_2_file.detail_url] = link_2_file 548 549 def add_unique_to_results(self, link_2_files): 550 """ 551 Adds only unique Link_to_file objects to the results treeview. 552 And updates the link_map accordingly. 553 """ 554 existing_links = set(self.link_map.keys()) 555 for link_2_file in link_2_files: 556 if link_2_file.detail_url not in existing_links: 557 source_name = CLASS_NAME_MAP.get(link_2_file.source_class, getattr(link_2_file.source_class, "__name__", "Unknown")) 558 self.results_tree.insert( 559 "", "end", iid=link_2_file.detail_url, 560 values=(self.get_check_symbol(False), link_2_file.title, link_2_file.size, source_name), 561 ) 562 self.checked_map[link_2_file.detail_url] = False 563 self.link_map[link_2_file.detail_url] = link_2_file 564 existing_links.add(link_2_file.detail_url) 565 566 def remove_from_results(self, link_2_files): 567 links_to_remove = {l.detail_url for l in link_2_files} 568 for item in list(self.results_tree.get_children()): 569 detail_url = item 570 if detail_url in links_to_remove: 571 self.results_tree.delete(item) 572 self.link_map.pop(detail_url, None) 573 self.checked_map.pop(detail_url, None) 574 575 def get_selected_link_2_files(self) -> list[Link_to_file]: 576 """ 577 Returns a list of Link_to_file objects corresponding to currently checked items in the results treeview. 578 If a selected link is missing from link_map, creates a safe fallback Link_to_file object. 579 """ 580 selected_links = [item for item in self.results_tree.get_children() if self.checked_map.get(item, False)] 581 582 result = [] 583 for link in selected_links: 584 l2f = self.link_map.get(link) 585 if l2f is None: 586 # Fallback: create a minimal Link_to_file object if map is missing entry 587 # Use link as title and unknown size; Download_page_search as a neutral source_class 588 self.log(_("Warning: Selected link {} not found in map, creating fallback object.").format(link), "warning") 589 try: 590 fallback = Link_to_file(link, link, "unknown", Download_page_search) 591 result.append(fallback) 592 except Exception: 593 # If construction fails, skip the entry 594 self.log(_("Failed to create fallback for {}").format(link), "error") 595 else: 596 result.append(l2f) 597 return result 598 599 def save_selected(self): 600 """ 601 Loads selected items from the results treeview. 602 Maps them to Link_to_file objects using link_map. 603 Saves the Link_to_file objects to JSON_FILE. 604 """ 605 self.log(_("Saving selected items..."), "info") 606 607 link_2_files = self.get_selected_link_2_files() 608 save_links_to_file(link_2_files, JSON_FILE) 609 610 self.log(_("Saved items: {}").format(len(link_2_files)), "success") 611 612 def load_from_file(self): 613 self.log(_("Loading selected items..."), "info") 614 link_2_files = load_links_from_file(JSON_FILE) 615 self.replace_results(link_2_files) # automatically updates link_map 616 self.log(_("Loaded items: {}").format(len(link_2_files)), "success") 617 618 def clear_all(self): 619 """ 620 Clears all items from the: 621 - results treeview, 622 - link_map. 623 """ 624 self.results_tree.delete(*self.results_tree.get_children()) 625 self.checked_map.clear() 626 self.link_map.clear() 627 self.log(_("Cleared all displayed files."), "info") 628 629 def clear_not_selected(self): 630 # keep only items that are checked (based on checked_map) 631 items_to_keep = [(self.results_tree.item(item)["values"], item) for item in self.results_tree.get_children() if self.checked_map.get(item, False)] 632 self.results_tree.delete(*self.results_tree.get_children()) 633 self.checked_map.clear() 634 for values, iid in items_to_keep: 635 self.results_tree.insert("", "end", iid=iid, values=values) 636 self.checked_map[iid] = True 637 self.log(_("Cleared not selected files."), "info") 638 639 def log(self, message, tag="info", end="\n"): 640 self.log_text.config(state=tk.NORMAL) 641 self.log_text.insert(tk.END, message + end, tag) 642 self.log_text.config(state=tk.DISABLED) 643 self.log_text.see(tk.END) 644 645 def sort_treeview(self, col, reverse): 646 items = [(self.results_tree.set(k, col), k) for k in self.results_tree.get_children('')] 647 if col == "Size": 648 items.sort(key=lambda t: size_string_2_bytes(t[0]), reverse=reverse) 649 elif col == "check": 650 # sort by checked state stored per-iid 651 items.sort(key=lambda t: self.checked_map.get(t[1], False), reverse=reverse) 652 else: 653 items.sort(reverse=reverse) 654 655 for index, (val, k) in enumerate(items): 656 self.results_tree.move(k, '', index) 657 658 self.results_tree.heading(col, command=lambda: self.sort_treeview(col, not reverse)) 659 660 def change_language(self, *args): 661 self.setup_translation() 662 self.update_ui_texts() 663 self.settings["language"] = self.current_language.get() 664 self.save_config() 665 666 def update_remove_successful(self, *args): 667 self.settings["remove_successful"] = self.remove_successful_var.get() 668 self.save_config() 669 670 def update_add_files_with_failed_timeout(self, *args): 671 self.settings["add_files_with_failed_timeout"] = self.add_files_with_failed_timeout_var.get() 672 self.save_config() 673 674 def update_ui_texts(self): 675 self.title(_("Universal Downloader")) 676 677 self.search_label.config(text=_("Search:")) 678 self.search_button.config(text=_("Search")) 679 self.max_results_label.config(text=_("Max Results:")) 680 681 self.download_button.config(text=_("Download Selected")) 682 self.clear_button.config(text=_("Clear All")) 683 self.clear_not_selected_button.config(text=_("Clear Not Selected")) 684 self.select_all_button.config(text=_("Select/Deselect All")) 685 686 self.results_tree.heading("check", text=_("Select")) 687 self.results_tree.heading("Title", text=_("Title")) 688 self.results_tree.heading("Size", text=_("Size")) 689 self.results_tree.heading("Source", text=_("Source")) 690 self._rebuild_type_menus() 691 self.log(_("Language changed to {}.").format(self.current_language.get()), "info") 692 693 def _rebuild_type_menus(self): 694 # rebuild file type menu 695 try: 696 menu = self.file_type_menu["menu"] 697 menu.delete(0, "end") 698 for key in Download_page_search.file_types.keys(): 699 label = _(key) 700 menu.add_command(label=label, command=lambda k=key, l=label: (self.file_type_var.set(k), self.file_type_display_var.set(l))) 701 # update displayed label to translated value for current key 702 self.file_type_display_var.set(_(self.file_type_var.get())) 703 except Exception: 704 print_error("Error rebuilding {Blue}file type{NC} menus.") 705 pass 706 707 # rebuild search type menu 708 try: 709 menu2 = self.search_type_menu["menu"] 710 menu2.delete(0, "end") 711 for key in Download_page_search.search_types.keys(): 712 label = _(key) 713 menu2.add_command(label=label, command=lambda k=key, l=label: (self.search_type_var.set(k), self.search_type_display_var.set(l))) 714 self.search_type_display_var.set(_(self.search_type_var.get())) 715 except Exception: 716 print_error("Error rebuilding {Blue}search type{NC} menus.") 717 pass
Toplevel widget of Tk which represents mostly the main window of an application. It has an associated Tcl interpreter.
81 def __init__(self): 82 super().__init__() 83 84 dpi = self.winfo_fpixels('1i') 85 self.scaling_factor = dpi / 96.0 86 87 # Nastavení škálování pro Tkinter engine 88 self.tk.call('tk', 'scaling', self.scaling_factor) 89 # --------------------------- 90 91 # Dynamický výpočet geometrie místo hardcodovaného "1300x800" 92 width = int(GEOMETRY[0] * self.scaling_factor) 93 height = int(GEOMETRY[1] * self.scaling_factor) 94 95 self.geometry(f"{width}x{height}") 96 97 icon_path = get_resource_path(os.path.join(ASSETS_DIR, "icon.png")) 98 if os.path.isfile(icon_path): 99 icon = tk.PhotoImage(file=icon_path) 100 self.iconphoto(True, icon) 101 else: 102 print(f"Icon: '{icon_path}' not found.") 103 104 self.settings = self.load_config() 105 self.current_language = tk.StringVar(value=self.settings.get("language", DEFAULT_LANGUAGE)) 106 self.remove_successful_var = tk.BooleanVar(value=self.settings.get("remove_successful", False)) 107 self.remove_successful_var.trace_add("write", self.update_remove_successful) 108 self.add_files_with_failed_timeout_var = tk.BooleanVar(value=self.settings.get("add_files_with_failed_timeout", False)) 109 self.add_files_with_failed_timeout_var.trace_add("write", self.update_add_files_with_failed_timeout) 110 111 self.source_vars = [] 112 for source in SOURCES: 113 var = tk.BooleanVar(value=self.settings.get(source["name"], True)) 114 # Save changes to config on change 115 var.trace_add("write", lambda *args, name=source["name"], var=var: self.settings.update({name: var.get()}) or self.save_config()) 116 self.source_vars.append(var) 117 118 self.link_map = {} # detail_url -> Link_to_file (mapping with result treeview) 119 120 self.setup_translation() 121 self.title(_(APP_NAME)) 122 self.create_widgets()
Return a new top level widget on screen SCREENNAME. A new Tcl interpreter will be created. BASENAME will be used for the identification of the profile file (see readprofile). It is constructed from sys.argv[0] without extensions if None is given. CLASSNAME is the name of the widget class.
124 def load_config(self, config_file=CONFIG_FILE) -> dict: 125 """ 126 Load configuration from a JSON file. 127 If the file does not exist, returns an empty dictionary. 128 """ 129 try: 130 with open(config_file, "r") as file: 131 return json.load(file) 132 except FileNotFoundError: 133 return {}
Load configuration from a JSON file. If the file does not exist, returns an empty dictionary.
135 def save_config(self, config_file=CONFIG_FILE): 136 """ 137 Save configuration to a JSON file. 138 """ 139 with open(config_file, "w") as file: 140 json.dump(self.settings, file)
Save configuration to a JSON file.
142 def setup_translation(self, domain=DOMAIN): 143 lang_code = self.current_language.get() 144 global _ 145 localedir = get_resource_path("locales") 146 try: 147 lang = gettext.translation(domain, localedir=localedir, languages=[lang_code]) 148 lang.install() 149 if DEBUG: 150 print_success(f"Translation loaded for {lang_code}.") 151 _ = lang.gettext 152 except Exception as e: 153 print_error(f"Translation not found for {lang_code}, falling back to default. Error: {e}") 154 gettext.install(domain, localedir=localedir) 155 _ = gettext.gettext
157 def create_widgets(self): 158 # Menu bar 159 menubar = tk.Menu(self) 160 self.config(menu=menubar) 161 162 # File menu 163 file_menu = tk.Menu(menubar, tearoff=0) 164 file_menu.add_command(label=_("Save Selected"), command=self.save_selected) 165 file_menu.add_command(label=_("Load from file"), command=self.load_from_file) 166 menubar.add_cascade(label=_("File"), menu=file_menu) 167 168 # Settings menu 169 settings_menu = tk.Menu(menubar, tearoff=0) 170 lang_menu = tk.Menu(settings_menu, tearoff=0) 171 for lang in self.lang_codes: 172 lang_menu.add_radiobutton(label=lang, variable=self.current_language, value=lang, command=self.change_language) 173 settings_menu.add_cascade(label=_("Language"), menu=lang_menu) 174 settings_menu.add_separator() 175 settings_menu.add_checkbutton(label=_("Remove successful from json"), variable=self.remove_successful_var) 176 settings_menu.add_checkbutton(label=_("Add back files with failed timeout"), variable=self.add_files_with_failed_timeout_var) 177 menubar.add_cascade(label=_("Settings"), menu=settings_menu) 178 179 # Zdroje menu 180 sources_menu = tk.Menu(menubar, tearoff=0) 181 for i, source in enumerate(SOURCES): 182 sources_menu.add_checkbutton( 183 label=source["name"], 184 variable=self.source_vars[i], 185 onvalue=True, 186 offvalue=False 187 ) 188 menubar.add_cascade(label=_("Sources"), menu=sources_menu) 189 190 191 # Search frame 192 search_frame = ttk.Frame(self) 193 search_frame.pack(pady=5, padx=5, fill=tk.X) 194 195 self.search_label = ttk.Label(search_frame, text=_("Search:")) 196 self.search_label.pack(side=tk.LEFT, padx=5) 197 198 self.search_entry = ttk.Entry(search_frame) 199 self.search_entry.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True) 200 201 # file type: show translated labels but keep internal key 202 # internal vars (hold keys used by search) 203 self.file_type_var = tk.StringVar(value="all") 204 self.search_type_var = tk.StringVar(value="relevance") 205 # display vars (hold translated label shown in OptionMenu) 206 self.file_type_display_var = tk.StringVar(value=_(self.file_type_var.get())) 207 self.search_type_display_var = tk.StringVar(value=_(self.search_type_var.get())) 208 209 # build OptionMenus using display vars; commands set both internal key and display label 210 self.file_type_menu = ttk.OptionMenu(search_frame, self.file_type_display_var, self.file_type_display_var.get()) 211 menu = self.file_type_menu["menu"] 212 menu.delete(0, "end") 213 for key in Download_page_search.file_types.keys(): 214 label = _(key) 215 menu.add_command(label=label, command=lambda k=key, l=label: (self.file_type_var.set(k), self.file_type_display_var.set(l))) 216 self.file_type_menu.pack(side=tk.LEFT, padx=5) 217 218 self.search_type_menu = ttk.OptionMenu(search_frame, self.search_type_display_var, self.search_type_display_var.get()) 219 menu2 = self.search_type_menu["menu"] 220 menu2.delete(0, "end") 221 for key in Download_page_search.search_types.keys(): 222 label = _(key) 223 menu2.add_command(label=label, command=lambda k=key, l=label: (self.search_type_var.set(k), self.search_type_display_var.set(l))) 224 self.search_type_menu.pack(side=tk.LEFT, padx=5) 225 226 self.max_results_label = ttk.Label(search_frame, text=_("Max Results:")) 227 self.max_results_label.pack(side=tk.LEFT, padx=5) 228 229 self.max_results_entry = ttk.Entry(search_frame, width=5) 230 self.max_results_entry.pack(side=tk.LEFT, padx=5) 231 self.max_results_entry.insert(0, "100") 232 233 self.search_button = ttk.Button(search_frame, text=_("Search"), command=self.start_search_thread) 234 self.search_button.pack(side=tk.LEFT, padx=5) 235 236 # Action frame 237 action_frame = ttk.Frame(self) 238 action_frame.pack(pady=5, padx=5, fill=tk.X) 239 240 self.select_all_button = ttk.Button(action_frame, text=_("Select/Deselect All"), command=self.toggle_select_all) 241 self.select_all_button.pack(side=tk.LEFT, padx=5) 242 243 self.clear_button = ttk.Button(action_frame, text=_("Clear All"), command=self.clear_all) 244 self.clear_button.pack(side=tk.LEFT, padx=5) 245 246 self.clear_not_selected_button = ttk.Button(action_frame, text=_("Clear Not Selected"), command=self.clear_not_selected) 247 self.clear_not_selected_button.pack(side=tk.LEFT, padx=5) 248 249 self.download_button = ttk.Button(action_frame, text=_("Download Selected"), command=self.start_download_thread) 250 self.download_button.pack(side=tk.LEFT, padx=5) 251 252 # Results frame 253 self.results_frame = ttk.Frame(self) 254 self.results_frame.pack(pady=10, fill=tk.BOTH, expand=True) 255 256 style = ttk.Style() 257 row_h = int(32 * self.scaling_factor) 258 style.configure("Treeview", rowheight=row_h) 259 default_size = int(10 * self.scaling_factor) 260 style.configure("Treeview", font=('TkDefaultFont', default_size)) 261 style.configure("Treeview.Heading", font=('TkDefaultFont', default_size, 'bold')) 262 263 self.results_tree = ttk.Treeview(self.results_frame, columns=("check", "Title", "Size", "Source"), show="headings") 264 self.results_tree.heading("check", text=_("Select"), command=lambda: self.sort_treeview("check", False)) 265 self.results_tree.heading("Title", text=_("Title"), command=lambda: self.sort_treeview("Title", False)) 266 self.results_tree.heading("Size", text=_("Size"), command=lambda: self.sort_treeview("Size", False)) 267 self.results_tree.heading("Source", text=_("Source")) 268 self.results_tree.column("check", width=10, anchor="center") 269 self.results_tree.column("Title", width=240) 270 self.results_tree.column("Size", width=20) 271 self.results_tree.column("Source", width=140) 272 273 # Scrollbar 274 scrollbar = ttk.Scrollbar(self.results_frame, orient="vertical", command=self.results_tree.yview) 275 self.results_tree.configure(yscrollcommand=scrollbar.set) 276 scrollbar.pack(side=tk.RIGHT, fill=tk.Y) 277 278 self.results_tree.pack(fill=tk.BOTH, expand=True) 279 self.results_tree.bind("<Double-1>", self.toggle_check) 280 # Right-click on Link column copies the download URL to clipboard (Windows: Button-3) 281 self.results_tree.bind("<Button-3>", self.on_right_click_copy_link) 282 283 # Log frame 284 self.log_frame = ttk.Frame(self) 285 self.log_frame.pack(pady=10, fill=tk.BOTH, expand=True) 286 287 self.log_text = tk.Text(self.log_frame, height=10, state=tk.DISABLED) 288 self.log_text.pack(fill=tk.BOTH, expand=True) 289 290 # map item iid (detail_url) -> checked(bool). Keeps state independent of row ordering. 291 self.checked_map = {} 292 293 # Define tags for colored text 294 self.log_text.tag_config("info", foreground="blue") 295 self.log_text.tag_config("warning", foreground="orange") 296 self.log_text.tag_config("error", foreground="red") 297 self.log_text.tag_config("success", foreground="green")
302 def toggle_check(self, event): 303 sel = self.results_tree.selection() 304 if not sel: 305 return 306 item = sel[0] 307 current = self.checked_map.get(item, False) 308 new = not current 309 self.checked_map[item] = new 310 self.results_tree.item(item, values=(self.get_check_symbol(new), *self.results_tree.item(item)["values"][1:]))
312 def toggle_select_all(self): 313 children = list(self.results_tree.get_children()) 314 select_all = not all(self.checked_map.get(item, False) for item in children) 315 for item in children: 316 self.checked_map[item] = select_all 317 self.results_tree.item(item, values=(self.get_check_symbol(select_all), *self.results_tree.item(item)["values"][1:]))
320 def on_right_click_copy_link(self, event): 321 """ 322 Right-click handler: if clicked cell is in the Source column, copy underlying detail URL to clipboard. 323 """ 324 try: 325 region = self.results_tree.identify_region(event.x, event.y) 326 col = self.results_tree.identify_column(event.x) # e.g. "#4" for 4th column 327 # Source is the 4th column (#4) 328 if region != "cell" or col != "#4": 329 return 330 # identify_row returns the item id (we store detail_url as iid) 331 row = self.results_tree.identify_row(event.y) 332 if not row: 333 return 334 # use iid (row) as the real detail_url 335 link = row 336 # copy to clipboard 337 self.clipboard_clear() 338 self.clipboard_append(link) 339 self.log(_("Link copied to clipboard: {}").format(link), "info") 340 except Exception as e: 341 self.log(_("Failed to copy link: {}").format(e), "error")
Right-click handler: if clicked cell is in the Source column, copy underlying detail URL to clipboard.
343 def start_search_thread(self): 344 """ 345 Starts the search in a separate thread for each selected source. 346 Initializes a queue to collect results and starts processing the queue. 347 """ 348 self.searching = True 349 self.result_queue = queue.Queue() 350 self.threads = [] 351 self.link_2_files = [] 352 self.max_results = int(self.max_results_entry.get()) 353 prompt = self.search_entry.get() 354 file_type = self.file_type_var.get() 355 search_type = self.search_type_var.get() 356 selected_sources = [source["class"] for i, source in enumerate(SOURCES) if self.source_vars[i].get()] 357 self.stop_event = threading.Event() 358 359 def search_source(source_class): 360 try: 361 results = source_class().search(prompt, file_type, search_type) 362 for r in results: 363 if self.stop_event.is_set(): 364 break 365 self.result_queue.put(r) 366 except Exception as e: 367 self.log(_("Error in source {}: {}").format(source_class.__name__, e), "error") 368 369 for source_class in selected_sources: 370 t = threading.Thread(target=search_source, args=(source_class,)) 371 t.start() 372 self.threads.append(t) 373 374 self.log(_("Search initiated..."), "info", end="") 375 self.after(100, self.process_search_queue)
Starts the search in a separate thread for each selected source. Initializes a queue to collect results and starts processing the queue.
377 def process_search_queue(self): 378 """ 379 Processes the result queue, adding unique results to the treeview. 380 Stops adding results if max_results is reached and sets the `stop_event`. 381 """ 382 added = 0 383 while not self.result_queue.empty() and (not self.max_results or len(self.link_2_files) < self.max_results): 384 link_2_file = self.result_queue.get() 385 self.add_unique_to_results([link_2_file]) 386 self.link_2_files.append(link_2_file) 387 self.log(".", "info", end="") 388 added += 1 389 390 # Pokud jsme dosáhli max_results, nastav stop_event 391 if self.max_results and len(self.link_2_files) >= self.max_results: 392 self.stop_event.set() 393 394 if (any(t.is_alive() for t in self.threads) or not self.result_queue.empty()) and not self.stop_event.is_set(): 395 self.after(100, self.process_search_queue) 396 elif any(t.is_alive() for t in self.threads) or not self.result_queue.empty(): 397 # Po dosažení max_results ještě necháme doběhnout frontu 398 self.after(100, self.process_search_queue) 399 else: 400 self.log(_("\nNumber of files found: {}").format(len(self.link_2_files)), "success") 401 self.searching = False
Processes the result queue, adding unique results to the treeview.
Stops adding results if max_results is reached and sets the stop_event.
406 def download_worker(self, q: queue.Queue, timeout: int, success_list: list, success_lock: threading.Lock): 407 """ 408 Worker function to download files from the queue. 409 410 Args: 411 q (queue.Queue): Queue containing Link_to_file objects to download. 412 timeout (int): Timeout between downloads. 413 success_list (list): Shared list to store successfully downloaded files. 414 success_lock (threading.Lock): Lock to synchronize access to success_list. 415 """ 416 while not q.empty(): 417 link_2_file = q.get() 418 419 # test if file exists 420 target_path = f"{download_folder}/{link_2_file.title}" 421 if os.path.exists(target_path): 422 self.log(_("File {} already exists.").format(link_2_file.title), "warning") 423 with success_lock: 424 success_list.append(link_2_file) 425 continue 426 427 self.log(_("Downloading file: {} of size {}...").format(link_2_file.title, link_2_file.size), "info") 428 429 try: 430 link_2_file.download(download_folder) 431 432 file_size = os.path.getsize(target_path) 433 if link_2_file.source_class.test_downloaded_file(link_2_file, download_folder): 434 with success_lock: 435 success_list.append(link_2_file) 436 self.log(_("File {} of size {} was downloaded.").format(link_2_file.title, size_int_2_string(file_size)), "success") 437 self.remove_from_results([link_2_file]) 438 except ValueError as e: 439 self.log(_("Error: {}").format(e), "error") 440 self.log(_("File {} was not downloaded correctly.").format(link_2_file.title), "error") 441 if os.path.exists(target_path): 442 os.remove(target_path) 443 self.log(_("File {} was removed.").format(link_2_file.title), "info") 444 except InsufficientTimeoutError as e: 445 self.log(_("Error: {}").format(e), "error") 446 self.log(_("File {} was not downloaded at all.").format(link_2_file.title), "error") 447 if os.path.exists(target_path): 448 os.remove(target_path) 449 self.log(_("File {} was removed.").format(link_2_file.title), "info") 450 if self.add_files_with_failed_timeout_var.get(): 451 q.put(link_2_file) 452 self.log(_("File {} was added back to the list.").format(link_2_file.title), "info") 453 except Exception as e: 454 self.log(_("Error: {}").format(e), "error") 455 if os.path.exists(target_path): 456 os.remove(target_path) 457 self.log(_("File {} was removed.").format(link_2_file.title), "info") 458 459 if not q.empty(): 460 self.log(_("Waiting for {} seconds...").format(timeout), "info") 461 time.sleep(timeout)
Worker function to download files from the queue.
Args: q (queue.Queue): Queue containing Link_to_file objects to download. timeout (int): Timeout between downloads. success_list (list): Shared list to store successfully downloaded files. success_lock (threading.Lock): Lock to synchronize access to success_list.
463 def download_selected(self): 464 """ 465 Downloads all selected `Link_to_file` objects using worker threads. 466 467 1. loads selected `Link_to_file` objects, 468 2. groups them by `source_class`, 469 3. starts a worker thread for each group to download files with appropriate timeout, 470 4. waits for all threads to complete, 471 5. removes successfully downloaded files from the results and JSON file if configured. 472 473 """ 474 self.log(_("Download initiated..."), "info") 475 476 selected = self.get_selected_link_2_files() 477 if not selected: 478 self.log(_("No files selected for download."), "warning") 479 return 480 481 self.log(_("Number of files to download: {}").format(len(selected)), "info") 482 483 # Seskupit podle source_class 484 groups: dict = {} 485 for l in selected: 486 groups.setdefault(l.source_class, []).append(l) 487 488 threads = [] 489 successfull_files: list = [] 490 success_lock = threading.Lock() 491 492 for source_class, items in groups.items(): 493 q = queue.Queue() 494 for it in items: 495 q.put(it) 496 497 # najít timeout pro daný zdroj 498 timeout = next((s["timeout"] for s in SOURCES if s["class"] == source_class), TIME_OUT) 499 500 t = threading.Thread(target=self.download_worker, args=(q, timeout, successfull_files, success_lock), daemon=True) 501 t.start() 502 threads.append(t) 503 504 # počkat na dokončení všech worker vláken 505 for t in threads: 506 t.join() 507 508 self.log(_("Downloaded files: {}").format(len(successfull_files)), "success") 509 510 if self.remove_successful_var.get(): 511 self.log(_("Removing successful downloads from the list..."), "info") 512 remove_links_from_file(successfull_files, JSON_FILE)
Downloads all selected Link_to_file objects using worker threads.
- loads selected
Link_to_fileobjects, - groups them by
source_class, - starts a worker thread for each group to download files with appropriate timeout,
- waits for all threads to complete,
- removes successfully downloaded files from the results and JSON file if configured.
514 def result_tree_2_link_2_files(self): 515 """ 516 Yields Link_to_file objects from the results treeview. 517 """ 518 for item in self.results_tree.get_children(): 519 # item is iid == detail_url 520 detail_url = item 521 l2f = self.link_map.get(detail_url) 522 if l2f is not None: 523 yield l2f 524 else: 525 vals = self.results_tree.item(item)["values"] 526 title = vals[1] if len(vals) > 1 else detail_url 527 size = vals[2] if len(vals) > 2 else "unknown" 528 self.log(_("Warning: Link not found in map, creating new Link_to_file object."), "warning") 529 yield Link_to_file(title, detail_url, size, Download_page_search) # Fallback
Yields Link_to_file objects from the results treeview.
531 def replace_results(self, link_2_files): 532 """ 533 Replaces all items in the results treeview with new Link_to_file objects. 534 And updates the link_map accordingly. 535 """ 536 self.results_tree.delete(*self.results_tree.get_children()) 537 self.checked_map.clear() 538 self.link_map.clear() 539 for i, link_2_file in enumerate(link_2_files): 540 source_name = CLASS_NAME_MAP.get(link_2_file.source_class, getattr(link_2_file.source_class, "__name__", "Unknown")) 541 # store detail_url as iid so we can later retrieve the real link even if we display Source 542 self.results_tree.insert( 543 "", "end", iid=link_2_file.detail_url, 544 values=(self.get_check_symbol(False), link_2_file.title, link_2_file.size, source_name), 545 ) 546 self.checked_map[link_2_file.detail_url] = False 547 self.link_map[link_2_file.detail_url] = link_2_file
Replaces all items in the results treeview with new Link_to_file objects. And updates the link_map accordingly.
549 def add_unique_to_results(self, link_2_files): 550 """ 551 Adds only unique Link_to_file objects to the results treeview. 552 And updates the link_map accordingly. 553 """ 554 existing_links = set(self.link_map.keys()) 555 for link_2_file in link_2_files: 556 if link_2_file.detail_url not in existing_links: 557 source_name = CLASS_NAME_MAP.get(link_2_file.source_class, getattr(link_2_file.source_class, "__name__", "Unknown")) 558 self.results_tree.insert( 559 "", "end", iid=link_2_file.detail_url, 560 values=(self.get_check_symbol(False), link_2_file.title, link_2_file.size, source_name), 561 ) 562 self.checked_map[link_2_file.detail_url] = False 563 self.link_map[link_2_file.detail_url] = link_2_file 564 existing_links.add(link_2_file.detail_url)
Adds only unique Link_to_file objects to the results treeview. And updates the link_map accordingly.
566 def remove_from_results(self, link_2_files): 567 links_to_remove = {l.detail_url for l in link_2_files} 568 for item in list(self.results_tree.get_children()): 569 detail_url = item 570 if detail_url in links_to_remove: 571 self.results_tree.delete(item) 572 self.link_map.pop(detail_url, None) 573 self.checked_map.pop(detail_url, None)
575 def get_selected_link_2_files(self) -> list[Link_to_file]: 576 """ 577 Returns a list of Link_to_file objects corresponding to currently checked items in the results treeview. 578 If a selected link is missing from link_map, creates a safe fallback Link_to_file object. 579 """ 580 selected_links = [item for item in self.results_tree.get_children() if self.checked_map.get(item, False)] 581 582 result = [] 583 for link in selected_links: 584 l2f = self.link_map.get(link) 585 if l2f is None: 586 # Fallback: create a minimal Link_to_file object if map is missing entry 587 # Use link as title and unknown size; Download_page_search as a neutral source_class 588 self.log(_("Warning: Selected link {} not found in map, creating fallback object.").format(link), "warning") 589 try: 590 fallback = Link_to_file(link, link, "unknown", Download_page_search) 591 result.append(fallback) 592 except Exception: 593 # If construction fails, skip the entry 594 self.log(_("Failed to create fallback for {}").format(link), "error") 595 else: 596 result.append(l2f) 597 return result
Returns a list of Link_to_file objects corresponding to currently checked items in the results treeview. If a selected link is missing from link_map, creates a safe fallback Link_to_file object.
599 def save_selected(self): 600 """ 601 Loads selected items from the results treeview. 602 Maps them to Link_to_file objects using link_map. 603 Saves the Link_to_file objects to JSON_FILE. 604 """ 605 self.log(_("Saving selected items..."), "info") 606 607 link_2_files = self.get_selected_link_2_files() 608 save_links_to_file(link_2_files, JSON_FILE) 609 610 self.log(_("Saved items: {}").format(len(link_2_files)), "success")
Loads selected items from the results treeview. Maps them to Link_to_file objects using link_map. Saves the Link_to_file objects to JSON_FILE.
618 def clear_all(self): 619 """ 620 Clears all items from the: 621 - results treeview, 622 - link_map. 623 """ 624 self.results_tree.delete(*self.results_tree.get_children()) 625 self.checked_map.clear() 626 self.link_map.clear() 627 self.log(_("Cleared all displayed files."), "info")
Clears all items from the:
- results treeview,
- link_map.
629 def clear_not_selected(self): 630 # keep only items that are checked (based on checked_map) 631 items_to_keep = [(self.results_tree.item(item)["values"], item) for item in self.results_tree.get_children() if self.checked_map.get(item, False)] 632 self.results_tree.delete(*self.results_tree.get_children()) 633 self.checked_map.clear() 634 for values, iid in items_to_keep: 635 self.results_tree.insert("", "end", iid=iid, values=values) 636 self.checked_map[iid] = True 637 self.log(_("Cleared not selected files."), "info")
645 def sort_treeview(self, col, reverse): 646 items = [(self.results_tree.set(k, col), k) for k in self.results_tree.get_children('')] 647 if col == "Size": 648 items.sort(key=lambda t: size_string_2_bytes(t[0]), reverse=reverse) 649 elif col == "check": 650 # sort by checked state stored per-iid 651 items.sort(key=lambda t: self.checked_map.get(t[1], False), reverse=reverse) 652 else: 653 items.sort(reverse=reverse) 654 655 for index, (val, k) in enumerate(items): 656 self.results_tree.move(k, '', index) 657 658 self.results_tree.heading(col, command=lambda: self.sort_treeview(col, not reverse))
674 def update_ui_texts(self): 675 self.title(_("Universal Downloader")) 676 677 self.search_label.config(text=_("Search:")) 678 self.search_button.config(text=_("Search")) 679 self.max_results_label.config(text=_("Max Results:")) 680 681 self.download_button.config(text=_("Download Selected")) 682 self.clear_button.config(text=_("Clear All")) 683 self.clear_not_selected_button.config(text=_("Clear Not Selected")) 684 self.select_all_button.config(text=_("Select/Deselect All")) 685 686 self.results_tree.heading("check", text=_("Select")) 687 self.results_tree.heading("Title", text=_("Title")) 688 self.results_tree.heading("Size", text=_("Size")) 689 self.results_tree.heading("Source", text=_("Source")) 690 self._rebuild_type_menus() 691 self.log(_("Language changed to {}.").format(self.current_language.get()), "info")
719def main(): 720 if not os.path.exists(JSON_FILE): 721 open(JSON_FILE, 'w').close() 722 print_info(f"Created empty JSON file at {JSON_FILE}") 723 724 if not os.path.exists(download_folder): 725 os.makedirs(download_folder) 726 print_info(f"Created download folder at {download_folder}") 727 728 compile_mo_files() 729 localedir = get_resource_path("locales") 730 if not os.path.exists(os.path.join(localedir, "cs", "LC_MESSAGES", DOMAIN + ".mo")): 731 print_error("Translation file not found!") 732 733 app = DownloaderGUI() 734 app.mainloop()