gui

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

Get absolute path to resource, works for dev and for PyInstaller

def compile_mo_files():
52def compile_mo_files():
53    """
54    Compiles .po files to .mo files using msgfmt.
55    If msgfmt is not found, skips compilation (Typically on target machine with .exe).
56    """
57    localedir = get_resource_path("locales")
58    msgfmt_path = shutil.which('msgfmt')
59    if not msgfmt_path:
60        print("msgfmt not found in PATH — skipping .po -> .mo compilation. Ensure .mo files are included in the build.")
61        return
62
63    for lang in LANGUAGES.values():
64        po_file = os.path.join(localedir, lang, 'LC_MESSAGES', DOMAIN + '.po')
65        mo_file = os.path.join(localedir, lang, 'LC_MESSAGES', DOMAIN + '.mo')
66        if os.path.exists(po_file):
67            if not os.path.exists(mo_file) or os.path.getmtime(po_file) > os.path.getmtime(mo_file):
68                print(f"Compiling {po_file} to {mo_file}")
69                try:
70                    subprocess.run([msgfmt_path, '-o', mo_file, po_file], check=True)
71                except subprocess.CalledProcessError as e:
72                    print(f"msgfmt failed: {e}")

Compiles .po files to .mo files using msgfmt. If msgfmt is not found, skips compilation (Typically on target machine with .exe).

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

Toplevel widget of Tk which represents mostly the main window of an application. It has an associated Tcl interpreter.

DownloaderGUI()
 77    def __init__(self):
 78        super().__init__()
 79        icon_path = get_resource_path(os.path.join(ASSETS_DIR, "icon.png"))
 80        if os.path.isfile(icon_path):
 81            icon = tk.PhotoImage(file=icon_path)
 82            self.iconphoto(True, icon)
 83        else:
 84            print(f"Icon: '{icon_path}' not found.")
 85        
 86        self.settings = self.load_config()
 87        self.current_language = tk.StringVar(value=self.settings.get("language", DEFAULT_LANGUAGE))
 88        self.remove_successful_var = tk.BooleanVar(value=self.settings.get("remove_successful", False))
 89        self.remove_successful_var.trace_add("write", self.update_remove_successful)
 90        self.add_files_with_failed_timeout_var = tk.BooleanVar(value=self.settings.get("add_files_with_failed_timeout", False))
 91        self.add_files_with_failed_timeout_var.trace_add("write", self.update_add_files_with_failed_timeout)
 92
 93        self.source_vars = []
 94        for source in SOURCES:
 95            var = tk.BooleanVar(value=self.settings.get(source["name"], True))
 96            # Save changes to config on change
 97            var.trace_add("write", lambda *args, name=source["name"], var=var: self.settings.update({name: var.get()}) or self.save_config())
 98            self.source_vars.append(var)
 99
100        self.link_map = {} # detail_url -> Link_to_file (mapping with result treeview)
101
102        self.setup_translation()
103        self.title(_("Universal Downloader"))
104        self.geometry("800x600")
105        self.create_widgets()

Return a new top level widget on screen SCREENNAME. A new Tcl interpreter will be created. BASENAME will be used for the identification of the profile file (see readprofile). It is constructed from sys.argv[0] without extensions if None is given. CLASSNAME is the name of the widget class.

lang_codes = dict_values(['en', 'cs'])
settings
current_language
remove_successful_var
add_files_with_failed_timeout_var
source_vars
def load_config(self, config_file='config.json') -> dict:
107    def load_config(self, config_file=CONFIG_FILE) -> dict:
108        """
109        Load configuration from a JSON file.
110        If the file does not exist, returns an empty dictionary.
111        """
112        try:
113            with open(config_file, "r") as file:
114                return json.load(file)
115        except FileNotFoundError:
116            return {}

Load configuration from a JSON file. If the file does not exist, returns an empty dictionary.

