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()
CONFIG_FILE = 'config.json'
DEFAULT_LANGUAGE = 'en'
TIME_OUT = 50
LANGUAGES = {'EN': 'en', 'CZ': 'cs'}
SOURCES = [{'name': 'Datoid.cz', 'class': <class 'src.downloader.datoid.Datoid_downloader'>, 'timeout': 50}, {'name': 'Prehraj.to', 'class': <class 'src.downloader.prehrajto.Prehrajto_downloader'>, 'timeout': 50}]
GEOMETRY = (1300, 800)
APP_NAME = 'Universal Downloader'
CLASS_NAME_MAP = {<class 'src.downloader.datoid.Datoid_downloader'>: 'Datoid.cz', <class 'src.downloader.prehrajto.Prehrajto_downloader'>: 'Prehraj.to'}
DOMAIN = 'universal_downloader'
ICON_FILE = 'icon.png'
ASSETS_DIR = 'assets'
def get_resource_path(relative_path):
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

def compile_mo_files():
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).

class DownloaderGUI(tkinter.Tk):
 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.

DownloaderGUI()
 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.

lang_codes = dict_values(['en', 'cs'])
scaling_factor
settings
current_language
remove_successful_var
add_files_with_failed_timeout_var
source_vars
def load_config(self, config_file='config.json') -> dict:
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.

def save_config(self, config_file='config.json'):
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.

def setup_translation(self, domain='universal_downloader'):
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
def create_widgets(self):
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")
def get_check_symbol(self, checked):
299    def get_check_symbol(self, checked):
300        return "✓" if checked else "✗"
def toggle_check(self, event):
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:]))
def toggle_select_all(self):
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:]))
def start_search_thread(self):
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.

def process_search_queue(self):
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.

def start_download_thread(self):
403    def start_download_thread(self):
404        threading.Thread(target=self.download_selected, daemon=True).start()
def download_worker( self, q: queue.Queue, timeout: int, success_list: list, success_lock: _thread.lock):
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.

def download_selected(self):
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.

  1. loads selected Link_to_file objects,
  2. groups them by source_class,
  3. starts a worker thread for each group to download files with appropriate timeout,
  4. waits for all threads to complete,
  5. removes successfully downloaded files from the results and JSON file if configured.
def replace_results(self, link_2_files):
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.

def add_unique_to_results(self, link_2_files):
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.

def remove_from_results(self, link_2_files):
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)
def save_selected(self):
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.

def load_from_file(self):
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")
def clear_all(self):
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.
def clear_not_selected(self):
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")
def log(self, message, tag='info', end='\n'):
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)
def sort_treeview(self, col, reverse):
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))
def change_language(self, *args):
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()
def update_remove_successful(self, *args):
666    def update_remove_successful(self, *args):
667        self.settings["remove_successful"] = self.remove_successful_var.get()
668        self.save_config()
def update_add_files_with_failed_timeout(self, *args):
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()
def update_ui_texts(self):
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")
def main():
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()