|
| 1 | +import time |
| 2 | +import tkinter as tk |
| 3 | +from datetime import date |
| 4 | +from pathlib import Path |
| 5 | +from threading import Thread |
| 6 | +from tkinter import messagebox, ttk |
| 7 | +from typing import TYPE_CHECKING, Optional |
| 8 | +from urllib.parse import ParseResult, urlparse |
| 9 | + |
| 10 | +import requests |
| 11 | +from core_helpers.logs import logger |
| 12 | + |
| 13 | +if TYPE_CHECKING: |
| 14 | + from . import VideoDownloaderUI |
| 15 | + |
| 16 | +from ..consts import AUTHOR, MB |
| 17 | +from ..consts import __version__ as VERSION |
| 18 | +from ..scraper import extract_video_info, fetch_web_content |
| 19 | +from ..translations import get_translations |
| 20 | +from ..utils import format_file_size |
| 21 | + |
| 22 | + |
| 23 | +def _create_pop_up_window(gui: "VideoDownloaderUI", title: str, message: str) -> None: |
| 24 | + """ |
| 25 | + Create a pop-up window with the given title and message. |
| 26 | +
|
| 27 | + Args: |
| 28 | + gui (VideoDownloaderUI): The GUI instance. |
| 29 | + title (str): The title of the pop-up window. |
| 30 | + message (str): The message to display in the pop-up window. |
| 31 | + """ |
| 32 | + # Create a top-level overlay window rather than using system messagebox |
| 33 | + help_win = tk.Toplevel(gui.win) |
| 34 | + help_win.title(title) |
| 35 | + help_win.geometry("450x250") |
| 36 | + help_win.minsize(650, 200) |
| 37 | + |
| 38 | + # Configure the inner frame wrapper to scale cleanly on resize mechanics |
| 39 | + frame = ttk.Frame(help_win, padding="15") |
| 40 | + frame.pack(expand=True, fill="both") |
| 41 | + frame.columnconfigure(0, weight=1) |
| 42 | + frame.rowconfigure(0, weight=1) |
| 43 | + |
| 44 | + # Message widget scales text wrapping seamlessly when width parameters shift |
| 45 | + msg = tk.Message(frame, text=message, justify="left", aspect=200, width=600) |
| 46 | + msg.grid(row=0, column=0, sticky="nsew", pady=(0, 10)) |
| 47 | + |
| 48 | + close_btn = ttk.Button(frame, text="OK", command=help_win.destroy) |
| 49 | + close_btn.grid(row=1, column=0, pady=5) |
| 50 | + |
| 51 | + |
| 52 | +def start_download_thread(gui: VideoDownloaderUI) -> None: |
| 53 | + """ |
| 54 | + Callback function to handle the download button click event. |
| 55 | +
|
| 56 | + Retrieves the video URL from the input field, fetches the web content, |
| 57 | + extracts video information, and downloads the video while displaying |
| 58 | + progress in the GUI. |
| 59 | + """ |
| 60 | + logger.debug("Download button clicked") |
| 61 | + |
| 62 | + if gui.downloading: |
| 63 | + gui.log_message(gui.translations["already_downloading_message"]) |
| 64 | + logger.warning("Download already in progress!") |
| 65 | + return |
| 66 | + |
| 67 | + url: str = gui.a_url.get() |
| 68 | + if not url: |
| 69 | + messagebox.showwarning( |
| 70 | + gui.translations["warning_title"], |
| 71 | + gui.translations["empty_url_message"], |
| 72 | + ) |
| 73 | + logger.warning("No URL provided for download!") |
| 74 | + return |
| 75 | + |
| 76 | + gui.log_message(f"Starting download from: {url}") |
| 77 | + |
| 78 | + r: requests.Response | None = handle_web_content_fetch(gui, url) |
| 79 | + if r is None: |
| 80 | + return |
| 81 | + |
| 82 | + video: str | None = extract_and_display_video_info(gui, url, r.text) |
| 83 | + if not video: |
| 84 | + return |
| 85 | + |
| 86 | + resp: requests.Response | None = handle_web_content_fetch(gui, video, stream=True) |
| 87 | + if resp is None: |
| 88 | + return |
| 89 | + |
| 90 | + try: |
| 91 | + thread = Thread( |
| 92 | + target=download_video_and_display_progress, |
| 93 | + args=(gui, resp), |
| 94 | + daemon=True, |
| 95 | + ) |
| 96 | + thread.start() |
| 97 | + except Exception as e: |
| 98 | + gui.log_message(gui.translations["download_failed_message"].format(str(e))) |
| 99 | + logger.error("Error downloading video from %s", url, exc_info=True) |
| 100 | + gui.downloading = False |
| 101 | + raise |
| 102 | + |
| 103 | + |
| 104 | +def handle_web_content_fetch( |
| 105 | + gui: VideoDownloaderUI, url: str, stream: bool = False |
| 106 | +) -> Optional[requests.Response]: |
| 107 | + """ |
| 108 | + Fetch web content from the given URL and handle exceptions. |
| 109 | +
|
| 110 | + Args: |
| 111 | + url (str): The URL to fetch web content from. |
| 112 | + stream (bool): Whether to stream the response content. Defaults to False. |
| 113 | +
|
| 114 | + Returns: |
| 115 | + requests.Response | None: The response object if successful, None |
| 116 | + otherwise. |
| 117 | + """ |
| 118 | + logger.debug("Fetching and handling web content for URL: %s", url) |
| 119 | + |
| 120 | + try: |
| 121 | + response: requests.Response = fetch_web_content(url, stream=stream) |
| 122 | + logger.info( |
| 123 | + "Web content fetched successfully for %s: %s", url, response.status_code |
| 124 | + ) |
| 125 | + return response |
| 126 | + except requests.exceptions.RequestException as e: |
| 127 | + if e.response is not None: |
| 128 | + error_message = e.response.status_code |
| 129 | + else: |
| 130 | + error_message = str(e) |
| 131 | + gui.log_message(gui.translations["failure_message"].format(error_message)) |
| 132 | + logger.error( |
| 133 | + "Failed to fetch content from %s: %s", url, error_message, exc_info=True |
| 134 | + ) |
| 135 | + return None |
| 136 | + |
| 137 | + |
| 138 | +def extract_and_display_video_info( |
| 139 | + gui: VideoDownloaderUI, url: str, html: str |
| 140 | +) -> Optional[str]: |
| 141 | + """ |
| 142 | + Extract and display video information. |
| 143 | +
|
| 144 | + Args: |
| 145 | + url (str): The URL of the video page. |
| 146 | + html (str): The HTML content of the page. |
| 147 | +
|
| 148 | + Returns: |
| 149 | + str: The video source URL if found, otherwise an empty string. |
| 150 | + """ |
| 151 | + logger.debug("Extracting and displaying video information for URL: %s", url) |
| 152 | + |
| 153 | + try: |
| 154 | + title, video = extract_video_info(html) |
| 155 | + except ValueError as e: |
| 156 | + gui.log_message(gui.translations["video_not_found"].format(str(e))) |
| 157 | + logger.error("Video information not found in the web content", exc_info=True) |
| 158 | + return "" |
| 159 | + |
| 160 | + # Display the translated messages and labels |
| 161 | + gui.log_message(gui.translations["download_video_message"].format(url)) |
| 162 | + gui.log_message(gui.translations["video_title"].format(title)) |
| 163 | + gui.log_message(gui.translations["video_url"].format(video)) |
| 164 | + logger.debug("Extracted video info: Title: %r, URL: %s", title, video) |
| 165 | + |
| 166 | + return video |
| 167 | + |
| 168 | + |
| 169 | +def download_video_and_display_progress( |
| 170 | + gui: VideoDownloaderUI, resp: requests.Response |
| 171 | +) -> None: |
| 172 | + """gui |
| 173 | + Download the video and display the download progress. |
| 174 | +
|
| 175 | + Args: |
| 176 | + resp (requests.Response): The response object containing the video data. |
| 177 | + """ |
| 178 | + logger.debug("Starting video download for URL: %s", resp.url) |
| 179 | + |
| 180 | + content_size = int(resp.headers["Content-Length"]) |
| 181 | + parsed_url: ParseResult = urlparse(resp.url) |
| 182 | + if not parsed_url.path: |
| 183 | + gui.win.after( |
| 184 | + 0, |
| 185 | + lambda: gui.log_message( |
| 186 | + gui.translations["invalid_url_message"].format(resp.url) |
| 187 | + ), |
| 188 | + ) |
| 189 | + logger.error("Invalid URL: %s", resp.url) |
| 190 | + return |
| 191 | + |
| 192 | + content_size_text: str = format_file_size(content_size) |
| 193 | + gui.win.after( |
| 194 | + 0, |
| 195 | + lambda: gui.log_message( |
| 196 | + gui.translations["total_size_message"].format(content_size_text) |
| 197 | + ), |
| 198 | + ) |
| 199 | + logger.debug("Total size of the video: %s", content_size_text) |
| 200 | + |
| 201 | + # Extract the media extension from the URL path |
| 202 | + file_extension: str = parsed_url.path.split(".")[-1] |
| 203 | + file_name: str = parsed_url.path.split("/")[-1] or f"video.{file_extension}" |
| 204 | + # Create the file path with the correct extension |
| 205 | + file_path: Path = Path(file_name).with_suffix(f".{file_extension}") |
| 206 | + |
| 207 | + # Set up progress bar |
| 208 | + gui.progress_bar["maximum"] = content_size |
| 209 | + gui.progress_bar["value"] = 0 |
| 210 | + gui.progress_percent.set("0%") |
| 211 | + # Reset flags |
| 212 | + gui.cancel_download = False |
| 213 | + gui.downloading = True |
| 214 | + # Ensure initial draw |
| 215 | + gui.win.update_idletasks() |
| 216 | + |
| 217 | + downloaded = 0 |
| 218 | + start_time: float = time.time() |
| 219 | + |
| 220 | + with open(file_path, mode="wb") as f: |
| 221 | + |
| 222 | + def update_progress() -> None: |
| 223 | + gui.progress_bar["value"] = downloaded |
| 224 | + gui.progress_percent.set(f"{percent:.1f}%") |
| 225 | + |
| 226 | + for chunk in resp.iter_content(MB): |
| 227 | + if gui.cancel_download: |
| 228 | + # Reset progress bar, label and flags |
| 229 | + gui.progress_bar["value"] = 0 |
| 230 | + gui.progress_percent.set("0%") |
| 231 | + gui.cancel_download = False # reset cancel flag |
| 232 | + gui.downloading = False |
| 233 | + logger.info("Download cancelled by user.") |
| 234 | + return |
| 235 | + |
| 236 | + if not chunk: |
| 237 | + continue |
| 238 | + |
| 239 | + f.write(chunk) |
| 240 | + downloaded += len(chunk) |
| 241 | + |
| 242 | + percent: float = (downloaded / content_size) * 100 |
| 243 | + elapsed: float = time.time() - start_time |
| 244 | + rate: float = downloaded / elapsed if elapsed > 0 else 0 |
| 245 | + remaining: float = (content_size - downloaded) / rate if rate > 0 else 0 |
| 246 | + eta: str = time.strftime("%M:%S", time.gmtime(remaining)) |
| 247 | + |
| 248 | + gui.win.after(0, update_progress) |
| 249 | + |
| 250 | + gui.downloading = False |
| 251 | + gui.win.after( |
| 252 | + 0, lambda: gui.log_message(gui.translations["download_complete_message"]) |
| 253 | + ) |
| 254 | + gui.win.after( |
| 255 | + 0, lambda: gui.log_message(gui.translations["video_saved"].format(file_path)) |
| 256 | + ) |
| 257 | + logger.info("Download complete for %r", file_path) |
| 258 | + |
| 259 | + |
| 260 | +def quit_app(gui: VideoDownloaderUI) -> None: |
| 261 | + """ |
| 262 | + Callback function to handle the quit button click event. |
| 263 | +
|
| 264 | + Closes the main application window and exits the program. |
| 265 | + """ |
| 266 | + logger.debug("Quit button clicked") |
| 267 | + |
| 268 | + gui.win.quit() |
| 269 | + gui.win.destroy() |
| 270 | + logger.debug("Destroyed the main window") |
| 271 | + |
| 272 | + |
| 273 | +def show_about(gui: VideoDownloaderUI) -> None: |
| 274 | + """ |
| 275 | + Callback function to handle the about button click event. |
| 276 | +
|
| 277 | + Displays an informational message box with author, version, and current |
| 278 | + date. |
| 279 | + """ |
| 280 | + logger.debug("About button clicked") |
| 281 | + |
| 282 | + today: str = date.today().strftime("%d/%m/%Y") |
| 283 | + about_message: str = gui.translations["about_message"].format( |
| 284 | + AUTHOR, VERSION, today |
| 285 | + ) |
| 286 | + messagebox.showinfo(gui.translations["about_button"], about_message) |
| 287 | + |
| 288 | + |
| 289 | +def show_help(gui: VideoDownloaderUI) -> None: |
| 290 | + """ |
| 291 | + Callback function to handle the help button click event. |
| 292 | +
|
| 293 | + Displays an informational message box with usage instructions or help |
| 294 | + content. |
| 295 | + """ |
| 296 | + logger.debug("Help button clicked") |
| 297 | + help_message: str = gui.translations["help_message"] |
| 298 | + messagebox.showinfo(gui.translations["help_button"], help_message) |
| 299 | + |
| 300 | + |
| 301 | +def cancel_download(gui: VideoDownloaderUI) -> None: |
| 302 | + """Sets the cancel flag to True.""" |
| 303 | + logger.debug("Cancel download button clicked") |
| 304 | + if not gui.downloading: |
| 305 | + gui.log_message(gui.translations["no_download_message"]) |
| 306 | + logger.warning("No download in progress to cancel.") |
| 307 | + return |
| 308 | + |
| 309 | + gui.cancel_download = True |
| 310 | + gui.log_message(gui.translations["cancelled_message"]) |
| 311 | + |
| 312 | + |
| 313 | +def change_language(gui: VideoDownloaderUI, lang_code: str) -> None: |
| 314 | + if gui.current_language == lang_code: |
| 315 | + logger.debug("Language change requested but already set to %s", lang_code) |
| 316 | + gui.log_message(gui.translations["language_already_set"].format(lang_code)) |
| 317 | + return |
| 318 | + |
| 319 | + gui.translations = get_translations(lang_code) |
| 320 | + gui.current_language = lang_code |
| 321 | + |
| 322 | + # Update the OS Window title |
| 323 | + gui.win.title(gui.translations["window_title"]) |
| 324 | + |
| 325 | + # Update buttons |
| 326 | + gui.help_btn.config(text=gui.translations["help_button"]) |
| 327 | + gui.about_btn.config(text=gui.translations["about_button"]) |
| 328 | + |
| 329 | + # Update other componentes |
| 330 | + gui.url_frame.config(text=gui.translations["enter_url_label"]) |
| 331 | + gui.log_frame.config(text=gui.translations["log_frame_label"]) |
| 332 | + gui.lang_label.config(text=gui.translations["language"]) |
| 333 | + |
| 334 | + gui.log_message(gui.translations["language_changed_message"].format(lang_code)) |
0 commit comments