def save_config(self, config_file='config.json'):
118    def save_config(self, config_file=CONFIG_FILE):
119        """
120        Save configuration to a JSON file.
121        """
122        with open(config_file, "w") as file:
123            json.dump(self.settings, file)

Save configuration to a JSON file.

def setup_translation(self, domain='universal_downloader'):
125    def setup_translation(self, domain=DOMAIN):
126        lang_code = self.current_language.get()
127        global _
128        localedir = get_resource_path("locales")
129        try:
130            lang = gettext.translation(domain, localedir=localedir, languages=[lang_code])
131            lang.install()
132            if DEBUG:
133                print_success(f"Translation loaded for {lang_code}.")
134            _ = lang.gettext
135        except Exception as e:
136            print_error(f"Translation not found for {lang_code}, falling back to default. Error: {e}")
137            gettext.install(domain, localedir=localedir)
138            _ = gettext.gettext
def create_widgets(self):
140    def create_widgets(self):
141        # Menu bar
142        menubar = tk.Menu(self)
143        self.config(menu=menubar)
144
145        # File menu
146        file_menu = tk.Menu(menubar, tearoff=0)
147        file_menu.add_command(label=_("Save Selected"), command=self.save_selected)
148        file_menu.add_command(label=_("Load from file"), command=self.load_from_file)
149        menubar.add_cascade(label=_("File"), menu=file_menu)
150
151        # Settings menu
152        settings_menu = tk.Menu(menubar, tearoff=0)
153        lang_menu = tk.Menu(settings_menu, tearoff=0)
154        for lang in self.lang_codes:
155            lang_menu.add_radiobutton(label=lang, variable=self.current_language, value=lang, command=self.change_language)
156        settings_menu.add_cascade(label=_("Language"), menu=lang_menu)
157        settings_menu.add_separator()
158        settings_menu.add_checkbutton(label=_("Remove successful from json"), variable=self.remove_successful_var)
159        settings_menu.add_checkbutton(label=_("Add back files with failed timeout"), variable=self.add_files_with_failed_timeout_var)
160        menubar.add_cascade(label=_("Settings"), menu=settings_menu)
161
162        # Zdroje menu
163        sources_menu = tk.Menu(menubar, tearoff=0)
164        for i, source in enumerate(SOURCES):
165            sources_menu.add_checkbutton(
166                label=source["name"],
167                variable=self.source_vars[i],
168                onvalue=True,
169                offvalue=False
170            )
171        menubar.add_cascade(label=_("Sources"), menu=sources_menu)
172
173
174        # Search frame
175        search_frame = ttk.Frame(self)
176        search_frame.pack(pady=5, padx=5, fill=tk.X)
177
178        self.search_label = ttk.Label(search_frame, text=_("Search:"))
179        self.search_label.pack(side=tk.LEFT, padx=5)
180
181        self.search_entry = ttk.Entry(search_frame)
182        self.search_entry.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True)
183
184        # file type: show translated labels but keep internal key
185        # internal vars (hold keys used by search)
186        self.file_type_var = tk.StringVar(value="all")
187        self.search_type_var = tk.StringVar(value="relevance")
188        # display vars (hold translated label shown in OptionMenu)
189        self.file_type_display_var = tk.StringVar(value=_(self.file_type_var.get()))
190        self.search_type_display_var = tk.StringVar(value=_(self.search_type_var.get()))
191
192        # build OptionMenus using display vars; commands set both internal key and display label
193        self.file_type_menu = ttk.OptionMenu(search_frame, self.file_type_display_var, self.file_type_display_var.get())
194        menu = self.file_type_menu["menu"]
195        menu.delete(0, "end")
196        for key in Download_page_search.file_types.keys():
197            label = _(key)
198            menu.add_command(label=label, command=lambda k=key, l=label: (self.file_type_var.set(k), self.file_type_display_var.set(l)))
199        self.file_type_menu.pack(side=tk.LEFT, padx=5)
200
201        self.search_type_menu = ttk.OptionMenu(search_frame, self.search_type_display_var, self.search_type_display_var.get())
202        menu2 = self.search_type_menu["menu"]
203        menu2.delete(0, "end")
204        for key in Download_page_search.search_types.keys():
205            label = _(key)
206            menu2.add_command(label=label, command=lambda k=key, l=label: (self.search_type_var.set(k), self.search_type_display_var.set(l)))
207        self.search_type_menu.pack(side=tk.LEFT, padx=5)
208
209        self.max_results_label = ttk.Label(search_frame, text=_("Max Results:"))
210        self.max_results_label.pack(side=tk.LEFT, padx=5)
211
212        self.max_results_entry = ttk.Entry(search_frame, width=5)
213        self.max_results_entry.pack(side=tk.LEFT, padx=5)
214        self.max_results_entry.insert(0, "100")
215
216        self.search_button = ttk.Button(search_frame, text=_("Search"), command=self.start_search_thread)
217        self.search_button.pack(side=tk.LEFT, padx=5)
218
219        # Action frame
220        action_frame = ttk.Frame(self)
221        action_frame.pack(pady=5, padx=5, fill=tk.X)
222
223        self.select_all_button = ttk.Button(action_frame, text=_("Select/Deselect All"), command=self.toggle_select_all)
224        self.select_all_button.pack(side=tk.LEFT, padx=5)
225
226        self.clear_button = ttk.Button(action_frame, text=_("Clear All"), command=self.clear_all)
227        self.clear_button.pack(side=tk.LEFT, padx=5)
228
229        self.clear_not_selected_button = ttk.Button(action_frame, text=_("Clear Not Selected"), command=self.clear_not_selected)
230        self.clear_not_selected_button.pack(side=tk.LEFT, padx=5)
231
232        self.download_button = ttk.Button(action_frame, text=_("Download Selected"), command=self.start_download_thread)
233        self.download_button.pack(side=tk.LEFT, padx=5)
234
235        # Results frame
236        self.results_frame = ttk.Frame(self)
237        self.results_frame.pack(pady=10, fill=tk.BOTH, expand=True)
238
239        self.results_tree = ttk.Treeview(self.results_frame, columns=("check", "Title", "Size", "Source"), show="headings")
240        self.results_tree.heading("check", text=_("Select"), command=lambda: self.sort_treeview("check", False))
241        self.results_tree.heading("Title", text=_("Title"), command=lambda: self.sort_treeview("Title", False))
242        self.results_tree.heading("Size", text=_("Size"), command=lambda: self.sort_treeview("Size", False))
243        self.results_tree.heading("Source", text=_("Source"))
244        self.results_tree.column("check", width=10, anchor="center")
245        self.results_tree.column("Title", width=240)
246        self.results_tree.column("Size", width=20)
247        self.results_tree.column("Source", width=140)
248
249        # Scrollbar
250        scrollbar = ttk.Scrollbar(self.results_frame, orient="vertical", command=self.results_tree.yview)
251        self.results_tree.configure(yscrollcommand=scrollbar.set)
252        scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
253
254        self.results_tree.pack(fill=tk.BOTH, expand=True)
255        self.results_tree.bind("<Double-1>", self.toggle_check)
256        # Right-click on Link column copies the download URL to clipboard (Windows: Button-3)
257        self.results_tree.bind("<Button-3>", self.on_right_click_copy_link)
258
259        # Log frame
260        self.log_frame = ttk.Frame(self)
261        self.log_frame.pack(pady=10, fill=tk.BOTH, expand=True)
262
263        self.log_text = tk.Text(self.log_frame, height=10, state=tk.DISABLED)
264        self.log_text.pack(fill=tk.BOTH, expand=True)
265
266        # map item iid (detail_url) -> checked(bool). Keeps state independent of row ordering.
267        self.checked_map = {}
268 
269        # Define tags for colored text
270        self.log_text.tag_config("info", foreground="blue")
271        self.log_text.tag_config("warning", foreground="orange")
272        self.log_text.tag_config("error", foreground="red")
273        self.log_text.tag_config("success", foreground="green")
def get_check_symbol(self, checked):
275    def get_check_symbol(self, checked):
276        return "✓" if checked else "✗"
def toggle_check(self, event):
278    def toggle_check(self, event):
279        sel = self.results_tree.selection()
280        if not sel:
281            return
282        item = sel[0]
283        current = self.checked_map.get(item, False)
284        new = not current
285        self.checked_map[item] = new
286        self.results_tree.item(item, values=(self.get_check_symbol(new), *self.results_tree.item(item)["values"][1:]))
def toggle_select_all(self):
288    def toggle_select_all(self):
289        children = list(self.results_tree.get_children())
290        select_all = not all(self.checked_map.get(item, False) for item in children)
291        for item in children:
292            self.checked_map[item] = select_all
293            self.results_tree.item(item, values=(self.get_check_symbol(select_all), *self.results_tree.item(item)["values"][1:]))
def start_search_thread(self):
319    def start_search_thread(self):
320        """
321        Starts the search in a separate thread for each selected source.
322        Initializes a queue to collect results and starts processing the queue.
323        """
324        self.searching = True
325        self.result_queue = queue.Queue()
326        self.threads = []
327        self.link_2_files = []
328        self.max_results = int(self.max_results_entry.get())
329        prompt = self.search_entry.get()
330        file_type = self.file_type_var.get()
331        search_type = self.search_type_var.get()
332        selected_sources = [source["class"] for i, source in enumerate(SOURCES) if self.source_vars[i].get()]
333        self.stop_event = threading.Event() 
334
335        def search_source(source_class):
336            try:
337                results = source_class().search(prompt, file_type, search_type)
338                for r in results:
339                    if self.stop_event.is_set():
340                        break
341                    self.result_queue.put(r)
342            except Exception as e:
343                self.log(_("Error in source {}: {}").format(source_class.__name__, e), "error")
344
345        for source_class in selected_sources:
346            t = threading.Thread(target=search_source, args=(source_class,))
347            t.start()
348            self.threads.append(t)
349
350        self.log(_("Search initiated..."), "info", end="")
351        self.after(100, self.process_search_queue)

