Files

1226 lines
53 KiB
Python

import datetime
import json
import queue
import sys
import re
import os
import threading
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, scrolledtext
from typing import Optional
from urllib.parse import ParseResult, parse_qs, urlparse
import webbrowser
import requests
from PIL import Image
import customtkinter as ctk
from PIL import Image, ImageTk
import psutil
import functools
import subprocess
#from app.patch_notes import PatchNotes
from app.settings_window import SettingsWindow
#from app.user_panel import UserPanel
from app.about_window import AboutWindow
from downloader.bunkr import BunkrDownloader
from downloader.downloader import Downloader
from downloader.erome import EromeDownloader
from downloader.simpcity import SimpCity
from downloader.jpg5 import Jpg5Downloader
from app.progress_manager import ProgressManager
from app.donors import DonorsModal
VERSION = "V0.8.12"
MAX_LOG_LINES = None
def extract_ck_parameters(url: ParseResult) -> tuple[Optional[str], Optional[str], Optional[str]]:
"""
Get the service, user and post id from the url if they exist
"""
match = re.search(r"/(?P<service>[^/?]+)(/user/(?P<user>[^/?]+)(/post/(?P<post>[^/?]+))?)?", url.path)
if match:
[site, service, post] = match.group("service", "user", "post")
return site, service, post
else:
return None, None, None
def extract_ck_query(url: ParseResult) -> tuple[Optional[str], int]:
"""
Try to obtain the query and offset from the url if they exist
"""
# This is kinda contrived but query parameters are awful to get right
query = parse_qs(url.query)
q = query.get("q")[0] if query.get("q") is not None and len(query.get("q")) > 0 else "0"
o = query.get("o")[0] if query.get("o") is not None and len(query.get("o")) > 0 else "0"
return q, int(o) if str.isdigit(o) else 0
# Application class
class ImageDownloaderApp(ctk.CTk):
def __init__(self):
self.errors = []
self._log_buffer = []
self.github_stars = 0
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("dark-blue")
super().__init__()
self.version = VERSION
self.title(f"Downloader [{VERSION}]")
# Setup window
self.setup_window()
# Settings window
self.settings_window = SettingsWindow(
self,
self.tr,
self.load_translations,
self.update_ui_texts,
self.save_language_preference,
VERSION,
None, # Por ahora, no se pasa ningún downloader
self.check_for_new_version,
on_settings_changed=self.apply_runtime_settings
)
self.all_logs = [] # Lista para almacenar todos los logs
# About window
self.about_window = AboutWindow(self, self.tr, VERSION) # Inicializa AboutWindow
self.extras_window = None
# Load settings
self.settings = self.settings_window.load_settings()
# Language preferences
lang = self.load_language_preference()
self.load_translations(lang)
self.image_downloader = None
# Patch notes
#self.patch_notes = PatchNotes(self, self.tr)
self.progress_bars = {}
# Obtener el número de estrellas de GitHub
self.github_stars = self.get_github_stars("emy69", "CoomerDL")
# Cargar el icono de GitHub
self.github_icon = self.load_github_icon()
self.autoscroll_logs_var = tk.BooleanVar(value=False)
# Initialize UI
self.initialize_ui()
self.update_ui_texts()
self.update_queue = queue.Queue()
self.check_update_queue()
self.protocol("WM_DELETE_WINDOW", self.on_app_close)
self.download_start_time = None
self.errors = []
self.warnings = []
# Load all settings with defaults from the settings object
self.max_downloads = self.settings.get('max_downloads', 3)
max_retries_setting = self.settings.get('max_retries', 3)
retry_interval_setting = self.settings.get('retry_interval', 2.0)
folder_structure_setting = self.settings.get('folder_structure', 'default')
# Load download folder
self.download_folder = self.load_download_folder()
if self.download_folder:
self.folder_path.configure(text=self.download_folder)
self.default_downloader = Downloader(
download_folder=self.download_folder,
max_workers=self.max_downloads,
log_callback=self.add_log_message_safe,
update_progress_callback=self.update_progress,
update_global_progress_callback=self.update_global_progress,
tr=self.tr,
retry_interval=retry_interval_setting,
folder_structure=folder_structure_setting,
max_retries=max_retries_setting
)
self.settings_window.downloader = self.default_downloader
self.active_downloader = None # Initialize active_downloader
# Cargar iconos redimensionados
self.icons = {
'image': self.load_and_resize_image('resources/img/iconos/ui/image_icon.png', (40, 40)),
'video': self.load_and_resize_image('resources/img/iconos/ui/video.png', (40, 40)),
'zip': self.load_and_resize_image('resources/img/iconos/ui/file-zip.png', (40, 40)),
'default': self.load_and_resize_image('resources/img/iconos/ui/default_icon.png', (40, 40))
}
# Progress manager
self.progress_manager = ProgressManager(
root=self,
icons=self.icons,
footer_speed_label=self.footer_speed_label,
footer_eta_label=self.footer_eta_label,
progress_bar=self.progress_bar,
progress_percentage=self.progress_percentage
)
# Check for new version on startup
threading.Thread(target=self.check_for_new_version, args=(True,)).start()
# Application close event
def on_app_close(self):
if self.is_download_active() and not self.active_downloader.cancel_requested:
# Mostrar advertencia si hay una descarga activa
messagebox.showwarning(
self.tr("Descarga Activa"),
self.tr("Hay una descarga en progreso. Por favor, cancela la descarga antes de cerrar.")
)
else:
self.destroy()
def is_download_active(self):
return self.active_downloader is not None
def close_program(self):
# Cierra todas las ventanas y termina el proceso principal
self.destroy()
# Matar el proceso actual (eliminar del administrador de tareas)
current_process = psutil.Process(os.getpid())
for handler in current_process.children(recursive=True):
handler.kill()
current_process.kill()
# Save and load language preferences
def save_language_preference(self, language_code):
config = {'language': language_code}
with open('resources/config/languages/save_language/language_config.json', 'w') as config_file:
json.dump(config, config_file)
self.load_translations(language_code)
self.update_ui_texts()
def load_language_preference(self):
try:
with open('resources/config/languages/save_language/language_config.json', 'r') as config_file:
config = json.load(config_file)
return config.get('language', 'en')
except FileNotFoundError:
return 'en'
# Load translations
def load_translations(self, lang):
path = "resources/config/languages/translations.json"
with open(path, 'r', encoding='utf-8') as file:
all_translations = json.load(file)
self.translations = {key: value.get(lang, key) for key, value in all_translations.items()}
def tr(self, text, **kwargs):
translated_text = self.translations.get(text, text)
if kwargs:
translated_text = translated_text.format(**kwargs)
return translated_text
def apply_runtime_settings(self, new_settings: dict):
"""
Se llama cuando se guardan cambios en Settings.
Aplica cambios en caliente (sin reiniciar la app).
"""
try:
self.settings = new_settings
# Max downloads (concurrencia)
self.max_downloads = int(new_settings.get("max_downloads", 3) or 3)
# Downloader principal
if hasattr(self, "default_downloader") and self.default_downloader:
dd = self.default_downloader
dd.max_workers = self.max_downloads
dd.folder_structure = new_settings.get("folder_structure", "default")
dd.size_filter_enabled = bool(new_settings.get("size_filter_enabled", False))
dd.min_size = float(new_settings.get("min_size_mb", 0) or 0) * 1024 * 1024
dd.max_size = float(new_settings.get("max_size_mb", 0) or 0) * 1024 * 1024
# modo de nombre de archivo (si lo usas)
try:
dd.file_naming_mode = int(new_settings.get("file_naming_mode", 0) or 0)
except Exception:
pass
# reintentos por archivo (si tu downloader lo soporta)
try:
dd.download_retry_attempts = int(new_settings.get("download_retry_attempts", 3) or 3)
except Exception:
pass
if hasattr(self, "refresh_download_settings"):
try:
self.refresh_download_settings()
except Exception:
pass
self.add_log_message_safe("Settings applied.")
except Exception as e:
try:
self.add_log_message_safe(f"Error applying settings: {e}")
except Exception:
pass
# Window setup
def setup_window(self):
window_width, window_height = 1000, 600
center_x = int((self.winfo_screenwidth() / 2) - (window_width / 2))
center_y = int((self.winfo_screenheight() / 2) - (window_height / 2))
self.geometry(f"{window_width}x{window_height}+{center_x}+{center_y}")
if sys.platform == "win32":
self.iconbitmap("resources/img/window.ico")
# Initialize UI components
def initialize_ui(self):
# Crear la barra de menú personalizada
self.menu_bar = ctk.CTkFrame(self, height=30, corner_radius=0)
self.menu_bar.pack(side="top", fill="x")
# Añadir botones al menú
self.create_custom_menubar()
# Update alert frame (initially hidden)
self.update_alert_frame = ctk.CTkFrame(self, fg_color="#4CAF50", corner_radius=0) # Green background
self.update_alert_frame.pack(side="top", fill="x")
self.update_alert_frame.pack_forget() # Hide initially
self.update_alert_label = ctk.CTkLabel(self.update_alert_frame, text="", text_color="white", font=("Arial", 12, "bold"))
self.update_alert_label.pack(side="left", padx=10, pady=5)
self.update_download_button = ctk.CTkButton(self.update_alert_frame, text=self.tr("Download Now"), command=self.open_latest_release, fg_color="#388E3C", hover_color="#2E7D32")
self.update_download_button.pack(side="right", padx=10, pady=5)
# Input frame
self.input_frame = ctk.CTkFrame(self)
self.input_frame.pack(fill='x', padx=20, pady=20)
self.input_frame.grid_columnconfigure(0, weight=1)
self.input_frame.grid_rowconfigure(1, weight=1)
self.url_label = ctk.CTkLabel(self.input_frame, text=self.tr("URL de la página web:"))
self.url_label.grid(row=0, column=0, sticky='w')
self.url_entry = ctk.CTkEntry(self.input_frame)
self.url_entry.grid(row=1, column=0, sticky='ew', padx=(0, 5))
self.browse_button = ctk.CTkButton(self.input_frame, text=self.tr("Seleccionar Carpeta"), command=self.select_folder)
self.browse_button.grid(row=1, column=1, sticky='e')
self.folder_path = ctk.CTkLabel(self.input_frame, text="", cursor="hand2", font=("Arial", 13))
self.folder_path.grid(row=2, column=0, columnspan=2, sticky='w')
self.folder_path.bind("<Button-1>", self.open_download_folder)
# Añadir eventos para el efecto hover
self.folder_path.bind("<Enter>", self.on_hover_enter)
self.folder_path.bind("<Leave>", self.on_hover_leave)
# Options frame
self.options_frame = ctk.CTkFrame(self)
self.options_frame.pack(pady=10, fill='x', padx=20)
self.download_images_check = ctk.CTkCheckBox(self.options_frame, text=self.tr("Descargar Imágenes"))
self.download_images_check.pack(side='left', padx=10)
self.download_images_check.select()
self.download_videos_check = ctk.CTkCheckBox(self.options_frame, text=self.tr("Descargar Vídeos"))
self.download_videos_check.pack(side='left', padx=10)
self.download_videos_check.select()
self.download_compressed_check = ctk.CTkCheckBox(self.options_frame, text=self.tr("Descargar Comprimidos"))
self.download_compressed_check.pack(side='left', padx=10)
self.download_compressed_check.select()
# Action frame
self.action_frame = ctk.CTkFrame(self)
self.action_frame.pack(pady=10, fill='x', padx=20)
self.download_button = ctk.CTkButton(self.action_frame, text=self.tr("Descargar"), command=self.start_download)
self.download_button.pack(side='left', padx=10)
self.cancel_button = ctk.CTkButton(self.action_frame, text=self.tr("Cancelar Descarga"), state="disabled", command=self.cancel_download)
self.cancel_button.pack(side='left', padx=10)
# Logs options (autoscroll)
self.autoscroll_logs_checkbox = ctk.CTkCheckBox(
self.action_frame,
text=self.tr("Auto-scroll logs"),
variable=self.autoscroll_logs_var
)
self.autoscroll_logs_checkbox.pack(side="right")
self.progress_label = ctk.CTkLabel(self.action_frame, text="")
self.progress_label.pack(side='left', padx=10)
self.log_textbox = ctk.CTkTextbox(self, width=590, height=200)
self.log_textbox.pack(pady=(10, 0), padx=20, fill='both', expand=True)
self.log_textbox.configure(state="disabled")
# Progress frame
self.progress_frame = ctk.CTkFrame(self)
self.progress_frame.pack(pady=(0, 10), fill='x', padx=20)
self.progress_bar = ctk.CTkProgressBar(self.progress_frame)
self.progress_bar.pack(side='left', fill='x', expand=True, padx=(0, 10))
# self.processing_label = ctk.CTkLabel(self.progress_frame, text=self.tr("Procesando videos..."), font=("Arial", 12))
# self.processing_label.pack(side='top', pady=(0, 10))
# self.processing_label.pack_forget()
self.progress_percentage = ctk.CTkLabel(self.progress_frame, text="0%")
self.progress_percentage.pack(side='left')
# Cargar el icono de descarga con un tamaño mayor
self.download_icon = self.load_and_resize_image('resources/img/iconos/ui/download_icon.png', (24, 24)) # Cambiado a (24, 24)
# Reemplazar el botón con una etiqueta que simule un botón
self.toggle_details_button = ctk.CTkLabel(self.progress_frame, image=self.download_icon, text="", cursor="hand2")
self.toggle_details_button.pack(side='left', padx=(5, 0))
self.toggle_details_button.bind("<Button-1>", lambda e: self.toggle_progress_details())
# Agregar efecto hover
self.toggle_details_button.bind("<Enter>", lambda e: self.toggle_details_button.configure(fg_color="gray25"))
self.toggle_details_button.bind("<Leave>", lambda e: self.toggle_details_button.configure(fg_color="transparent"))
self.progress_details_frame = ctk.CTkFrame(self)
self.progress_details_frame.place_forget()
# Context menu
self.context_menu = tk.Menu(self.url_entry, tearoff=0)
self.context_menu.add_command(label=self.tr("Copiar"), command=self.copy_to_clipboard)
self.context_menu.add_command(label=self.tr("Pegar"), command=self.paste_from_clipboard)
self.context_menu.add_command(label=self.tr("Cortar"), command=self.cut_to_clipboard)
self.url_entry.bind("<Button-3>", self.show_context_menu)
self.bind("<Button-1>", self.on_click)
footer = ctk.CTkFrame(self, height=30, corner_radius=0)
footer.pack(side="bottom", fill="x")
self.footer_eta_label = ctk.CTkLabel(footer, text="ETA: N/A", font=("Arial", 11))
self.footer_eta_label.pack(side="left", padx=20)
self.footer_speed_label = ctk.CTkLabel(footer, text="Speed: 0 KB/s", font=("Arial", 11))
self.footer_speed_label.pack(side="right", padx=20)
# Actualizar textos después de inicializar la UI
self.update_ui_texts()
# Update UI texts
def update_ui_texts(self):
# Actualizar textos de los botones del menú
for widget in self.menu_bar.winfo_children():
if isinstance(widget, ctk.CTkButton):
text = widget.cget("text")
if text.strip() in ["Archivo", "Ayuda", "Donaciones", "About", "Patreons"]:
widget.configure(text=self.tr(text.strip()))
# Si los menús están abiertos, recrearlos para actualizar los textos
if self.archivo_menu_frame and self.archivo_menu_frame.winfo_exists():
self.archivo_menu_frame.destroy()
self.toggle_archivo_menu()
self.url_label.configure(text=self.tr("URL de la página web:"))
self.browse_button.configure(text=self.tr("Seleccionar Carpeta"))
self.download_images_check.configure(text=self.tr("Descargar Imágenes"))
self.download_videos_check.configure(text=self.tr("Descargar Vídeos"))
self.download_compressed_check.configure(text=self.tr("Descargar Comprimidos"))
self.download_button.configure(text=self.tr("Descargar"))
self.cancel_button.configure(text=self.tr("Cancelar Descarga"))
# self.processing_label.configure(text=self.tr("Procesando videos..."))
self.title(self.tr(f"Downloader [{VERSION}]"))
self.update_download_button.configure(text=self.tr("Download Now"))
def open_download_folder(self, event=None):
if self.download_folder and os.path.exists(self.download_folder):
if sys.platform == "win32":
os.startfile(self.download_folder) # Para Windows
elif sys.platform == "darwin":
subprocess.Popen(["open", self.download_folder]) # Para macOS
else:
subprocess.Popen(["xdg-open", self.download_folder]) # Para Linux
else:
messagebox.showerror(self.tr("Error"), self.tr("La carpeta no existe o no es válida."))
def on_click(self, event):
# Obtener la lista de widgets que no deben cerrar el menú al hacer clic
widgets_to_ignore = [self.menu_bar]
# Añadir los frames de los menús desplegables si existen
for frame in [self.archivo_menu_frame, self.ayuda_menu_frame, self.donaciones_menu_frame]:
if frame and frame.winfo_exists():
widgets_to_ignore.append(frame)
widgets_to_ignore.extend(self.get_all_children(frame))
# Si el widget en el que se hizo clic no es ninguno de los que debemos ignorar, cerramos los menús
if event.widget not in widgets_to_ignore:
self.close_all_menus()
def get_all_children(self, widget):
children = widget.winfo_children()
all_children = list(children)
for child in children:
all_children.extend(self.get_all_children(child))
return all_children
def create_custom_menubar(self):
# Botón Archivo
archivo_button = ctk.CTkButton(
self.menu_bar,
text=self.tr("Archivo"),
width=80,
fg_color="transparent",
hover_color="gray25",
command=self.toggle_archivo_menu
)
archivo_button.pack(side="left")
archivo_button.bind("<Button-1>", lambda e: "break")
# Botón About
about_button = ctk.CTkButton(
self.menu_bar,
text=self.tr("About"),
width=80,
fg_color="transparent",
hover_color="gray25",
command=self.about_window.show_about
)
about_button.pack(side="left")
about_button.bind("<Button-1>", lambda e: "break")
# Botón Donors
donors_button = ctk.CTkButton(
self.menu_bar,
text=self.tr("Patreons"),
width=80,
fg_color="transparent",
hover_color="gray25",
command=self.show_donors_modal
)
donors_button.pack(side="left")
donors_button.bind("<Button-1>", lambda e: "break")
# Inicializar variables para los menús desplegables
self.archivo_menu_frame = None
self.ayuda_menu_frame = None
self.donaciones_menu_frame = None
# Función para cambiar el fondo al pasar el ratón
def on_enter(event, frame):
frame.configure(fg_color="gray25")
def on_leave(event, frame):
frame.configure(fg_color="transparent")
# Añadir el icono de GitHub y el contador de estrellas
if self.github_icon:
resized_github_icon = self.github_icon.resize((16, 16), Image.Resampling.LANCZOS)
resized_github_icon = ctk.CTkImage(resized_github_icon)
github_frame = ctk.CTkFrame(self.menu_bar,cursor="hand2", fg_color="transparent", corner_radius=5)
github_frame.pack(side="right", padx=5)
github_label = ctk.CTkLabel(
github_frame,
image=resized_github_icon,
text=f" Star {self.github_stars}",
compound="left",
font=("Arial", 12)
)
github_label.pack(padx=5, pady=5)
github_frame.bind("<Enter>", lambda e: on_enter(e, github_frame))
github_frame.bind("<Leave>", lambda e: on_leave(e, github_frame))
github_label.bind("<Enter>", lambda e: on_enter(e, github_frame))
github_label.bind("<Leave>", lambda e: on_leave(e, github_frame))
github_label.bind("<Button-1>", lambda e: webbrowser.open("https://github.com/emy69/CoomerDL"))
# Añadir el icono de Discord
self.discord_icon = self.load_discord_icon()
if self.discord_icon:
resized_discord_icon = self.discord_icon.resize((16, 16), Image.Resampling.LANCZOS)
resized_discord_icon = ctk.CTkImage(resized_discord_icon)
discord_frame = ctk.CTkFrame(self.menu_bar,cursor="hand2", fg_color="transparent", corner_radius=5)
discord_frame.pack(side="right", padx=5)
discord_label = ctk.CTkLabel(
discord_frame,
image=resized_discord_icon,
text="Discord",
compound="left"
)
discord_label.pack(padx=5, pady=5)
discord_frame.bind("<Enter>", lambda e: on_enter(e, discord_frame))
discord_frame.bind("<Leave>", lambda e: on_leave(e, discord_frame))
discord_label.bind("<Enter>", lambda e: on_enter(e, discord_frame))
discord_label.bind("<Leave>", lambda e: on_leave(e, discord_frame))
discord_label.bind("<Button-1>", lambda e: webbrowser.open("https://discord.gg/ku8gSPsesh"))
# Añadir un nuevo icono PNG
self.new_icon = self.load_patreon_icon()
if self.new_icon:
resized_new_icon = self.new_icon.resize((16, 16), Image.Resampling.LANCZOS)
resized_new_icon = ctk.CTkImage(resized_new_icon)
new_icon_frame = ctk.CTkFrame(self.menu_bar,cursor="hand2", fg_color="transparent", corner_radius=5)
new_icon_frame.pack(side="right", padx=5)
new_icon_label = ctk.CTkLabel(
new_icon_frame,
image=resized_new_icon,
text="Patreon",
compound="left"
)
new_icon_label.pack(padx=5, pady=5)
new_icon_frame.bind("<Enter>", lambda e: on_enter(e, new_icon_frame))
new_icon_frame.bind("<Leave>", lambda e: on_leave(e, new_icon_frame))
new_icon_label.bind("<Enter>", lambda e: on_enter(e, new_icon_frame))
new_icon_label.bind("<Leave>", lambda e: on_leave(e, new_icon_frame))
new_icon_label.bind("<Button-1>", lambda e: webbrowser.open("https://www.patreon.com/Emy69"))
def show_donors_modal(self):
donors_modal = DonorsModal(self, self.tr)
donors_modal.focus_set()
def toggle_archivo_menu(self):
if self.archivo_menu_frame and self.archivo_menu_frame.winfo_exists():
self.archivo_menu_frame.destroy()
else:
self.close_all_menus()
self.archivo_menu_frame = self.create_menu_frame([
(self.tr("Configuraciones"), self.settings_window.open_settings),
("separator", None),
(self.tr("Salir"), self.quit),
], x=0)
def create_menu_frame(self, options, x):
# Crear el marco del menú con fondo oscuro y borde de sombra para resaltar
menu_frame = ctk.CTkFrame(self, corner_radius=5, fg_color="gray25", border_color="black", border_width=1)
menu_frame.place(x=x, y=30)
# Agregar sombra alrededor del menú
menu_frame.configure(border_width=1, border_color="black")
# Evitar la propagación del clic en el menú
menu_frame.bind("<Button-1>", lambda e: "break")
# Añadir opciones al menú con separación entre elementos
for option in options:
if option[0] == "separator":
separator = ctk.CTkFrame(menu_frame, height=1, fg_color="gray50")
separator.pack(fill="x", padx=5, pady=5)
separator.bind("<Button-1>", lambda e: "break")
elif option[1] is None:
# Texto sin comando (por ejemplo, título de submenú)
label = ctk.CTkLabel(menu_frame, text=option[0], anchor="w", fg_color="gray30")
label.pack(fill="x", padx=5, pady=2)
label.bind("<Button-1>", lambda e: "break")
else:
btn = ctk.CTkButton(
menu_frame,
text=option[0],
fg_color="transparent",
hover_color="gray35",
anchor="w",
text_color="white",
command=lambda cmd=option[1]: cmd()
)
btn.pack(fill="x", padx=5, pady=2)
btn.bind("<Button-1>", lambda e: "break")
return menu_frame
def close_all_menus(self):
for menu_frame in [self.archivo_menu_frame, self.ayuda_menu_frame, self.donaciones_menu_frame]:
if menu_frame and menu_frame.winfo_exists():
menu_frame.destroy()
# Image processing
def create_photoimage(self, path, size=(32, 32)):
img = Image.open(path)
img = img.resize(size, Image.Resampling.LANCZOS)
photoimg = ImageTk.PhotoImage(img)
return photoimg
# Setup downloaders
def setup_erome_downloader(self, is_profile_download=False):
self.erome_downloader = EromeDownloader(
root=self,
enable_widgets_callback=self.enable_widgets,
headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, como Gecko) Chrome/58.0.3029.110 Safari/537.36',
'Referer': 'https://www.erome.com/'
},
log_callback=self.add_log_message_safe,
update_progress_callback=self.update_progress,
update_global_progress_callback=self.update_global_progress,
download_images=self.download_images_check.get(),
download_videos=self.download_videos_check.get(),
is_profile_download=is_profile_download,
max_workers=self.max_downloads,
tr=self.tr
)
def setup_simpcity_downloader(self):
self.simpcity_downloader = SimpCity(
download_folder=self.download_folder,
log_callback=self.add_log_message_safe,
enable_widgets_callback=self.enable_widgets,
update_progress_callback=self.update_progress,
update_global_progress_callback=self.update_global_progress,
tr=self.tr
)
def setup_bunkr_downloader(self):
self.bunkr_downloader = BunkrDownloader(
download_folder=self.download_folder,
log_callback=self.add_log_message_safe,
enable_widgets_callback=self.enable_widgets,
update_progress_callback=self.update_progress,
update_global_progress_callback=self.update_global_progress,
headers={
'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
'Referer': 'https://bunkr.site/',
},
max_workers=self.max_downloads
)
def setup_general_downloader(self):
self.general_downloader = Downloader(
download_folder=self.download_folder,
log_callback=self.add_log_message_safe,
enable_widgets_callback=self.enable_widgets,
update_progress_callback=self.update_progress,
update_global_progress_callback=self.update_global_progress,
headers={
'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
'Referer': 'https://coomer.st/',
"Accept": "text/css"
},
download_images=self.download_images_check.get(),
download_videos=self.download_videos_check.get(),
download_compressed=self.download_compressed_check.get(),
tr=self.tr,
max_workers=self.max_downloads,
folder_structure=self.settings.get('folder_structure', 'default')
)
self.general_downloader.file_naming_mode = self.settings.get('file_naming_mode', 0)
def setup_jpg5_downloader(self):
self.active_downloader = Jpg5Downloader(
url=self.url_entry.get().strip(),
carpeta_destino=self.download_folder,
log_callback=self.add_log_message_safe,
tr=self.tr,
progress_manager=self.progress_manager,
max_workers=self.max_downloads
)
# Folder selection
def select_folder(self):
folder_selected = filedialog.askdirectory()
if folder_selected:
self.download_folder = folder_selected
self.folder_path.configure(text=folder_selected)
self.save_download_folder(folder_selected)
# Función para cargar y redimensionar imágenes
def load_and_resize_image(self, path, size=(20, 20)):
img = Image.open(path)
return ctk.CTkImage(img, size=size)
# Reemplaza las llamadas a los métodos de progreso con self.progress_manager
def update_progress(self, downloaded, total,file_id=None, file_path=None,speed=None, eta=None, status=None):
self.progress_manager.update_progress(downloaded, total,file_id, file_path,speed, eta, status=status)
def remove_progress_bar(self, file_id):
self.progress_manager.remove_progress_bar(file_id)
def update_global_progress(self, completed_files, total_files):
self.progress_manager.update_global_progress(completed_files, total_files)
def toggle_progress_details(self):
self.progress_manager.toggle_progress_details()
def center_progress_details_frame(self):
self.progress_manager.center_progress_details_frame()
# Error logging
def log_error(self, error_message):
self.errors.append(error_message)
self.add_log_message_safe(f"Error: {error_message}")
def wrapped_download(self, download_method, *args):
try:
download_method(*args)
finally:
self.active_downloader = None
self.enable_widgets()
self.export_logs()
# Download management
def start_download(self):
url = self.url_entry.get().strip()
if not hasattr(self, 'download_folder') or not self.download_folder:
messagebox.showerror(self.tr("Error"), self.tr("Por favor, selecciona una carpeta de descarga."))
return
self.download_button.configure(state="disabled")
self.cancel_button.configure(state="normal")
self.download_start_time = datetime.datetime.now()
self.errors = []
download_all = True
parsed_url = urlparse(url)
if "erome.com" in url:
self.add_log_message_safe(self.tr("Descargando Erome"))
is_profile_download = "/a/" not in url
self.setup_erome_downloader(is_profile_download=is_profile_download)
self.active_downloader = self.erome_downloader
if "/a/" in url:
self.add_log_message_safe(self.tr("URL del álbum"))
download_thread = threading.Thread(target=self.wrapped_download, args=(self.active_downloader.process_album_page, url, self.download_folder, self.download_images_check.get(), self.download_videos_check.get()))
else:
self.add_log_message_safe(self.tr("URL del perfil"))
download_thread = threading.Thread(target=self.wrapped_download, args=(self.active_downloader.process_profile_page, url, self.download_folder, self.download_images_check.get(), self.download_videos_check.get()))
elif re.search(r"https?://([a-z0-9-]+\.)?bunkr\.[a-z]{2,}", url):
self.add_log_message_safe(self.tr("Descargando Bunkr"))
self.setup_bunkr_downloader()
self.active_downloader = self.bunkr_downloader
# Si la URL contiene "/v/", "/i/" o "/f/", la tratamos como un post individual.
if any(sub in url for sub in ["/v/", "/i/", "/f/"]):
self.add_log_message_safe(self.tr("URL del post"))
download_thread = threading.Thread(target=self.wrapped_download, args=(self.bunkr_downloader.descargar_post_bunkr, url))
else:
self.add_log_message_safe(self.tr("URL del perfil"))
download_thread = threading.Thread(target=self.wrapped_download, args=(self.bunkr_downloader.descargar_perfil_bunkr, url))
elif parsed_url.netloc in ["coomer.st", "kemono.cr"]:
self.add_log_message_safe(self.tr("Iniciando descarga..."))
self.setup_general_downloader()
self.active_downloader = self.general_downloader
site = f"{parsed_url.netloc}"
service, user, post = extract_ck_parameters(parsed_url)
if service is None or user is None:
if service is None:
self.add_log_message_safe(self.tr("No se pudo extraer el servicio."))
messagebox.showerror(self.tr("Error"), self.tr("No se pudo extraer el servicio."))
else:
self.add_log_message_safe(self.tr("No se pudo extraer el ID del usuario."))
messagebox.showerror(self.tr("Error"), self.tr("No se pudo extraer el ID del usuario."))
self.add_log_message_safe(self.tr("URL no válida"))
self.download_button.configure(state="normal")
self.cancel_button.configure(state="disabled")
return
self.add_log_message_safe(self.tr("Servicio extraído: {service} del sitio: {site}", service=service, site=site))
if post is not None:
self.add_log_message_safe(self.tr("Descargando post único..."))
download_thread = threading.Thread(target=self.wrapped_download, args=(self.start_ck_post_download, site, service, user, post))
else:
query, offset = extract_ck_query(parsed_url)
self.add_log_message_safe(self.tr("Descargando todo el contenido del usuario..."))
download_thread = threading.Thread(target=self.wrapped_download, args=(self.start_ck_profile_download, site, service, user, query, download_all, offset))
elif "simpcity.cr" in url:
self.add_log_message_safe(self.tr("Descargando SimpCity"))
self.setup_simpcity_downloader()
self.active_downloader = self.simpcity_downloader
# Iniciar la descarga en un hilo separado
download_thread = threading.Thread(target=self.wrapped_download, args=(self.active_downloader.download_images_from_simpcity, url))
elif "jpg5.su" in url:
self.add_log_message_safe(self.tr("Descargando desde Jpg5"))
self.setup_jpg5_downloader()
# Usar wrapped_download para manejar la descarga
download_thread = threading.Thread(target=self.wrapped_download, args=(self.active_downloader.descargar_imagenes,))
else:
self.add_log_message_safe(self.tr("URL no válida"))
self.download_button.configure(state="normal")
self.cancel_button.configure(state="disabled")
return
download_thread.start()
def start_ck_profile_download(self, site, service, user, query, download_all, initial_offset):
download_info = self.active_downloader.download_media(site, user, service, query=query, download_all=download_all, initial_offset=initial_offset)
if download_info:
self.add_log_message_safe(f"Download info: {download_info}")
return download_info
def start_ck_post_download(self, site, service, user, post):
download_info = self.active_downloader.download_single_post(site, post, service, user)
if download_info:
self.add_log_message_safe(f"Download info: {download_info}")
return download_info
def extract_user_id(self, url):
self.add_log_message_safe(self.tr("Extrayendo ID del usuario del URL: {url}", url=url))
match = re.search(r'/user/([^/?]+)', url)
if match:
user_id = match.group(1)
self.add_log_message_safe(self.tr("ID del usuario extraído: {user_id}", user_id=user_id))
return user_id
else:
self.add_log_message_safe(self.tr("No se pudo extraer el ID del usuario."))
messagebox.showerror(self.tr("Error"), self.tr("No se pudo extraer el ID del usuario."))
return None
def extract_post_id(self, url):
match = re.search(r'/post/([^/?]+)', url)
if match:
post_id = match.group(1)
self.add_log_message_safe(self.tr("ID del post extraído: {post_id}", post_id=post_id))
return post_id
else:
self.add_log_message_safe(self.tr("No se pudo extraer el ID del post."))
messagebox.showerror(self.tr("Error"), self.tr("No se pudo extraer el ID del post."))
return None
def cancel_download(self):
if self.active_downloader:
self.active_downloader.request_cancel()
self.active_downloader = None
self.clear_progress_bars()
else:
self.add_log_message_safe(self.tr("No hay una descarga en curso para cancelar."))
self.enable_widgets()
def clear_progress_bars(self):
for file_id in list(self.progress_bars.keys()):
self.remove_progress_bar(file_id)
# Log messages safely
def add_log_message_safe(self, message: str):
# Asegura estructuras
if not hasattr(self, "errors") or self.errors is None:
self.errors = []
self.errors.append(message)
# Intenta escribir en el textbox si existe; si no, bufferiza
try:
if hasattr(self, "log_textbox") and self.log_textbox:
self.log_textbox.configure(state="normal")
self.log_textbox.insert("end", message + "\n")
self.log_textbox.configure(state="disabled")
if getattr(self, "autoscroll_logs_var", None) and self.autoscroll_logs_var.get():
self.log_textbox.see("end")
else:
# aún no existe el textbox; guardamos
if not hasattr(self, "_log_buffer") or self._log_buffer is None:
self._log_buffer = []
self._log_buffer.append(message)
except Exception:
# ante cualquier problema, también bufferiza
if not hasattr(self, "_log_buffer") or self._log_buffer is None:
self._log_buffer = []
self._log_buffer.append(message)
def limit_log_lines(self):
log_lines = self.log_textbox.get("1.0", "end-1c").split("\n")
if len(log_lines) > MAX_LOG_LINES:
# Quitamos solo las líneas que sobran
overflow = len(log_lines) - MAX_LOG_LINES
self.log_textbox.delete("1.0", f"{overflow}.0")
# Export logs to a file
def export_logs(self):
log_folder = "resources/config/logs/"
Path(log_folder).mkdir(parents=True, exist_ok=True)
log_file_path = Path(log_folder) / f"log_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
try:
if self.active_downloader:
total_files = self.active_downloader.total_files
completed_files = self.active_downloader.completed_files
skipped_files = self.active_downloader.skipped_files
failed_files = self.active_downloader.failed_files
else:
total_files = 0
completed_files = 0
skipped_files = []
failed_files = []
# Info general
total_images = completed_files if self.download_images_check.get() else 0
total_videos = completed_files if self.download_videos_check.get() else 0
errors = len(self.errors)
warnings = len(self.warnings)
if self.download_start_time:
duration = datetime.datetime.now() - self.download_start_time
else:
duration = "N/A"
skipped_files_summary = "\n".join(skipped_files)
failed_files_summary = "\n".join(failed_files)
summary = (
f"Total de archivos descargados: {total_files}\n"
f"Total de imágenes descargadas: {total_images}\n"
f"Total de videos descargados: {total_videos}\n"
f"Errores: {errors}\n"
f"Advertencias: {warnings}\n"
f"Tiempo total de descarga: {duration}\n\n"
f"Archivos saltados:\n{skipped_files_summary}\n\n"
f"Archivos fallidos:\n{failed_files_summary}\n\n"
)
with open(log_file_path, 'w', encoding='utf-8') as file:
# Escribimos el resumen
file.write(summary)
# Escribimos TODOS los mensajes (no solo los del textbox)
file.write("\n--- LOGS COMPLETOS ---\n")
file.write("\n".join(self.all_logs))
self.add_log_message_safe(f"Logs exportados exitosamente a {log_file_path}")
except Exception as e:
self.add_log_message_safe(f"No se pudo exportar los logs: {e}")
# Clipboard operations
def copy_to_clipboard(self):
try:
selected_text = self.url_entry.selection_get()
if selected_text:
self.clipboard_clear()
self.clipboard_append(selected_text)
else:
self.add_log_message_safe(self.tr("No hay texto seleccionado para copiar."))
except tk.TclError:
self.add_log_message_safe(self.tr("No hay texto seleccionado para copiar."))
def paste_from_clipboard(self):
try:
clipboard_text = self.clipboard_get()
if clipboard_text:
try:
self.url_entry.delete("sel.first", "sel.last") # Elimina el texto seleccionado si hay alguno
except tk.TclError:
pass
self.url_entry.insert(tk.INSERT, clipboard_text)
else:
self.add_log_message_safe(self.tr("No hay texto en el portapapeles para pegar."))
except tk.TclError as e:
self.add_log_message_safe(self.tr(f"Error al pegar desde el portapapeles: {e}"))
def cut_to_clipboard(self):
try:
selected_text = self.url_entry.selection_get()
if selected_text:
self.clipboard_clear()
self.clipboard_append(selected_text)
self.url_entry.delete("sel.first", "sel.last")
else:
self.add_log_message_safe(self.tr("No hay texto seleccionado para cortar."))
except tk.TclError:
self.add_log_message_safe(self.tr("No hay texto seleccionado para cortar."))
# Show context menu
def show_context_menu(self, event):
self.context_menu.tk_popup(event.x_root, event.y_root)
self.context_menu.grab_release()
# Update queue
def check_update_queue(self):
while not self.update_queue.empty():
task = self.update_queue.get_nowait()
task()
self.after(100, self.check_update_queue)
# Enable widgets
def enable_widgets(self):
self.update_queue.put(lambda: self.download_button.configure(state="normal"))
self.update_queue.put(lambda: self.cancel_button.configure(state="disabled"))
# Save and load download folder
def save_download_folder(self, folder_path):
config = {'download_folder': folder_path}
with open('resources/config/download_path/download_folder.json', 'w') as config_file:
json.dump(config, config_file)
def load_download_folder(self):
config_path = 'resources/config/download_path/download_folder.json'
config_dir = Path(config_path).parent
if not config_dir.exists():
config_dir.mkdir(parents=True)
if not Path(config_path).exists():
with open(config_path, 'w') as config_file:
json.dump({'download_folder': ''}, config_file)
try:
with open(config_path, 'r') as config_file:
config = json.load(config_file)
return config.get('download_folder', '')
except json.JSONDecodeError:
return ''
# Update max downloads
def update_max_downloads(self, max_downloads):
self.max_downloads = max_downloads
if hasattr(self, 'general_downloader'):
self.general_downloader.max_workers = max_downloads
if hasattr(self, 'erome_downloader'):
self.erome_downloader.max_workers = max_downloads
if hasattr(self, 'bunkr_downloader'):
self.bunkr_downloader.max_workers = max_downloads
def on_hover_enter(self, event):
self.folder_path.configure(font=("Arial", 13, "underline")) # Subrayar el texto al pasar el ratón
def on_hover_leave(self, event):
self.folder_path.configure(font=("Arial", 13)) # Quitar el subrayado al salir el ratón
def get_github_stars(self, user: str, repo: str, timeout: float = 2.5) -> int:
try:
url = f"https://api.github.com/repos/{user}/{repo}"
headers = {
"User-Agent": "CoomerDL",
"Accept": "application/vnd.github+json",
}
r = requests.get(url, headers=headers, timeout=timeout)
r.raise_for_status()
data = r.json()
return int(data.get("stargazers_count", 0))
except Exception:
# No rompas el arranque si no hay internet
self.add_log_message_safe(self.tr("Offline mode: GitHub stars could not be retrieved."))
return 0
def load_icon(self, icon_path, icon_name):
try:
img = Image.open(icon_path)
return img # Devuelve la imagen de PIL
except Exception as e:
self.add_log_message_safe(f"Error al cargar el icono {icon_name}: {e}")
return None
# Uso de la función genérica para cargar íconos específicos
def load_github_icon(self):
return self.load_icon("resources/img/iconos/ui/social/github-logo-24.png", "GitHub")
def load_discord_icon(self):
return self.load_icon("resources/img/iconos/ui/social/discord-alt-logo-24.png", "Discord")
def load_patreon_icon(self):
return self.load_icon("resources/img/iconos/ui/social/patreon-logo-24.png", "New Icon")
def parse_version_string(self, version_str):
# Removes 'V' prefix and splits by '.'
try:
return tuple(int(p) for p in version_str[1:].split('.'))
except (ValueError, IndexError):
return (0, 0, 0) # Fallback for invalid format
def check_for_new_version(self, startup_check=False):
repo_owner = "emy69"
repo_name = "CoomerDL"
github_api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
try:
response = requests.get(github_api_url)
response.raise_for_status() # Raise an exception for HTTP errors
latest_release = response.json()
latest_tag = latest_release.get("tag_name")
latest_url = latest_release.get("html_url")
if latest_tag and latest_url:
# Use the global VERSION constant directly
current_version_parsed = self.parse_version_string(VERSION)
latest_version_parsed = self.parse_version_string(latest_tag)
if latest_version_parsed > current_version_parsed:
self.latest_release_url = latest_url
# Use functools.partial to ensure 'self' is correctly bound
self.after(0, functools.partial(self.show_update_alert, latest_tag))
if not startup_check:
self.after(0, lambda: messagebox.showinfo(
self.tr("Update Available"),
self.tr("A new version ({latest_tag}) is available! Please download it from GitHub.", latest_tag=latest_tag)
))
else:
if not startup_check:
self.after(0, lambda: messagebox.showinfo(
self.tr("No Updates"),
self.tr("You are running the latest version.")
))
else:
if not startup_check:
self.after(0, lambda: messagebox.showwarning(
self.tr("Update Check Failed"),
self.tr("Could not retrieve latest version information from GitHub.")
))
except requests.exceptions.RequestException as e:
if self._is_offline_error(e):
self.add_log_message_safe(self.tr("Offline mode: could not check for updates."))
self.after(0, lambda: messagebox.showinfo(
self.tr("No Internet connection"),
self.tr("We couldn't check for updates. You may not be connected to the Internet right now.\n\nThe app will continue to work in offline mode.")
))
else:
self.add_log_message_safe(f"Error checking for updates: {e}")
if not startup_check:
self.after(0, lambda: messagebox.showerror(
self.tr("Network Error"),
self.tr("Could not connect to GitHub to check for updates. Please check your internet connection.")
))
except Exception as e:
self.add_log_message_safe(f"An unexpected error occurred during update check: {e}")
if not startup_check:
self.after(0, lambda: messagebox.showerror(
self.tr("Error"),
self.tr("An unexpected error occurred during update check.")
))
def show_update_alert(self, latest_tag):
self.update_alert_label.configure(text=self.tr("New version ({latest_tag}) available!", latest_tag=latest_tag))
self.update_alert_frame.pack(side="top", fill="x")
# Re-pack other elements to ensure they are below the alert
self.input_frame.pack_forget()
self.input_frame.pack(fill='x', padx=20, pady=20)
self.options_frame.pack_forget()
self.options_frame.pack(pady=10, fill='x', padx=20)
self.action_frame.pack_forget()
self.action_frame.pack(pady=10, fill='x', padx=20)
self.log_textbox.pack_forget()
self.log_textbox.pack(pady=(10, 0), padx=20, fill='both', expand=True)
self.progress_frame.pack_forget()
self.progress_frame.pack(pady=(0, 10), fill='x', padx=20)
def open_latest_release(self):
if hasattr(self, 'latest_release_url'):
webbrowser.open(self.latest_release_url)
else:
messagebox.showwarning(self.tr("No Release Found"), self.tr("No latest release URL available."))
def _is_offline_error(self, err: Exception) -> bool:
s = str(err)
return (
isinstance(err, requests.exceptions.ConnectionError)
or "NameResolutionError" in s
or "getaddrinfo failed" in s
or "Failed to establish a new connection" in s
or "Max retries exceeded" in s
)