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