Starts the search in a separate thread for each selected source. Initializes a queue to collect results and starts processing the queue.

def process_search_queue(self):
353    def process_search_queue(self):
354        """
355        Processes the result queue, adding unique results to the treeview.
356        Stops adding results if max_results is reached and sets the `stop_event`.
357        """
358        added = 0
359        while not self.result_queue.empty() and (not self.max_results or len(self.link_2_files) < self.max_results):
360            link_2_file = self.result_queue.get()
361            self.add_unique_to_results([link_2_file])
362            self.link_2_files.append(link_2_file)
363            self.log(".", "info", end="")
364            added += 1
365
366            # Pokud jsme dosáhli max_results, nastav stop_event
367            if self.max_results and len(self.link_2_files) >= self.max_results:
368                self.stop_event.set()
369
370        if (any(t.is_alive() for t in self.threads) or not self.result_queue.empty()) and not self.stop_event.is_set():
371            self.after(100, self.process_search_queue)
372        elif any(t.is_alive() for t in self.threads) or not self.result_queue.empty():
373            # Po dosažení max_results ještě necháme doběhnout frontu
374            self.after(100, self.process_search_queue)
375        else:
376            self.log(_("\nNumber of files found: {}").format(len(self.link_2_files)), "success")
377            self.searching = False

Processes the result queue, adding unique results to the treeview. Stops adding results if max_results is reached and sets the stop_event.

def start_download_thread(self):
379    def start_download_thread(self):
380        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):
382    def download_worker(self, q: queue.Queue, timeout: int, success_list: list, success_lock: threading.Lock):
383        """
384        Worker function to download files from the queue.
385        
386        Args:
387            q (queue.Queue): Queue containing Link_to_file objects to download.
388            timeout (int): Timeout between downloads.
389            success_list (list): Shared list to store successfully downloaded files.
390            success_lock (threading.Lock): Lock to synchronize access to success_list.
391        """
392        while not q.empty():
393            link_2_file = q.get()
394
395            # test if file exists
396            target_path = f"{download_folder}/{link_2_file.title}"
397            if os.path.exists(target_path):
398                self.log(_("File {} already exists.").format(link_2_file.title), "warning")
399                with success_lock:
400                    success_list.append(link_2_file)
401                continue
402
403            self.log(_("Downloading file: {} of size {}...").format(link_2_file.title, link_2_file.size), "info")
404
405            try:
406                link_2_file.download(download_folder)
407
408                file_size = os.path.getsize(target_path)
409                if link_2_file.source_class.test_downloaded_file(link_2_file, download_folder):
410                    with success_lock:
411                        success_list.append(link_2_file)
412                    self.log(_("File {} of size {} was downloaded.").format(link_2_file.title, size_int_2_string(file_size)), "success")
413                    self.remove_from_results([link_2_file])
414            except ValueError as e:
415                self.log(_("Error: {}").format(e), "error")
416                self.log(_("File {} was not downloaded correctly.").format(link_2_file.title), "error")
417                if os.path.exists(target_path):
418                    os.remove(target_path)
419                    self.log(_("File {} was removed.").format(link_2_file.title), "info")
420            except InsufficientTimeoutError as e:
421                self.log(_("Error: {}").format(e), "error")
422                self.log(_("File {} was not downloaded at all.").format(link_2_file.title), "error")
423                if os.path.exists(target_path):
424                    os.remove(target_path)
425                    self.log(_("File {} was removed.").format(link_2_file.title), "info")
426                if self.add_files_with_failed_timeout_var.get():
427                    q.put(link_2_file)
428                    self.log(_("File {} was added back to the list.").format(link_2_file.title), "info")
429            except Exception as e:
430                self.log(_("Error: {}").format(e), "error")
431                if os.path.exists(target_path):
432                    os.remove(target_path)
433                    self.log(_("File {} was removed.").format(link_2_file.title), "info")
434            
435            if not q.empty():
436                self.log(_("Waiting for {} seconds...").format(timeout), "info")
437                time.sleep(timeout)

Worker function to download files from the queue.

Args: q (queue.Queue): Queue containing Link_to_file objects to download. timeout (int): Timeout between downloads. success_list (list): Shared list to store successfully downloaded files. success_lock (threading.Lock): Lock to synchronize access to success_list.

def download_selected(self):
439    def download_selected(self):
440        """
441        Downloads all selected `Link_to_file` objects using worker threads.
442
443        1. loads selected `Link_to_file` objects,
444        2. groups them by `source_class`,
445        3. starts a worker thread for each group to download files with appropriate timeout,
446        4. waits for all threads to complete,
447        5. removes successfully downloaded files from the results and JSON file if configured.
448
449        """
450        self.log(_("Download initiated..."), "info")
451
452        selected = self.get_selected_link_2_files()
453        if not selected:
454            self.log(_("No files selected for download."), "warning")
455            return
456
457        self.log(_("Number of files to download: {}").format(len(selected)), "info")
458
459        # Seskupit podle source_class
460        groups: dict = {}
461        for l in selected:
462            groups.setdefault(l.source_class, []).append(l)
463
464        threads = []
465        successfull_files: list = []
466        success_lock = threading.Lock()
467
468        for source_class, items in groups.items():
469            q = queue.Queue()
470            for it in items:
471                q.put(it)
472
473            # najít timeout pro daný zdroj
474            timeout = next((s["timeout"] for s in SOURCES if s["class"] == source_class), TIME_OUT)
475
476            t = threading.Thread(target=self.download_worker, args=(q, timeout, successfull_files, success_lock), daemon=True)
477            t.start()
478            threads.append(t)
479
480        # počkat na dokončení všech worker vláken
481        for t in threads:
482            t.join()
483
484        self.log(_("Downloaded files: {}").format(len(successfull_files)), "success")
485
486        if self.remove_successful_var.get():
487            self.log(_("Removing successful downloads from the list..."), "info")
488            remove_links_from_file(successfull_files, JSON_FILE)

Downloads all selected Link_to_file objects using worker threads.

  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):
507    def replace_results(self, link_2_files):
508        """
509        Replaces all items in the results treeview with new Link_to_file objects.
510        And updates the link_map accordingly.
511        """
512        self.results_tree.delete(*self.results_tree.get_children())
513        self.checked_map.clear()
514        self.link_map.clear()
515        for i, link_2_file in enumerate(link_2_files):
516            source_name = CLASS_NAME_MAP.get(link_2_file.source_class, getattr(link_2_file.source_class, "__name__", "Unknown"))
517            # store detail_url as iid so we can later retrieve the real link even if we display Source
518            self.results_tree.insert(
519                "", "end", iid=link_2_file.detail_url,
520                values=(self.get_check_symbol(False), link_2_file.title, link_2_file.size, source_name),
521            )
522            self.checked_map[link_2_file.detail_url] = False
523            self.link_map[link_2_file.detail_url] = link_2_file

Replaces all items in the results treeview with new Link_to_file objects. And updates the link_map accordingly.

def add_unique_to_results(self, link_2_files):
525    def add_unique_to_results(self, link_2_files):
526        """
527        Adds only unique Link_to_file objects to the results treeview.
528        And updates the link_map accordingly.
529        """
530        existing_links = set(self.link_map.keys())
531        for link_2_file in link_2_files:
532            if link_2_file.detail_url not in existing_links:
533                source_name = CLASS_NAME_MAP.get(link_2_file.source_class, getattr(link_2_file.source_class, "__name__", "Unknown"))
534                self.results_tree.insert(
535                    "", "end", iid=link_2_file.detail_url,
536                    values=(self.get_check_symbol(False), link_2_file.title, link_2_file.size, source_name),
537                )
538                self.checked_map[link_2_file.detail_url] = False
539                self.link_map[link_2_file.detail_url] = link_2_file
540                existing_links.add(link_2_file.detail_url)

Adds only unique Link_to_file objects to the results treeview. And updates the link_map accordingly.

def remove_from_results(self, link_2_files):
542    def remove_from_results(self, link_2_files):
543        links_to_remove = {l.detail_url for l in link_2_files}
544        for item in list(self.results_tree.get_children()):
545            detail_url = item
546            if detail_url in links_to_remove:
547                self.results_tree.delete(item)
548                self.link_map.pop(detail_url, None)
549                self.checked_map.pop(detail_url, None)
def save_selected(self):
575    def save_selected(self):
576        """
577        Loads selected items from the results treeview.
578        Maps them to Link_to_file objects using link_map.
579        Saves the Link_to_file objects to JSON_FILE.
580        """
581        self.log(_("Saving selected items..."), "info")
582
583        link_2_files = self.get_selected_link_2_files()
584        save_links_to_file(link_2_files, JSON_FILE)
585
586        self.log(_("Saved items: {}").format(len(link_2_files)), "success")

Loads selected items from the results treeview. Maps them to Link_to_file objects using link_map. Saves the Link_to_file objects to JSON_FILE.

def load_from_file(self):
588    def load_from_file(self):
589        self.log(_("Loading selected items..."), "info")
590        link_2_files = load_links_from_file(JSON_FILE)
591        self.replace_results(link_2_files) # automatically updates link_map
592        self.log(_("Loaded items: {}").format(len(link_2_files)), "success")
def clear_all(self):
594    def clear_all(self):
595        """
596        Clears all items from the:
597         - results treeview,
598         - link_map.
599        """
600        self.results_tree.delete(*self.results_tree.get_children())
601        self.checked_map.clear()
602        self.link_map.clear()
603        self.log(_("Cleared all displayed files."), "info")

Clears all items from the:

  • results treeview,
  • link_map.
def clear_not_selected(self):
605    def clear_not_selected(self):
606        # keep only items that are checked (based on checked_map)
607        items_to_keep = [(self.results_tree.item(item)["values"], item) for item in self.results_tree.get_children() if self.checked_map.get(item, False)]
608        self.results_tree.delete(*self.results_tree.get_children())
609        self.checked_map.clear()
610        for values, iid in items_to_keep:
611            self.results_tree.insert("", "end", iid=iid, values=values)
612            self.checked_map[iid] = True
613        self.log(_("Cleared not selected files."), "info")
def log(self, message, tag='info', end='\n'):
615    def log(self, message, tag="info", end="\n"):
616        self.log_text.config(state=tk.NORMAL)
617        self.log_text.insert(tk.END, message + end, tag)
618        self.log_text.config(state=tk.DISABLED)
619        self.log_text.see(tk.END)
def sort_treeview(self, col, reverse):
621    def sort_treeview(self, col, reverse):
622        items = [(self.results_tree.set(k, col), k) for k in self.results_tree.get_children('')]
623        if col == "Size":
624            items.sort(key=lambda t: size_string_2_bytes(t[0]), reverse=reverse)
625        elif col == "check":
626            # sort by checked state stored per-iid
627            items.sort(key=lambda t: self.checked_map.get(t[1], False), reverse=reverse)
628        else:
629            items.sort(reverse=reverse)
630        
631        for index, (val, k) in enumerate(items):
632            self.results_tree.move(k, '', index)
633        
634        self.results_tree.heading(col, command=lambda: self.sort_treeview(col, not reverse))
def change_language(self, *args):
636    def change_language(self, *args):
637        self.setup_translation()
638        self.update_ui_texts()
639        self.settings["language"] = self.current_language.get()
640        self.save_config()
def update_remove_successful(self, *args):
642    def update_remove_successful(self, *args):
643        self.settings["remove_successful"] = self.remove_successful_var.get()
644        self.save_config()
def update_add_files_with_failed_timeout(self, *args):
646    def update_add_files_with_failed_timeout(self, *args):
647        self.settings["add_files_with_failed_timeout"] = self.add_files_with_failed_timeout_var.get()
648        self.save_config()
def update_ui_texts(self):
650    def update_ui_texts(self):
651        self.title(_("Universal Downloader"))
652
653        self.search_label.config(text=_("Search:"))
654        self.search_button.config(text=_("Search"))
655        self.max_results_label.config(text=_("Max Results:"))
656
657        self.download_button.config(text=_("Download Selected"))
658        self.clear_button.config(text=_("Clear All"))
659        self.clear_not_selected_button.config(text=_("Clear Not Selected"))
660        self.select_all_button.config(text=_("Select/Deselect All"))
661
662        self.results_tree.heading("check", text=_("Select"))
663        self.results_tree.heading("Title", text=_("Title"))
664        self.results_tree.heading("Size", text=_("Size"))
665        self.results_tree.heading("Source", text=_("Source"))
666        self._rebuild_type_menus()
667        self.log(_("Language changed to {}.").format(self.current_language.get()), "info")
def main():
695def main():
696    if not os.path.exists(JSON_FILE):
697        open(JSON_FILE, 'w').close()
698        print_info(f"Created empty JSON file at {JSON_FILE}")
699    
700    if not os.path.exists(download_folder):
701        os.makedirs(download_folder)
702        print_info(f"Created download folder at {download_folder}")
703    
704    compile_mo_files()
705    localedir = get_resource_path("locales")
706    if not os.path.exists(os.path.join(localedir, "cs", "LC_MESSAGES", DOMAIN + ".mo")):
707        print_error("Translation file not found!")
708    
709    app = DownloaderGUI()
710    app.mainloop()