Major overhaul to the theme and now functioning.

This commit is contained in:
2026-01-05 16:07:30 -05:00
parent 5823ca3c39
commit a475dfeab6
5218 changed files with 1643729 additions and 13229 deletions
@@ -0,0 +1,46 @@
# Virtual environment directories
myenv/
venv/
.venv/
# Python cache and compiled files
*.py[cod]
*.pyc
__pycache__/
# Python distribution artifacts
*.egg-info/
build/
dist/
Scripts/
# Test caches
.pytest_cache/
# IDE/editor configuration files
.idea/
.vscode/
# Operating system files
.DS_Store
Thumbs.db
# Sensitive configuration files
.env
# Other files to ignore
*.csv
*.log
coomer.py
descargas_test/
list.txt
resources/config/logs/*
setup.py
test/
test.py
test/parse.py
app/user_panel.py
resources/config/download_path/download_folder.json
CLI/
resources/config/downloads.db
resources/config/cookies/*
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Emy69
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,114 @@
![Windows Compatibility](https://img.shields.io/badge/Windows-10%2C%2011-blue)
![Downloads](https://img.shields.io/github/downloads/emy69/CoomerDL/total)
# Coomer Downloader App
**Coomer Downloader App** is a Python-based desktop application that simplifies downloading images and videos from various URLs. With an intuitive GUI, you can paste a link and let the app handle the rest.
---
## Support My Work
If you find this tool helpful, please consider supporting my efforts:
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00.svg?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/emy_69)
[![Support on Patreon](https://img.shields.io/badge/Support%20on%20Patreon-FF424D.svg?style=for-the-badge&logo=patreon&logoColor=white)](https://www.patreon.com/emy69)
---
## Features
### Download Images and Videos
- **Multithreaded Downloads**: Boosts download speed by utilizing multiple threads.
- **Progress Feedback**: Real-time progress updates during downloads.
- **Queue Management**: Efficiently handles large download queues.
**Supported File Extensions**:
- **Videos**: `.mp4`, `.mkv`, `.webm`, `.mov`, `.avi`, `.flv`, `.wmv`, `.m4v`
- **Images**: `.jpg`, `.jpeg`, `.png`, `.gif`, `.bmp`, `.tiff`
- **Documents**: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`
- **Compressed**: `.zip`, `.rar`, `.7z`, `.tar`, `.gz`
---
## Supported Pages
- [coomer.su](https://coomer.su/)
- [kemono.su](https://kemono.su/)
- [erome.com](https://www.erome.com/)
- [bunkr.albums.io](https://bunkr-albums.io/)
- [simpcity.su](https://simpcity.su/)
- [jpg5.su](https://jpg5.su/)
---
## CLI Tools
If you prefer using command-line interfaces, check out the following projects:
- **[Coomer CLI](https://github.com/Emy69/Coomer-cli)**
A CLI tool for downloading media from Coomer and similar sites. It offers customizable options for file naming, download modes, rate limiting, checksum verification, and more.
- **[Simpcity CLI](https://github.com/Emy69/SimpCityCLI)**
A CLI tool specifically designed for downloading media from Simpcity. It shares many features with Coomer CLI and is tailored for the Simpcity platform.
---
## Language Support
- [Español](#)
- [English](#)
- [日本語 (Japanese)](#)
- [中文 (Chinese)](#)
- [Français (French)](#)
- [Русский (Russian)](#)
---
## Community
Have questions or just want to say hi? Join the Discord server:
[![Join Discord](https://img.shields.io/badge/Join-Discord-7289DA.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ku8gSPsesh)
---
## Downloads
- **Latest Version**: Visit the [Releases Page](https://github.com/Emy69/CoomerDL/releases) to download the newest version.
---
## Usage
1. Launch the application.
2. Paste the URL of the image or video you want to download.
3. Click **Download** and wait for the process to finish.
![Usage GIF](https://github.com/Emy69/CoomerDL/blob/main/resources/screenshots/0627.gif)
---
## Clone the Repository
To get a local copy of the project, run the following command:
```sh
git clone https://github.com/Emy69/CoomerDL.git
```
### Install Dependencies
Navigate to the project folder:
```sh
cd CoomerDL
```
Then install the required dependencies:
```sh
pip install -r requirements.txt
```
### Run the Application
Once everything is installed, you can start the application with:
```sh
python main.py
```
@@ -0,0 +1,187 @@
import threading
import customtkinter as ctk
import webbrowser
import requests
from PIL import Image
class AboutWindow:
def __init__(self, parent, translate, version):
self.parent = parent
self.translate = translate
self.version = version
def get_github_data(self):
url = "https://api.github.com/repos/Emy69/CoomerDL"
try:
response = requests.get(url)
response.raise_for_status()
repo_data = response.json()
# Obtener la fecha de creación y las descargas totales (si existen releases)
created_at = repo_data.get("created_at", "N/A")
created_date = created_at.split("T")[0] if created_at != "N/A" else "N/A"
releases_url = repo_data.get("releases_url", "").replace("{/id}", "")
releases_response = requests.get(releases_url)
releases_response.raise_for_status()
releases_data = releases_response.json()
total_downloads = sum(
asset["download_count"] for release in releases_data for asset in release.get("assets", [])
) if releases_data else 0
return created_date, total_downloads
except Exception as e:
print(f"Error fetching GitHub data: {e}")
return "N/A", 0
def show_about(self):
# Crear una nueva ventana
about_window = ctk.CTkToplevel(self.parent)
about_window.title(self.translate("About"))
about_window.geometry("300x600")
about_window.resizable(False, False)
# Centrar la ventana
self.center_window(about_window, 300, 600)
# Hacer que la ventana aparezca al frente
about_window.transient(self.parent)
about_window.lift()
about_window.grab_set()
# placeholders mientras carga
created_date = self.translate("Loading...")
total_downloads = self.translate("Loading...")
# Crear un marco para el contenido
about_frame = ctk.CTkFrame(about_window, corner_radius=15)
about_frame.pack(fill="both", expand=True, padx=10, pady=10)
# Encabezado estilizado
header_label = ctk.CTkLabel(
about_frame,
text=self.translate("About This App"),
font=("Helvetica", 20, "bold"),
text_color="white",
anchor="w"
)
header_label.pack(pady=(10, 5), padx=10, anchor="w")
# Cargar las imágenes de los íconos
developer_icon = ctk.CTkImage(Image.open("resources/img/iconos/about/user-account-solid-24.png"), size=(20, 20))
version_icon = ctk.CTkImage(Image.open("resources/img/iconos/about/git-branch-line.png"), size=(20, 20))
downloads_icon = ctk.CTkImage(Image.open("resources/img/iconos/about/download_icon.png"), size=(20, 20))
date_icon = ctk.CTkImage(Image.open("resources/img/iconos/about/calendar-event-line.png"), size=(20, 20))
# labels que se actualizarán
self.downloads_label = None
self.date_label = None
details = [
(developer_icon, f"{self.translate('Developer')}: Emy69", None),
(version_icon, f"{self.translate('Version')}: {self.version}", None),
(downloads_icon, f"{self.translate('Total Downloads')}: {total_downloads}", "downloads"),
(date_icon, f"{self.translate('Release Date')}: {created_date}", "date")
]
for icon, text, key in details:
detail_frame = ctk.CTkFrame(about_frame, fg_color="transparent")
detail_frame.pack(anchor="w", pady=5, padx=30)
icon_label = ctk.CTkLabel(detail_frame, text="", image=icon)
icon_label.pack(side="left", padx=(0, 10))
text_label = ctk.CTkLabel(
detail_frame,
text=text,
font=("Helvetica", 14),
text_color="white",
justify="left"
)
text_label.pack(side="left")
if key == "downloads": # agregado
self.downloads_label = text_label
elif key == "date": # agregado
self.date_label = text_label
separator = ctk.CTkFrame(about_frame, height=1, fg_color="gray")
separator.pack(fill="x", padx=10, pady=10)
# Sección de plataformas soportadas
supported_label = ctk.CTkLabel(
about_frame,
text=self.translate("Supported Platforms"),
font=("Helvetica", 16, "bold"),
text_color="white",
anchor="w"
)
supported_label.pack(pady=(10, 5), padx=10, anchor="w")
# Lista de plataformas con íconos
platforms = [
("coomer.su", "https://coomer.su"),
("kemono.su", "https://kemono.su"),
("erome.com", "https://erome.com"),
("bunkr-albums.io", "https://bunkr-albums.io"),
("simpcity.su", "https://simpcity.su"),
("jpg5.su", "https://jpg5.su")
]
for name, url in platforms:
# Crear el marco para cada plataforma
platform_frame = ctk.CTkFrame(about_frame, fg_color="transparent")
platform_frame.pack(anchor="w", pady=5, padx=10)
icon_image = ctk.CTkImage(Image.open("resources/img/iconos/about/global-line.png"), size=(20, 20))
icon_label = ctk.CTkLabel(platform_frame, text="", image=icon_image)
icon_label.pack(side="left")
# Botón con el nombre de la plataforma
platform_button = ctk.CTkButton(
platform_frame,
text=name,
font=("Helvetica", 14),
fg_color="transparent",
hover_color="gray25",
command=lambda u=url: webbrowser.open(u)
)
platform_button.pack(side="left")
# Footer
footer_label = ctk.CTkLabel(
about_frame,
text=self.translate("Thank you for using our app!"),
font=("Helvetica", 12, "italic"),
text_color="white",
anchor="w"
)
footer_label.pack(pady=(10, 10), padx=10, anchor="w")
# hilo para cargar datos
def fetch_and_update():
cd, td = self.get_github_data()
def safe_update():
try:
if not about_window.winfo_exists():
return
if self.date_label and self.date_label.winfo_exists():
self.date_label.configure(text=f"{self.translate('Release Date')}: {cd}")
if self.downloads_label and self.downloads_label.winfo_exists():
self.downloads_label.configure(text=f"{self.translate('Total Downloads')}: {td}")
except Exception:
pass
about_window.after(0, safe_update)
threading.Thread(target=fetch_and_update, daemon=True).start()
def center_window(self, window, width, height):
screen_width = window.winfo_screenwidth()
screen_height = window.winfo_screenheight()
x = int((screen_width / 2) - (width / 2))
y = int((screen_height / 2) - (height / 2))
window.geometry(f'{width}x{height}+{x}+{y}')
@@ -0,0 +1,221 @@
import os
import customtkinter as ctk
import requests
import threading
import json
import tkinter as tk
from PIL import Image
class DonorsModal(ctk.CTkToplevel):
def __init__(self, parent, tr):
super().__init__(parent)
self.parent = parent
self.tr = tr # Translation function
self.title(self.tr("Patreons"))
self.geometry("600x600")
self.resizable(False, False)
self.transient(parent)
# Centrar la ventana y crear la UI
self.center_window()
self.create_ui()
self.after_idle(self._safe_grab)
# Start loading data in a separate thread to prevent UI freeze
threading.Thread(target=self._load_donors, daemon=True).start()
def _safe_grab(self):
"""Intenta grab_set hasta que la ventana sea visible."""
try:
self.grab_set()
except tk.TclError:
# Aún no es “viewable”; reintenta en 20 ms
self.after(20, self._safe_grab)
def center_window(self):
"""Centers the modal window relative to the parent window."""
self.update_idletasks()
parent_x = self.parent.winfo_x()
parent_y = self.parent.winfo_y()
parent_w = self.parent.winfo_width()
parent_h = self.parent.winfo_height()
window_w = self.winfo_width()
window_h = self.winfo_height()
cx = parent_x + (parent_w // 2) - (window_w // 2)
cy = parent_y + (parent_h // 2) - (window_h // 2)
self.geometry(f"{window_w}x{window_h}+{cx}+{cy}")
def create_ui(self):
"""Creates the UI elements for the Top Donors modal."""
main_frame = ctk.CTkFrame(self)
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
title_label = ctk.CTkLabel(
main_frame,
text=self.tr("Patreons"),
font=ctk.CTkFont(size=24, weight="bold")
)
title_label.pack(pady=(0, 20))
self.donors_frame = ctk.CTkScrollableFrame(main_frame, fg_color="transparent")
self.donors_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10))
self.grid_container = ctk.CTkFrame(self.donors_frame, fg_color="transparent")
self.grid_container.pack(fill="both", expand=True)
self.status_label = ctk.CTkLabel(
self.grid_container,
text=self.tr("Loading Patreons..."),
font=ctk.CTkFont(size=14),
text_color="gray"
)
self.status_label.grid(pady=20)
close_button = ctk.CTkButton(
main_frame,
text=self.tr("Close"),
command=self.destroy
)
close_button.pack(pady=(10, 0))
def _load_donors(self):
"""Fetches donor data from the API."""
donors = []
error_message = ""
try:
# Explicitly set Accept and User-Agent headers
resp = requests.get(
'https://emydevs.com/coomer/donadores.php',
headers={
'Accept': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36'
},
timeout=10
)
resp.raise_for_status()
donors = resp.json()
except requests.exceptions.RequestException as req_e:
error_message = str(req_e)
print(f"Error fetching donors: {error_message}")
self.after(0, lambda msg=error_message: self.status_label.configure(text=self.tr("Error fetching donors: {error}").format(error=msg), text_color="red"))
return
except json.JSONDecodeError as json_e:
error_message = str(json_e)
print(f"Error decoding JSON: {error_message}")
self.after(0, lambda msg=error_message: self.status_label.configure(text=self.tr("Error processing donor data: {error}").format(error=msg), text_color="red"))
return
# Update the UI on the main thread
self.after(0, lambda: self._show_donors(donors))
def _show_donors(self, donors):
"""Displays donor data in the modal."""
# Clear status label
self.status_label.grid_forget()
# Clear existing data
for widget in self.grid_container.winfo_children():
if widget is self.status_label:
continue
widget.destroy()
columns = 2
for c in range(columns):
self.grid_container.grid_columnconfigure(c, weight=1)
if not donors:
empty = ctk.CTkLabel(self.grid_container, text=self.tr("No donors found."), text_color="gray")
empty.grid(row=0, column=0, columnspan=columns, pady=20)
return
# Sort by amount in descending order, ensuring numeric comparison
def _to_float(v):
try:
return float(v)
except Exception:
return 0.0
donors.sort(key=lambda x: _to_float(x.get("donated_amount", 0)), reverse=True)
# Cache icons once (PNG medals and default)
if not hasattr(self, "_donor_icons"):
icon_dir = os.path.join("resources", "img", "iconos", "donors")
def _load_icon(fname):
path = os.path.join(icon_dir, fname)
if os.path.exists(path):
return ctk.CTkImage(
light_image=Image.open(path),
dark_image=Image.open(path),
size=(20, 20)
)
return None
# Keep a dict for quick access and to hold references
self._donor_icons = {
"gold": _load_icon("gold.png"),
"silver": _load_icon("silver.png"),
"bronze": _load_icon("bronze.png"),
"default": _load_icon("default.png"),
}
# Info note about donors data update
info_label = ctk.CTkLabel(
self.grid_container,
text=self.tr(
"Note: Donor information is updated every 10th of each month.\n"
"Names and donation amounts are retrieved from my Patreon page."
),
font=ctk.CTkFont(size=12, slant="italic"),
text_color="gray",
justify="center",
wraplength=350 # wrap text to fit in ~350px width
)
info_label.grid(row=0, column=0, columnspan=columns, pady=(0, 10), sticky="ew")
for i, donor in enumerate(donors):
donor_name = donor.get("name", self.tr("Unknown Donor"))
# Select icon and colors
icon_key = "default"
font_size = 14
font_weight = "normal"
name_color = "#E0E0E0"
icon_img = self._donor_icons.get(icon_key)
# Row container
donor_row_frame = ctk.CTkFrame(self.grid_container, fg_color="transparent", corner_radius=8)
donor_row_frame.grid_columnconfigure(0, weight=0)
donor_row_frame.grid_columnconfigure(1, weight=1)
row = (i // columns) + 1
col = i % columns
donor_row_frame.grid(row=row, column=col, padx=8, pady=8, sticky="nsew")
# Rank label with icon
rank_label = ctk.CTkLabel(
donor_row_frame,
text="", # remove numeric order
image=icon_img,
compound="left",
font=ctk.CTkFont(size=font_size, weight=font_weight),
width=40,
anchor="w",
text_color=name_color
)
rank_label.grid(row=0, column=0, padx=(5, 5), pady=5, sticky="w")
# Name label
name_label = ctk.CTkLabel(
donor_row_frame,
text=donor_name,
font=ctk.CTkFont(size=font_size, weight=font_weight),
anchor="w",
text_color=name_color
)
name_label.grid(row=0, column=1, padx=(0, 10), pady=5, sticky="ew")
def update_donor_data(self, new_donors):
self._show_donors(new_donors) # Call _show_donors with new data
@@ -0,0 +1,128 @@
import tkinter as tk
import requests
from tkinterweb import HtmlFrame
import customtkinter as ctk
import markdown2
class PatchNotes:
WINDOW_WIDTH = 900
WINDOW_HEIGHT = 800
def __init__(self, parent, translations_func):
self.parent = parent
self.tr = translations_func
self.patch_notes_window = None
def show_patch_notes(self):
# Cerrar la ventana si ya existe
if self.patch_notes_window is not None and tk.Toplevel.winfo_exists(self.patch_notes_window):
self.patch_notes_window.destroy()
self.patch_notes_window = ctk.CTkToplevel(self.parent)
self.patch_notes_window.title(self.tr("Patch Notes"))
self.patch_notes_window.transient(self.parent)
# Centrar la ventana
self.center_window(self.patch_notes_window, self.WINDOW_WIDTH, self.WINDOW_HEIGHT)
# Forzar visibilidad y foco
self.patch_notes_window.deiconify()
self.patch_notes_window.focus_force()
main_frame = ctk.CTkFrame(self.patch_notes_window)
main_frame.pack(expand=True, fill="both", padx=20, pady=20)
# Obtener el release más reciente desde GitHub
latest_release = self.get_latest_github_release('Emy69', 'CoomerDL')
# Crear un HtmlFrame para mostrar el contenido HTML
html_frame = HtmlFrame(main_frame, messages_enabled=False)
html_frame.pack(fill="both", expand=True, padx=10, pady=10)
# Cargar el contenido HTML
if latest_release:
patch_notes_html = self.get_patch_notes_html(latest_release)
html_frame.load_html(patch_notes_html)
else:
html_frame.load_html("<p>Failed to load patch notes.</p>")
# Añadir un frame inferior para el botón y opción de "No mostrar de nuevo"
bottom_frame = ctk.CTkFrame(self.patch_notes_window)
bottom_frame.pack(fill="x", padx=20, pady=(0, 20))
dont_show_again_var = tk.IntVar()
dont_show_again_check = ctk.CTkCheckBox(bottom_frame, text=self.tr("Don't show again"), variable=dont_show_again_var)
dont_show_again_check.pack(side="left", padx=5)
ok_button = ctk.CTkButton(bottom_frame, text=self.tr("OK"), command=lambda: self.close_patch_notes(dont_show_again_var))
ok_button.pack(side="right", padx=5)
@staticmethod
def get_latest_github_release(repo_owner, repo_name):
url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
response = requests.get(url)
if response.status_code == 200:
return response.json()
else:
return None
def center_window(self, window, width, height):
screen_width = window.winfo_screenwidth()
screen_height = window.winfo_screenheight()
x = int((screen_width / 2) - (width / 2))
y = int((screen_height / 2) - (height / 2))
window.geometry(f'{width}x{height}+{x}+{y}')
def get_patch_notes_html(self, latest_release):
# Convertir el cuerpo del release de Markdown a HTML
release_body_html = markdown2.markdown(latest_release['body'])
html_content = f"""
<html>
<head>
<style>
body {{
font-family: 'Arial', sans-serif;
background-color: #1e1e1e;
color: #f5f5f5;
padding: 20px;
margin: 0;
}}
h1 {{
color: #ffcc00;
text-align: center;
margin-top: 10px;
}}
h2 {{
color: #ff6600;
margin-bottom: 5px;
text-align: center;
}}
p {{
margin-bottom: 15px;
line-height: 1.6;
text-align: justify;
}}
.footer {{
text-align: center;
font-size: 12px;
color: #888;
margin-top: 20px;
}}
</style>
</head>
<body>
<h1>{self.tr('Patch Notes')}</h1>
<h2>{latest_release['name']}</h2>
{release_body_html}
</body>
</html>
"""
return html_content
def close_patch_notes(self, dont_show_again_var):
if self.patch_notes_window is not None:
self.patch_notes_window.destroy()
self.patch_notes_window = None
@@ -0,0 +1,192 @@
import customtkinter as ctk
import os
class ProgressManager:
def __init__(self, root, icons, footer_speed_label, footer_eta_label, progress_bar, progress_percentage):
self.root = root
self.icons = icons
self.footer_speed_label = footer_speed_label
self.footer_eta_label = footer_eta_label
self.progress_bar = progress_bar
self.progress_percentage = progress_percentage
self.progress_bars = {}
self.progress_window = None
self.no_downloads_label = None
self.progress_details_frame = None
self.auto_show_details = False
def create_progress_window(self):
if self.progress_window is None or not self.progress_window.winfo_exists():
self.progress_window = ctk.CTkToplevel(self.root)
self.progress_window.title("Detalles de Descarga")
self.progress_window.geometry("600x500")
self.progress_window.resizable(True, True)
self.progress_window.protocol("WM_DELETE_WINDOW", self.close_progress_window)
self.progress_details_frame = ctk.CTkFrame(self.progress_window)
self.progress_details_frame.pack(fill='both', expand=True, padx=10, pady=10)
# Crear el mensaje de "No hay descargas"
self.no_downloads_label = ctk.CTkLabel(self.progress_details_frame, text="No hay descargas en cola", font=("Arial", 14))
self.no_downloads_label.pack(pady=20)
# Hacer que la ventana aparezca al frente
self.progress_window.transient(self.root)
self.progress_window.grab_set()
def close_progress_window(self):
if self.progress_window is not None and self.progress_window.winfo_exists():
self.progress_window.grab_release()
self.progress_window.withdraw()
def update_progress(self, downloaded, total, file_id=None, file_path=None, speed=None, eta=None, status: str | None = None):
if status is not None:
original = self.footer_eta_label.cget("text")
base_eta = original.split(" | ")[0]
self.footer_eta_label.configure(text=f"{base_eta} | STATUS:{status}")
# Oculta el estado tras 5 s
self.footer_eta_label.after(
5000,
lambda: self.footer_eta_label.configure(text=base_eta)
)
return
if total > 0:
percentage = (downloaded / total) * 100
remaining = total - downloaded
if file_id is None:
if self.progress_bar.winfo_exists():
self.progress_bar.set(downloaded / total)
self.progress_percentage.configure(text=f"{percentage:.2f}%")
else:
if self.progress_window is None or not self.progress_window.winfo_exists():
return
if file_id not in self.progress_bars:
if self.no_downloads_label and self.no_downloads_label.winfo_exists():
self.no_downloads_label.pack_forget()
file_name = os.path.basename(file_path)
file_extension = os.path.splitext(file_path)[1].lower()
# Determinar el icono
if file_extension in ['.jpg', '.jpeg', '.png', '.gif']:
icon = self.icons['image']
elif file_extension in ['.mp4', '.avi', '.mkv']:
icon = self.icons['video']
elif file_extension in ['.zip', '.rar']:
icon = self.icons['zip']
else:
icon = self.icons['default']
progress_bar_frame = ctk.CTkFrame(self.progress_details_frame)
progress_bar_frame.pack(fill='x', padx=5, pady=5)
# Crear un contenedor para el icono y el texto
icon_and_text_frame = ctk.CTkFrame(progress_bar_frame)
icon_and_text_frame.pack(side='left', padx=5)
# Crear el icono con ctk.CTkLabel
icon_label = ctk.CTkLabel(icon_and_text_frame, image=icon, text="")
icon_label.pack(side='left')
# Limitar el texto y mostrar puntos suspensivos si excede el límite
max_text_length = 30
if len(file_name) > max_text_length:
displayed_file_name = file_name[:max_text_length] + '...'
else:
displayed_file_name = file_name
progress_label = ctk.CTkLabel(icon_and_text_frame, text=displayed_file_name, anchor='w')
progress_label.pack(side='left', padx=5)
# Crear barra de progreso y etiquetas de porcentaje y ETA
progress_bar = ctk.CTkProgressBar(progress_bar_frame)
progress_bar.pack(fill='x', padx=5, pady=5)
# Mostrar porcentaje y megabytes restantes
percentage_label = ctk.CTkLabel(progress_bar_frame, text=f"{percentage:.2f}% ({downloaded / 1048576:.2f} MB / {total / 1048576:.2f} MB)")
percentage_label.pack(side='left', padx=5)
eta_label = ctk.CTkLabel(progress_bar_frame, text=f"ETA: N/A")
eta_label.pack(side='right', padx=5)
self.progress_bars[file_id] = (progress_bar, percentage_label, eta_label, progress_bar_frame)
if file_id in self.progress_bars and self.progress_bars[file_id][0].winfo_exists():
self.progress_bars[file_id][0].set(downloaded / total)
self.progress_bars[file_id][1].configure(text=f"{percentage:.2f}% ({downloaded / 1048576:.2f} MB / {total / 1048576:.2f} MB)")
if eta is not None:
eta_text = f"ETA: {int(eta // 60)}m {int(eta % 60)}s"
self.progress_bars[file_id][2].configure(text=eta_text)
# Eliminar el frame si la descarga está completa
if downloaded >= total:
self.remove_progress_bar(file_id)
else:
if file_id is None:
if self.progress_bar.winfo_exists():
self.progress_bar.set(0)
self.progress_percentage.configure(text="0%")
else:
if file_id in self.progress_bars and self.progress_bars[file_id][0].winfo_exists():
self.progress_bars[file_id][0].set(0)
self.progress_bars[file_id][1].configure(text="0%")
self.progress_bars[file_id][2].configure(text="ETA: N/A")
# Actualizar velocidad en el footer (si corresponde)
if speed is not None:
if speed < 1_048_576:
speed_text = f"Speed: {speed / 1024:.2f} KB/s"
else:
speed_text = f"Speed: {speed / 1_048_576:.2f} MB/s"
self.footer_speed_label.configure(text=speed_text)
if eta is not None:
mins, secs = divmod(int(eta), 60)
self.footer_eta_label.configure(text=f"ETA: {mins}m {secs}s")
def remove_progress_bar(self, file_id):
if file_id in self.progress_bars and self.progress_bars[file_id][3].winfo_exists():
# Programar la eliminación del frame después de 2 segundos
self.progress_bars[file_id][3].after(2000, lambda: self._forget_and_delete(file_id))
def _forget_and_delete(self, file_id):
if file_id in self.progress_bars and self.progress_bars[file_id][3].winfo_exists():
self.progress_bars[file_id][3].pack_forget()
del self.progress_bars[file_id]
# Si ya no quedan descargas, mostrar mensaje y restablecer el footer
if not self.progress_bars:
self.no_downloads_label.pack(pady=20)
# Restablecer valores por defecto en el footer
self.footer_speed_label.configure(text="Speed: 0 KB/s")
self.footer_eta_label.configure(text="ETA: N/A")
def update_global_progress(self, completed_files, total_files):
if total_files > 0:
percentage = (completed_files / total_files) * 100
if self.progress_bar.winfo_exists():
self.progress_bar.set(completed_files / total_files)
self.progress_percentage.configure(text=f"{percentage:.2f}%")
def toggle_progress_details(self):
self.create_progress_window()
if self.progress_window.winfo_viewable():
self.close_progress_window()
else:
self.center_progress_details_frame()
self.progress_window.deiconify()
self.progress_window.lift()
def center_progress_details_frame(self):
if self.progress_window is not None and self.progress_window.winfo_exists():
self.progress_window.update_idletasks()
width = self.progress_window.winfo_width()
height = self.progress_window.winfo_height()
x = (self.root.winfo_screenwidth() // 2) - (width // 2)
y = (self.root.winfo_screenheight() // 2) - (height // 2)
self.progress_window.geometry(f"{width}x{height}+{x}+{y}")
self.progress_window.minsize(width, height)
@@ -0,0 +1,905 @@
import json
import os
import sqlite3
import threading
from tkinter import filedialog, messagebox, ttk
import customtkinter as ctk
import tkinter as tk
from PIL import Image, ImageTk
from PIL import Image as PilImage
import requests
class SettingsWindow:
CONFIG_PATH = 'resources/config/settings.json'
def __init__(self, parent, translate, load_translations_func, update_ui_texts_func, save_language_preference_func, version, downloader, check_for_new_version_func,on_settings_changed=None):
self.parent = parent
self.translate = translate
self.load_translations = load_translations_func
self.update_ui_texts = update_ui_texts_func
self.save_language_preference = save_language_preference_func
self.version = version
self.downloader = downloader
self.check_for_new_version = check_for_new_version_func
self.languages = {
"Español": "es",
"English": "en",
"日本語": "ja",
"中文": "zh",
"Français": "fr",
"Русский": "ru"
}
self.settings = self.load_settings()
self.folder_structure_icons = self.load_icons()
self.on_settings_changed = on_settings_changed
self.site_status_labels = {}
self.site_textboxes = {}
def load_settings(self):
if not os.path.exists(self.CONFIG_PATH):
return {'max_downloads': 3, 'folder_structure': 'default', 'language': 'en', 'theme': 'System'}
try:
with open(self.CONFIG_PATH, 'r') as file:
return json.load(file)
except (FileNotFoundError, json.JSONDecodeError):
return {'max_downloads': 3, 'folder_structure': 'default', 'language': 'en', 'theme': 'System'}
def save_settings(self):
os.makedirs(os.path.dirname(self.CONFIG_PATH), exist_ok=True)
with open(self.CONFIG_PATH, 'w') as file:
json.dump(self.settings, file, indent=4)
if callable(self.on_settings_changed):
self.on_settings_changed(self.settings)
def load_icons(self):
icons = {}
try:
icons['folder'] = ImageTk.PhotoImage(PilImage.open("resources/img/iconos/settings/folder.png").resize((20, 20), PilImage.Resampling.LANCZOS))
except Exception as e:
messagebox.showerror(self.translate("Error"), self.translate(f"Error loading icons: {e}"))
icons['folder'] = None
return icons
def open_settings(self):
self.settings_window = ctk.CTkToplevel(self.parent)
self.settings_window.title(self.translate("Settings"))
self.settings_window.geometry("800x600")
self.settings_window.transient(self.parent)
self.settings_window.deiconify()
self.settings_window.after_idle(self.settings_window.grab_set)
self.center_window(self.settings_window, 800, 600)
self.main_frame = ctk.CTkFrame(self.settings_window)
self.main_frame.pack(fill="both", expand=True, padx=10, pady=10)
self.tabview = ctk.CTkTabview(self.main_frame, width=700, height=500)
self.tabview.pack(fill="both", expand=True)
# Existing tabs
self.general_tab = self.tabview.add(self.translate("General"))
self.downloads_tab = self.tabview.add(self.translate("Downloads"))
self.structure_tab = self.tabview.add(self.translate("Structure"))
self.db_tab = self.tabview.add(self.translate("Database"))
self.cookies_tab = self.tabview.add(self.translate("Cookies"))
# New tab for database
self.render_general_tab(self.general_tab)
self.render_downloads_tab(self.downloads_tab)
self.render_structure_tab(self.structure_tab)
self.render_db_tab(self.db_tab)
self.render_cookies_tab(self.cookies_tab)
def render_db_tab(self, tab):
tab.grid_columnconfigure(0, weight=1)
tab.grid_rowconfigure(0, weight=1)
db_frame = ctk.CTkFrame(tab, fg_color="transparent")
db_frame.pack(fill="both", expand=True, padx=20, pady=20)
header_label = ctk.CTkLabel(db_frame, text=self.translate("Database Management"), font=("Helvetica", 16, "bold"))
header_label.pack(pady=(0, 10))
# Configurar un estilo personalizado para el Treeview
style = ttk.Style()
style.theme_use('clam') # O puedes elegir 'default' o 'alt'
style.configure("Custom.Treeview",
background="#2e2e2e",
foreground="white",
fieldbackground="#2e2e2e",
rowheight=25,
font=('Helvetica', 10))
style.configure("Custom.Treeview.Heading",
background="#1e1e1e",
foreground="white",
font=('Helvetica', 10, 'bold'))
# Para filas alternas (opcional)
style.map("Custom.Treeview", background=[("selected", "#4a6984")])
# Frame para el Treeview y su scrollbar
table_frame = tk.Frame(db_frame)
table_frame.pack(fill="both", expand=True)
# Definir las columnas que se mostrarán en la parte derecha del Treeview
columns = ("id", "file_name", "type", "size", "downloaded_at")
self.db_tree = ttk.Treeview(table_frame, style="Custom.Treeview", columns=columns, show="tree headings", height=15, selectmode="extended")
# Configurar encabezados
self.db_tree.heading("#0", text=self.translate("User/Post"), anchor="w")
self.db_tree.heading("id", text="ID", anchor="center")
self.db_tree.heading("file_name", text=self.translate("File Name"), anchor="w")
self.db_tree.heading("type", text=self.translate("Type"), anchor="center")
self.db_tree.heading("size", text=self.translate("Size"), anchor="center")
self.db_tree.heading("downloaded_at", text=self.translate("Downloaded At"), anchor="center")
# Configurar ancho de columnas
self.db_tree.column("#0", width=180)
self.db_tree.column("id", width=30, anchor="center")
self.db_tree.column("file_name", width=200)
self.db_tree.column("type", width=80, anchor="center")
self.db_tree.column("size", width=80, anchor="center")
self.db_tree.column("downloaded_at", width=150, anchor="center")
# Scrollbar vertical
vsb = ttk.Scrollbar(table_frame, orient="vertical", command=self.db_tree.yview)
self.db_tree.configure(yscrollcommand=vsb.set)
self.db_tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
table_frame.grid_rowconfigure(0, weight=1)
table_frame.grid_columnconfigure(0, weight=1)
# Botones para exportar y limpiar
btn_frame = ctk.CTkFrame(db_frame, fg_color="transparent")
btn_frame.pack(pady=10)
export_button = ctk.CTkButton(btn_frame, text=self.translate("Export Database"), command=self.export_db)
export_button.pack(side="left", padx=10)
clear_button = ctk.CTkButton(btn_frame, text=self.translate("Clear Database"), command=self.clear_db)
clear_button.pack(side="left", padx=10)
delete_users_button = ctk.CTkButton(
btn_frame,
text=self.translate("Delete Selected Users"),
command=self.delete_selected_users
)
delete_users_button.pack(side="left", padx=10)
# Cargar los registros en el Treeview con agrupación por usuario y post
self.load_db_records()
def render_cookies_tab(self, tab):
tab.grid_columnconfigure(0, weight=1)
tab.grid_rowconfigure(1, weight=1)
# Header
header_frame = ctk.CTkFrame(tab, fg_color="transparent")
header_frame.grid(row=0, column=0, sticky="ew", padx=20, pady=(20, 10))
title_label = ctk.CTkLabel(
header_frame,
text="Cookie Management",
font=("Helvetica", 18, "bold")
)
title_label.pack(anchor="w")
desc_label = ctk.CTkLabel(
header_frame,
text="Paste or import cookies in JSON format (list of objects with name, value, domain, path). "
"They will be stored by site in resources/config/cookies/{sitio}.json",
font=("Helvetica", 12),
text_color="gray",
wraplength=800,
justify="left"
)
desc_label.pack(anchor="w", pady=(5, 0))
main_frame = ctk.CTkScrollableFrame(tab)
main_frame.grid(row=1, column=0, sticky="nsew", padx=20, pady=(0, 20))
main_frame.grid_columnconfigure(0, weight=1)
sites_config = [
{"key": "simpcity", "name": "SimpCity", "color": "#FF6B6B", "icon": "🌐",
"description": "Cookies for accessing SimCity"},
]
def cookie_path_for(site_key: str) -> str:
base = "resources/config/cookies"
os.makedirs(base, exist_ok=True)
return os.path.join(base, f"{site_key}.json")
for i, cfg in enumerate(sites_config):
site_key = cfg["key"]
site_name = cfg["name"]
site_color = cfg["color"]
site_icon = cfg["icon"]
site_desc = cfg["description"]
# --- Container ---
site_container = ctk.CTkFrame(main_frame)
site_container.grid(row=i, column=0, sticky="ew", pady=(0, 22), padx=10)
site_container.grid_columnconfigure(0, weight=1)
accent = ctk.CTkFrame(site_container, height=4, fg_color=site_color)
accent.grid(row=0, column=0, sticky="ew")
# Header
header = ctk.CTkFrame(site_container, fg_color="transparent")
header.grid(row=1, column=0, sticky="ew", padx=15, pady=(10, 6))
header.grid_columnconfigure(1, weight=1)
title = ctk.CTkLabel(header, text=f"{site_icon} {site_name}", font=("Helvetica", 16, "bold"))
title.grid(row=0, column=0, sticky="w")
status_exists = os.path.exists(cookie_path_for(site_key))
status_label = ctk.CTkLabel(
header,
text=("✓ Configuradas" if status_exists else "⚠ Sin configurar"),
font=("Helvetica", 11),
text_color=("#4CAF50" if status_exists else "#FF9800")
)
status_label.grid(row=0, column=1, sticky="e")
self.site_status_labels[site_key] = status_label
if site_desc:
desc = ctk.CTkLabel(
header, text=site_desc, font=("Helvetica", 11),
text_color="gray", wraplength=600, justify="left"
)
desc.grid(row=1, column=0, columnspan=2, sticky="w", pady=(4, 0))
instructions_frame = ctk.CTkFrame(site_container, fg_color=("gray90", "gray20"))
instructions_frame.grid(row=2, column=0, sticky="ew", padx=15, pady=(0, 8))
instructions = (
"📋 How to obtain cookies (JSON format):\n"
"1) Open the browser Developer Tools (F12).\n"
"2) Go to Application/Storage → Cookies and select the site.\n"
"3) Use an extension to export cookies as JSON (e.g., EditThisCookie), or copy the fields manually.\n"
"4) Paste the JSON below or import a .json file.\n"
" Expected format: an array of objects with at least name, value, domain, path."
)
ctk.CTkLabel(instructions_frame, text=instructions, font=("Helvetica", 10),
justify="left", anchor="w").pack(padx=12, pady=8, fill="x")
# Textbox
text_frame = ctk.CTkFrame(site_container, fg_color="transparent")
text_frame.grid(row=3, column=0, sticky="ew", padx=15, pady=(2, 6))
text_frame.grid_columnconfigure(0, weight=1)
textbox = ctk.CTkTextbox(text_frame, height=160, wrap="none")
textbox.grid(row=0, column=0, sticky="ew")
pre_path = cookie_path_for(site_key)
if os.path.exists(pre_path):
try:
with open(pre_path, "r", encoding="utf-8") as f:
textbox.delete("1.0", "end")
textbox.insert("1.0", f.read())
except Exception:
pass
self.site_textboxes[site_key] = textbox
buttons = ctk.CTkFrame(site_container, fg_color="transparent")
buttons.grid(row=4, column=0, sticky="ew", padx=15, pady=(4, 12))
buttons.grid_columnconfigure((0,1,2,3), weight=0)
buttons.grid_columnconfigure(4, weight=1)
def do_import(sk=site_key, tb=textbox):
file = filedialog.askopenfilename(
title=f"Import cookies JSON for {sk}",
filetypes=[("JSON files", "*.json"), ("All files", "*.*")]
)
if not file:
return
try:
with open(file, "r", encoding="utf-8") as f:
content = f.read()
json.loads(content) # valida
tb.delete("1.0", "end")
tb.insert("1.0", content)
messagebox.showinfo("OK", f"Cookies loaded into editor for {sk}. Don't forget to Save!")
except Exception as e:
messagebox.showerror("Error", f"Invalid JSON: {e}")
def do_save(sk=site_key, tb=textbox, sl=status_label):
content = tb.get("1.0", "end").strip()
if not content:
messagebox.showwarning("Empty", "Nothing to save.")
return
try:
data = json.loads(content)
if isinstance(data, dict):
data = [data]
if not isinstance(data, list):
raise ValueError("JSON must be an object or an array of objects.")
for idx, c in enumerate(data, start=1):
if not isinstance(c, dict) or "name" not in c or "value" not in c:
raise ValueError(f"Item {idx} is missing 'name' or 'value'.")
save_path = cookie_path_for(sk)
os.makedirs(os.path.dirname(save_path), exist_ok=True)
with open(save_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
sl.configure(text="✓ Configured", text_color="#4CAF50")
messagebox.showinfo("OK", f"Cookies saved to:\n{save_path}")
except Exception as e:
messagebox.showerror("Error", f"Could not save: {e}")
def do_test(sk=site_key, sl=status_label):
urls = {
"simpcity": ["https://www.simpcity.cr/"]
}
test_path = cookie_path_for(sk)
if not os.path.exists(test_path):
messagebox.showwarning("Missing file", "You haven't saved cookies for this site yet.")
return
try:
with open(test_path, "r", encoding="utf-8") as f:
cookies = json.load(f)
s = requests.Session()
jar = requests.cookies.RequestsCookieJar()
for c in cookies if isinstance(cookies, list) else [cookies]:
jar.set(c["name"], c["value"], domain=c.get("domain", None), path=c.get("path", "/"))
s.cookies = jar
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
}
ok_any, codes = False, []
for test_url in urls.get(sk, []):
try:
r = s.get(test_url, headers=headers, timeout=12)
codes.append(r.status_code)
if 200 <= r.status_code < 400:
ok_any = True
except Exception:
codes.append("ERR")
if ok_any:
sl.configure(text="✓ Configured (test OK)", text_color="#4CAF50")
messagebox.showinfo("OK", f"Response from {sk}: {codes}")
else:
sl.configure(text="⚠ Tested (non-OK response)", text_color="#FFC107")
messagebox.showwarning("Heads up", f"Response from {sk}: {codes}\n"
"Cookie format looks fine but the site returned non-OK status codes.\n"
"If content requires login, try a protected URL inside the downloader.")
except Exception as e:
sl.configure(text="⚠ Not configured", text_color="#FF9800")
messagebox.showerror("Error", f"Test failed: {e}")
def do_clear(sk=site_key, tb=textbox, sl=status_label):
del_path = cookie_path_for(sk)
if os.path.exists(del_path):
try:
os.remove(del_path)
except Exception as e:
messagebox.showerror("Error", f"Could not delete file: {e}")
return
tb.delete("1.0", "end")
sl.configure(text="⚠ Not configured", text_color="#FF9800")
messagebox.showinfo("OK", f"Cookies for {sk} deleted.")
ctk.CTkButton(buttons, text="Import JSON...", command=do_import).grid(row=0, column=0, padx=(0, 6))
ctk.CTkButton(buttons, text="Save",command=do_save).grid( row=0, column=1, padx=6)
ctk.CTkButton(buttons, text="Test",command=do_test).grid( row=0, column=2, padx=6)
ctk.CTkButton(buttons, text="Delete",command=do_clear, fg_color="#E53935", hover_color="#C62828").grid(row=0, column=3, padx=6)
def delete_selected_users(self):
selected = self.db_tree.selection()
if not selected:
messagebox.showwarning(
self.translate("Warning"),
self.translate("Please select at least one user to delete.")
)
return
user_ids = [
self.db_tree.item(node, 'text')
for node in selected
if self.db_tree.parent(node) == ''
]
if not user_ids:
messagebox.showwarning(
self.translate("Warning"),
self.translate("Please select valid user entries (not posts/files).")
)
return
confirm = messagebox.askyesno(
self.translate("Confirm"),
self.translate(
"Are you sure you want to delete all records for user(s): {}?".format(
", ".join(user_ids)
)
)
)
if not confirm:
return
try:
conn = sqlite3.connect(self.downloader.db_path)
cursor = conn.cursor()
for uid in user_ids:
cursor.execute("DELETE FROM downloads WHERE user_id = ?", (uid,))
conn.commit()
conn.close()
messagebox.showinfo(
self.translate("Success"),
self.translate("Selected user(s) and their records were deleted.")
)
self.load_db_records()
except Exception as e:
messagebox.showerror(
self.translate("Error"),
self.translate(f"Error deleting user(s): {e}")
)
def load_db_records(self):
"""Consulta la base de datos y carga los registros en el Treeview agrupados por usuario y, opcionalmente, por post."""
db_path = self.downloader.db_path
if not os.path.exists(db_path):
tk.messagebox.showwarning(self.translate("Warning"), self.translate("Database not found."))
return
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT id, media_url, file_path, file_size, user_id, post_id, downloaded_at FROM downloads")
rows = cursor.fetchall()
conn.close()
except Exception as e:
tk.messagebox.showerror(self.translate("Error"), self.translate("Error loading database: {e}", e=e))
return
# Limpiar el Treeview
for child in self.db_tree.get_children():
self.db_tree.delete(child)
# Función auxiliar para determinar el tipo de archivo según la extensión
def get_file_type(file_path):
ext = os.path.splitext(file_path)[1].lower()
if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff']:
return self.translate("Image")
elif ext in ['.mp4', '.mkv', '.webm', '.mov', '.avi', '.flv', '.wmv', '.m4v']:
return self.translate("Video")
elif ext in ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx']:
return self.translate("Document")
elif ext in ['.zip', '.rar', '.7z', '.tar', '.gz']:
return self.translate("Compressed")
else:
return self.translate("Other")
# Función para formatear el tamaño
def format_size(size):
if size < 1024:
return f"{size} B"
elif size < 1024**2:
return f"{size/1024:.2f} KB"
else:
return f"{size/1024**2:.2f} MB"
# Agrupar registros por usuario
usuarios = {}
for rec in rows:
rec_id, media_url, file_path, file_size, user_id, post_id, downloaded_at = rec
usuarios.setdefault(user_id, []).append(rec)
# Insertar cada usuario como nodo padre en el Treeview
for user, registros in usuarios.items():
user_node = self.db_tree.insert("", "end", text=user, open=False)
# Dentro de cada usuario, agrupar los registros que tienen post_id y los que no
posts = {}
sin_post = []
for rec in registros:
rec_id, media_url, file_path, file_size, user_id, post_id, downloaded_at = rec
if post_id:
posts.setdefault(post_id, []).append(rec)
else:
sin_post.append(rec)
# Insertar nodos para cada post
for post, recs in posts.items():
post_node = self.db_tree.insert(user_node, "end", text=post, open=False)
for rec in recs:
rec_id, media_url, file_path, file_size, user_id, post_id, downloaded_at = rec
file_name = os.path.basename(file_path)
file_type = get_file_type(file_path)
size_str = format_size(file_size)
self.db_tree.insert(post_node, "end", values=(rec_id, file_name, file_type, size_str, downloaded_at))
# Insertar los registros sin post en un nodo especial
if sin_post:
sin_post_node = self.db_tree.insert(user_node, "end", text=self.translate("No Post"), open=False)
for rec in sin_post:
rec_id, media_url, file_path, file_size, user_id, post_id, downloaded_at = rec
file_name = os.path.basename(file_path)
file_type = get_file_type(file_path)
size_str = format_size(file_size)
self.db_tree.insert(sin_post_node, "end", values=(rec_id, file_name, file_type, size_str, downloaded_at))
def render_general_tab(self, tab):
tab.grid_columnconfigure(0, weight=1)
tab.grid_rowconfigure(0, weight=0)
# General frame
general_frame = ctk.CTkFrame(tab, fg_color="transparent")
general_frame.grid(row=0, column=0, sticky="ew", padx=20, pady=20)
general_frame.grid_columnconfigure(1, weight=1)
# Main label
general_label = ctk.CTkLabel(general_frame, text=self.translate("General Options"), font=("Helvetica", 16, "bold"))
general_label.grid(row=0, column=0, columnspan=3, sticky="w")
# Section description
description_label = ctk.CTkLabel(general_frame, text=self.translate("Here you can change the appearance and language of the application."),
font=("Helvetica", 11), text_color="gray")
description_label.grid(row=1, column=0, columnspan=3, sticky="w", pady=(0, 15))
# Theme
theme_label = ctk.CTkLabel(general_frame, text=self.translate("Theme"), font=("Helvetica", 14))
theme_label.grid(row=2, column=0, pady=5, sticky="w")
theme_combobox = ctk.CTkComboBox(general_frame, values=["Light", "Dark", "System"], state='readonly', width=120)
theme_combobox.set(self.settings.get('theme', 'System'))
theme_combobox.grid(row=2, column=1, pady=5, padx=(10,0), sticky="w")
apply_theme_button = ctk.CTkButton(general_frame, text=self.translate("Apply Theme"),
command=lambda: self.change_theme_in_thread(theme_combobox.get()))
apply_theme_button.grid(row=2, column=2, pady=5, sticky="e")
# Separator
separator_1 = ttk.Separator(general_frame, orient="horizontal")
separator_1.grid(row=3, column=0, columnspan=3, sticky="ew", pady=15)
# Language
language_label = ctk.CTkLabel(general_frame, text=self.translate("Language"), font=("Helvetica", 14))
language_label.grid(row=4, column=0, pady=5, sticky="w")
language_combobox = ctk.CTkComboBox(general_frame, values=list(self.languages.keys()), state='readonly', width=120)
language_combobox.set(self.get_language_name(self.settings.get('language', 'en')))
language_combobox.grid(row=4, column=1, pady=5, padx=(10,0), sticky="w")
apply_language_button = ctk.CTkButton(general_frame, text=self.translate("Apply Language"),
command=lambda: self.apply_language_settings(language_combobox.get()))
apply_language_button.grid(row=4, column=2, pady=5, sticky="e")
# Separator for updates
separator_2 = ttk.Separator(general_frame, orient="horizontal")
separator_2.grid(row=5, column=0, columnspan=3, sticky="ew", pady=15)
# Check for Updates
update_label = ctk.CTkLabel(general_frame, text=self.translate("Check for Updates"), font=("Helvetica", 14))
update_label.grid(row=6, column=0, pady=5, sticky="w")
check_update_button = ctk.CTkButton(general_frame, text=self.translate("Check Now"),
command=lambda: threading.Thread(target=self.check_for_new_version, args=(False,)).start())
check_update_button.grid(row=6, column=1, pady=5, padx=(10,0), sticky="w")
def render_downloads_tab(self, tab):
tab.grid_columnconfigure(0, weight=1)
tab.grid_rowconfigure(0, weight=1)
# Frame for the "Downloads" tab
downloads_frame = ctk.CTkFrame(tab, fg_color="transparent")
downloads_frame.grid(row=0, column=0, sticky="nsew", padx=20, pady=20)
downloads_frame.grid_columnconfigure(1, weight=1)
download_label = ctk.CTkLabel(downloads_frame, text=self.translate("Download Options"), font=("Helvetica", 16, "bold"))
download_label.grid(row=0, column=0, columnspan=2, sticky="w")
description_label = ctk.CTkLabel(
downloads_frame,
text=self.translate("Here you can adjust the number of simultaneous downloads, file naming, retries, and more."),
font=("Helvetica", 11),
text_color="gray"
)
description_label.grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 15))
# ----------------------------
# Simultaneous downloads
# ----------------------------
max_downloads_label = ctk.CTkLabel(downloads_frame, text=self.translate("Simultaneous Downloads"))
max_downloads_label.grid(row=2, column=0, pady=5, sticky="w")
max_downloads_combobox = ctk.CTkComboBox(
downloads_frame,
values=[str(i) for i in range(1, 11)],
state='readonly',
width=80
)
max_downloads_combobox.set(str(self.settings.get('max_downloads', 3)))
max_downloads_combobox.grid(row=2, column=1, pady=5, padx=(10, 0), sticky="w")
# ----------------------------
# Folder structure
# ----------------------------
folder_structure_label = ctk.CTkLabel(downloads_frame, text=self.translate("Folder Structure"))
folder_structure_label.grid(row=3, column=0, pady=5, sticky="w")
folder_structure_combobox = ctk.CTkComboBox(
downloads_frame,
values=["default", "post_number"],
state='readonly',
width=150
)
folder_structure_combobox.set(self.settings.get('folder_structure', 'default'))
folder_structure_combobox.grid(row=3, column=1, pady=5, padx=(10, 0), sticky="w")
# ----------------------------
# Max retries
# ----------------------------
retry_label = ctk.CTkLabel(downloads_frame, text=self.translate("Max Retries"))
retry_label.grid(row=4, column=0, pady=5, sticky="w")
retry_combobox = ctk.CTkComboBox(
downloads_frame,
values=[str(i) for i in range(0, 11)] + ['999999'],
state='readonly',
width=80
)
retry_combobox.set(str(self.settings.get('max_retries', 3)))
retry_combobox.grid(row=4, column=1, pady=5, padx=(10, 0), sticky="w")
# ----------------------------
# Retry interval
# ----------------------------
retry_interval_label = ctk.CTkLabel(downloads_frame, text=self.translate("Retry Interval (seconds)"))
retry_interval_label.grid(row=5, column=0, pady=5, sticky="w")
retry_interval_entry = ctk.CTkEntry(downloads_frame, width=80)
retry_interval_entry.insert(0, str(self.settings.get('retry_interval', 2.0)))
retry_interval_entry.grid(row=5, column=1, pady=5, padx=(10, 0), sticky="w")
# ----------------------------
# File naming mode
# ----------------------------
naming_label = ctk.CTkLabel(downloads_frame, text=self.translate("File Naming Mode"))
naming_label.grid(row=6, column=0, pady=5, sticky="w")
naming_options = [
"Use File ID (default)",
"Use Sanitized Post Name",
"Post Name + Post ID Suffix",
"Post Date/Time + Post Name"
]
# 1) Crear PRIMERO el combobox
file_naming_combobox = ctk.CTkComboBox(
downloads_frame,
values=naming_options,
state='readonly',
width=200
)
# 2) Recuperar el valor desde settings (puede ser int o str).
# Si es int (0,1,2,3) mapeamos a la cadena; si es str, la usamos tal cual.
naming_mode = self.settings.get('file_naming_mode', 0)
if isinstance(naming_mode, int):
# Asegurarnos de no pasarnos de índice
if 0 <= naming_mode < len(naming_options):
file_naming_combobox.set(naming_options[naming_mode])
else:
file_naming_combobox.set(naming_options[0])
else:
# Aquí asumes que es una cadena. Si no, fallback a la primera
if naming_mode in naming_options:
file_naming_combobox.set(naming_mode)
else:
file_naming_combobox.set(naming_options[0])
file_naming_combobox.grid(row=6, column=1, pady=5, padx=(10, 0), sticky="w")
# ----------------------------
# Botón para aplicar configuración
# ----------------------------
apply_download_button = ctk.CTkButton(
downloads_frame,
text=self.translate("Apply Download Settings"),
command=lambda: self.apply_download_settings(
max_downloads_combobox,
folder_structure_combobox,
retry_combobox,
retry_interval_entry,
file_naming_combobox
)
)
apply_download_button.grid(row=7, column=1, pady=10, sticky="e")
def render_structure_tab(self, tab):
tab.grid_columnconfigure(0, weight=1)
tab.grid_rowconfigure(1, weight=1)
structure_label = ctk.CTkLabel(
tab,
text=self.translate("Folder Structure Preview"),
font=("Helvetica", 16, "bold")
)
structure_label.grid(row=0, column=0, pady=(20,10), sticky="w")
# Descripción principal
description_label = ctk.CTkLabel(
tab,
text=self.translate("Here you can see how your downloaded files will be organized on disk."),
font=("Helvetica", 11),
text_color="gray"
)
description_label.grid(row=1, column=0, sticky="w", padx=20, pady=(0,15))
# -------- BLOQUE DE EJEMPLO: Ejemplos de nombres de archivos según la opción --------
examples_text = (
"Ejemplos de nombres según File Naming Mode:\n\n"
" • [Modo 0] Use File ID (default): \n"
" 123456.mp4 (usa el nombre/ID original del archivo)\n\n"
" • [Modo 1] Use Sanitized Post Name: \n"
" Mi_Post_Ejemplo_1_ab12.mp4 (nombre del post + índice del adjunto + hash único)\n\n"
" • [Modo 2] Post Name + Post ID Suffix: \n"
" Mi_Post_Ejemplo - 98765_1.mp4 (nombre del post + ID del post + índice del adjunto)\n"
" • [Modo 3] Post Time + Post Name: \n"
" 2000-01-01T00_00_00 - Mi_Post_Ejemplo - 98765_1.mp4 (post date/time + nombre del post + índice del adjunto + hash único)\n"
)
examples_label = ctk.CTkLabel(
tab,
text=self.translate(examples_text),
font=("Helvetica", 11),
text_color="gray",
justify="left"
)
examples_label.grid(row=2, column=0, sticky="w", padx=20, pady=(0,15))
# ------------------------------------------------------------------------------------
treeview_frame = ctk.CTkFrame(tab, fg_color="transparent")
treeview_frame.grid(row=3, column=0, sticky="nsew", padx=20, pady=20)
treeview_frame.grid_rowconfigure(0, weight=1)
treeview_frame.grid_columnconfigure(0, weight=1)
treeview_frame.grid_columnconfigure(1, weight=1)
self.default_treeview = ttk.Treeview(treeview_frame, show="tree")
self.default_treeview.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
self.post_treeview = ttk.Treeview(treeview_frame, show="tree")
self.post_treeview.grid(row=0, column=1, sticky="nsew", padx=5, pady=5)
self.update_treeview()
def export_db(self):
db_path = self.downloader.db_path
if os.path.exists(db_path):
export_path = filedialog.asksaveasfilename(defaultextension=".db",
filetypes=[("SQLite DB", "*.db")],
title=self.translate("Export Database"))
if export_path:
try:
import shutil
shutil.copy(db_path, export_path)
messagebox.showinfo(self.translate("Success"), self.translate("The database was exported successfully."))
except Exception as e:
messagebox.showerror(self.translate("Error"), self.translate(f"Error exporting database: {e}"))
else:
messagebox.showwarning(self.translate("Warning"), self.translate("Database not found."))
def clear_db(self):
confirm = messagebox.askyesno(self.translate("Confirm"),
self.translate("Are you sure you want to clear the database? This will delete all download records."))
if confirm:
try:
self.downloader.clear_database()
messagebox.showinfo(self.translate("Success"), self.translate("The database was cleared successfully."))
except Exception as e:
messagebox.showerror(self.translate("Error"), self.translate(f"Error clearing database: {e}"))
def apply_download_settings(self,max_downloads_combobox,folder_structure_combobox,retry_combobox,retry_interval_entry,file_naming_combobox):
try:
max_downloads = int(max_downloads_combobox.get())
folder_structure = folder_structure_combobox.get()
max_retries = int(retry_combobox.get())
retry_interval = float(retry_interval_entry.get())
file_naming_mode_str = file_naming_combobox.get()
mapping = {
"Use File ID (default)": 0,
"Use Sanitized Post Name": 1,
"Post Name + Post ID Suffix": 2,
"Post Date/Time + Post Name": 3
}
numeric_mode = mapping.get(file_naming_mode_str, 0)
self.downloader.file_naming_mode = numeric_mode
self.settings['max_downloads'] = max_downloads
self.settings['folder_structure'] = folder_structure
self.settings['max_retries'] = max_retries
self.settings['retry_interval'] = retry_interval
self.settings['file_naming_mode'] = numeric_mode
self.save_settings()
if self.downloader:
self.downloader.update_max_downloads(max_downloads)
self.downloader.max_retries = max_retries
self.downloader.retry_interval = retry_interval
self.downloader.file_naming_mode = numeric_mode
messagebox.showinfo(
self.translate("Éxito"),
self.translate("La configuración de descargas se aplicó correctamente.")
)
except ValueError:
messagebox.showerror(
self.translate("Error"),
self.translate("Por favor, ingresa valores numéricos válidos.")
)
def apply_language_settings(self, selected_language_name):
if selected_language_name in self.languages:
selected_language_code = self.languages[selected_language_name]
self.settings['language'] = selected_language_code
self.save_settings()
self.save_language_preference(selected_language_code)
self.load_translations(selected_language_code)
self.update_ui_texts()
messagebox.showinfo(self.translate("Success"), self.translate("The language was applied successfully."))
else:
messagebox.showwarning(self.translate("Warning"), self.translate("Please select a language."))
def update_treeview(self):
"""Update the TreeViews to display the folder structure."""
if hasattr(self, 'default_treeview') and hasattr(self, 'post_treeview'):
self.default_treeview.delete(*self.default_treeview.get_children())
self.post_treeview.delete(*self.post_treeview.get_children())
root = self.default_treeview.insert("", "end", text="User", image=self.folder_structure_icons.get('folder'))
self.default_treeview.insert(root, "end", text="images", image=self.folder_structure_icons.get('folder'))
self.default_treeview.insert(root, "end", text="videos", image=self.folder_structure_icons.get('folder'))
self.default_treeview.insert(root, "end", text="documents", image=self.folder_structure_icons.get('folder'))
self.default_treeview.insert(root, "end", text="compressed", image=self.folder_structure_icons.get('folder'))
self.default_treeview.item(root, open=True)
post_root = self.post_treeview.insert("", "end", text="User", image=self.folder_structure_icons.get('folder'))
post = self.post_treeview.insert(post_root, "end", text="post_id", image=self.folder_structure_icons.get('folder'))
self.post_treeview.insert(post, "end", text="images", image=self.folder_structure_icons.get('folder'))
self.post_treeview.insert(post, "end", text="videos", image=self.folder_structure_icons.get('folder'))
self.post_treeview.insert(post, "end", text="documents", image=self.folder_structure_icons.get('folder'))
self.post_treeview.insert(post, "end", text="compressed", image=self.folder_structure_icons.get('folder'))
self.post_treeview.item(post_root, open=True)
def clear_frame(self, frame):
for widget in frame.winfo_children():
widget.destroy()
def get_language_name(self, lang_code):
for name, code in self.languages.items():
if code == lang_code:
return name
return "English"
def change_theme_in_thread(self, theme_name):
threading.Thread(target=self.apply_theme, args=(theme_name,)).start()
def apply_theme(self, theme_name):
if theme_name.lower() == "light":
ctk.set_appearance_mode("light")
elif theme_name.lower() == "dark":
ctk.set_appearance_mode("dark")
else:
ctk.set_appearance_mode("system")
self.settings['theme'] = theme_name
self.save_settings()
messagebox.showinfo(self.translate("Success"), self.translate("The theme was applied successfully."))
def center_window(self, window, width, height):
screen_width = window.winfo_screenwidth()
screen_height = window.winfo_screenheight()
x = int((screen_width / 2) - (width / 2))
y = int((screen_height / 2) - (height / 2))
window.geometry(f'{width}x{height}+{x}+{y}')
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,359 @@
import hashlib
import os
import requests
import time
from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urlparse, urljoin
import uuid
import re
import threading
class BunkrDownloader:
def __init__(self, download_folder, log_callback=None, enable_widgets_callback=None, update_progress_callback=None, update_global_progress_callback=None, headers=None, max_workers=5, translations=None):
self.download_folder = download_folder
self.log_callback = log_callback
self.enable_widgets_callback = enable_widgets_callback
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.session = requests.Session()
self.headers = headers or {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36',
'Referer': 'https://bunkr.site/',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
'Accept-Language': 'en-US,en;q=0.9',
}
self.cancel_requested = False # Flag to indicate if a cancellation request has been made
self.executor = ThreadPoolExecutor(max_workers=max_workers) # Thread pool executor for concurrent downloads
self.total_files = 0
self.completed_files = 0
self.max_downloads = 5 # Valor por defecto
self.log_messages = [] # Cola para almacenar mensajes de log
self.notification_interval = 10 # Intervalo de notificación en segundos
self.start_notification_thread()
self.translations = translations or {}
def start_notification_thread(self):
def notify_user():
while not self.cancel_requested:
if self.log_messages:
# Enviar todos los mensajes acumulados
if self.log_callback:
self.log_callback("\n".join(self.log_messages))
self.log_messages.clear()
time.sleep(self.notification_interval)
# Iniciar un hilo para notificaciones periódicas
notification_thread = threading.Thread(target=notify_user, daemon=True)
notification_thread.start()
def tr(self, key):
# Obtener la traducción para la clave dada
return self.translations.get(key, key)
def log(self, message_key, url=None):
message = self.tr(message_key)
domain = urlparse(url).netloc if url else "General"
full_message = f"{domain}: {message}"
self.log_messages.append(full_message) # Agregar mensaje a la cola
def request_cancel(self):
self.cancel_requested = True
self.log("Download has been cancelled.")
self.shutdown_executor()
def shutdown_executor(self):
self.executor.shutdown(wait=False)
self.log("Executor shut down.")
def clean_filename(self, filename):
return re.sub(r'[<>:"/\\|?*\u200b]', '_', filename)
def get_consistent_folder_name(self, url, default_name):
# Genera un hash de la URL para crear un nombre único y consistente
url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
folder_name = f"{default_name}_{url_hash}"
return self.clean_filename(folder_name)
def download_file(self, url_media, ruta_carpeta, file_id):
if self.cancel_requested:
self.log("Descarga cancelada", url=url_media)
return
file_name = os.path.basename(urlparse(url_media).path)
file_path = os.path.join(ruta_carpeta, file_name)
if os.path.exists(file_path):
self.log(f"El archivo ya existe, omitiendo: {file_path}")
self.completed_files += 1
if self.update_global_progress_callback:
self.update_global_progress_callback(self.completed_files, self.total_files)
return
max_attempts = 3
delay = 1
for attempt in range(max_attempts):
try:
self.log(f"Intentando descargar {url_media} (Intento {attempt + 1}/{max_attempts})")
response = self.session.get(url_media, headers=self.headers, stream=True)
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
downloaded_size = 0
# Descargar el archivo en fragmentos
with open(file_path, 'wb') as file:
for chunk in response.iter_content(chunk_size=65536): # Fragmentos de 64KB
if self.cancel_requested:
self.log("Descarga cancelada durante la descarga del archivo.", url=url_media)
file.close()
os.remove(file_path)
return
file.write(chunk)
downloaded_size += len(chunk)
if self.update_progress_callback:
self.update_progress_callback(downloaded_size, total_size, file_id=file_id, file_path=file_path)
self.log("Archivo descargado", url=url_media)
# Notificar al usuario al completar la descarga
if self.log_callback:
self.log_callback(f"Descarga completada: {file_name}")
self.completed_files += 1
if self.update_global_progress_callback:
self.update_global_progress_callback(self.completed_files, self.total_files)
break
except requests.RequestException as e:
if response.status_code == 429:
self.log(f"Límite de tasa excedido. Reintentando después de {delay} segundos.")
time.sleep(delay)
delay *= 2 # Retroceso exponencial para limitación de tasa
else:
self.log(f"Error al descargar de {url_media}: {e}. Intento {attempt + 1} de {max_attempts}", url=url_media)
if attempt < max_attempts - 1:
time.sleep(3)
def descargar_post_bunkr(self, url_post):
try:
self.log(f"Iniciando descarga para el post: {url_post}")
# Si se trata de una URL tipo '/f/', seguimos el flujo en dos pasos:
if '/f/' in url_post:
self.log("Detectado URL tipo '/f/'. Procediendo a extraer el enlace intermedio.")
# Paso 1: Accedemos a la URL original para obtener el primer enlace (intermedio)
response = self.session.get(url_post, headers=self.headers)
if response.status_code != 200:
self.log(f"Error al acceder al post {url_post}: Estado {response.status_code}")
return
soup = BeautifulSoup(response.text, 'html.parser')
first_anchor = soup.find('a', {
'class': 'btn btn-main btn-lg rounded-full px-6 font-semibold flex-1 ic-download-01 ic-before before:text-lg'
})
if not first_anchor or 'href' not in first_anchor.attrs:
self.log("No se encontró el primer enlace de descarga en la página original.")
return
intermediate_url = first_anchor['href']
self.log(f"Enlace intermedio encontrado: {intermediate_url}")
# Paso 2: Accedemos a la URL intermedia para extraer el enlace final de descarga
intermediate_response = self.session.get(intermediate_url, headers=self.headers)
if intermediate_response.status_code != 200:
self.log(f"Error al acceder a la URL intermedia: {intermediate_url} (Estado {intermediate_response.status_code})")
return
soup2 = BeautifulSoup(intermediate_response.text, 'html.parser')
# Buscamos la etiqueta <p> con la clase esperada y, dentro, el <a> con el enlace final
p_tag = soup2.find('p', class_="mt-3 text-center")
if not p_tag:
self.log("No se encontró la etiqueta <p> con clase 'mt-3 text-center' en la página intermedia.")
return
download_anchor = p_tag.find('a', {
'class': 'btn btn-main btn-lg rounded-full px-6 font-semibold ic-download-01 ic-before before:text-lg'
})
if not download_anchor or 'href' not in download_anchor.attrs:
self.log("No se encontró el enlace de descarga final en la página intermedia.")
return
final_download_url = download_anchor['href']
self.log(f"Enlace de descarga final encontrado: {final_download_url}")
# Creamos la carpeta de destino para este post
file_name = "bunkr_post"
folder_name = self.get_consistent_folder_name(url_post, file_name)
ruta_carpeta = os.path.join(self.download_folder, folder_name)
os.makedirs(ruta_carpeta, exist_ok=True)
# Preparamos la lista de medios con el enlace final
media_urls = [(final_download_url, ruta_carpeta)]
else:
# Lógica original para posts que contienen imágenes y videos
response = self.session.get(url_post, headers=self.headers)
if response.status_code != 200:
self.log(f"Error al acceder al post {url_post}: Estado {response.status_code}")
return
soup = BeautifulSoup(response.text, 'html.parser')
# Extraer y sanitizar el nombre de la carpeta para el post
file_name_tag = soup.find('h1', {'class': 'truncate'})
if file_name_tag:
file_name = file_name_tag.text.strip()
file_name = self.clean_filename(file_name)[:50] # Limitar a 50 caracteres
else:
file_name = "bunkr_post"
folder_name = self.get_consistent_folder_name(url_post, file_name)
ruta_carpeta = os.path.join(self.download_folder, folder_name)
os.makedirs(ruta_carpeta, exist_ok=True)
media_urls = []
# Buscar imágenes en etiquetas <figure>
media_divs = soup.find_all('figure', {'class': 'relative rounded-lg overflow-hidden flex justify-center items-center aspect-video bg-soft'})
for div in media_divs:
img_tags = div.find_all('img')
for img_tag in img_tags:
if 'src' in img_tag.attrs:
img_url = img_tag['src']
self.log(f"URL de imagen encontrada: {img_url}")
media_urls.append((img_url, ruta_carpeta))
# Buscar videos: se recorre cada div que pueda contener el enlace intermedio de descarga
video_divs = soup.find_all('div', {'class': 'flex w-full md:w-auto gap-4'})
self.log(f"Se encontraron {len(video_divs)} divs de video.")
for video_div in video_divs:
self.log("Buscando enlace de página de descarga en el div de video.")
download_page_link = video_div.find('a', {
'class': 'btn btn-main btn-lg rounded-full px-6 font-semibold flex-1 ic-download-01 ic-before before:text-lg'
})
if download_page_link and 'href' in download_page_link.attrs:
video_page_url = download_page_link['href']
self.log(f"URL de la página de descarga encontrada: {video_page_url}. Accediendo ahora.")
video_page_response = self.session.get(video_page_url, headers=self.headers)
self.log(f"Estado de la respuesta de la página de video: {video_page_response.status_code} para {video_page_url}")
if video_page_response.status_code == 200:
video_page_soup = BeautifulSoup(video_page_response.text, 'html.parser')
self.log("Buscando enlace de descarga real en la página de video.")
download_link = video_page_soup.find('a', {
'class': 'btn btn-main btn-lg rounded-full px-6 font-semibold ic-download-01 ic-before before:text-lg'
})
if download_link and 'href' in download_link.attrs:
video_url = download_link['href']
self.log(f"URL de descarga de video encontrada: {video_url}")
media_urls.append((video_url, ruta_carpeta))
else:
self.log(f"No se encontró enlace de descarga en la página de video: {video_page_url}")
else:
self.log(f"Error al acceder a la página de video: {video_page_url} con estado {video_page_response.status_code}")
# Proceder a la descarga de todos los medios encontrados
self.total_files = len(media_urls)
if media_urls: # Solo proceder si hay URLs para descargar
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=self.max_downloads) as executor:
futures = [executor.submit(self.download_file, url, folder, str(uuid.uuid4())) for url, folder in media_urls]
for future in as_completed(futures):
if self.cancel_requested:
self.log("Cancelando descargas restantes.")
break
future.result()
self.log("Descarga iniciada para todos los medios.")
if self.enable_widgets_callback:
self.enable_widgets_callback()
except Exception as e:
self.log(f"Error al procesar el post {url_post}: {e}")
if self.enable_widgets_callback:
self.enable_widgets_callback()
def descargar_perfil_bunkr(self, url_perfil):
try:
self.log(f"Iniciando descarga para el perfil: {url_perfil}")
response = self.session.get(url_perfil, headers=self.headers)
self.log(f"Código de estado de la respuesta: {response.status_code} para {url_perfil}")
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
# Extraer y sanitizar el nombre de la carpeta para el perfil
file_name_tag = soup.find('h1', {'class': 'truncate'})
if file_name_tag:
folder_name = file_name_tag.text.strip()
else:
folder_name = "bunkr_profile"
# Usar el nuevo método para obtener un nombre de carpeta consistente
folder_name = self.get_consistent_folder_name(url_perfil, folder_name)
ruta_carpeta = os.path.join(self.download_folder, folder_name)
os.makedirs(ruta_carpeta, exist_ok=True)
# Find media URLs in the profile
media_urls = []
grid_div = soup.find('div', {'class': 'grid gap-4 grid-cols-repeat [--size:11rem] lg:[--size:14rem] grid-images'})
if grid_div:
links = grid_div.find_all('a', {'class': 'after:absolute after:z-10 after:inset-0'})
total_links = len(links)
for idx, link in enumerate(links):
if self.cancel_requested:
self.log("Cancelling remaining downloads.")
break
# Resolve relative URLs to full URLs
image_page_url = urljoin(url_perfil, link['href'])
self.log(f"Processing media page URL: {image_page_url}")
# Visit the page to get the media URL
image_response = self.session.get(image_page_url, headers=self.headers)
if image_response.status_code == 200:
image_soup = BeautifulSoup(image_response.text, 'html.parser')
# Search for image URL
media_tag = image_soup.select_one("figure.relative img[class='w-full h-full absolute opacity-20 object-cover blur-sm z-10']")
if media_tag and 'src' in media_tag.attrs:
media_url = urljoin(image_page_url, media_tag['src']) # Resolve media URL
self.log(f"Found image URL: {media_url}")
media_urls.append((media_url, ruta_carpeta))
# Search for video URL
video_tag = image_soup.select_one("video#player")
if video_tag and 'src' in video_tag.attrs:
video_url = urljoin(image_page_url, video_tag['src']) # Resolve video URL
self.log(f"Found video URL: {video_url}")
media_urls.append((video_url, ruta_carpeta))
else:
source_tag = video_tag.find('source') if video_tag else None
if source_tag and 'src' in source_tag.attrs:
video_url = urljoin(image_page_url, source_tag['src']) # Resolve video URL from source
self.log(f"Found video URL from source: {video_url}")
media_urls.append((video_url, ruta_carpeta))
self.total_files = len(media_urls)
futures = [self.executor.submit(self.download_file, url, folder, str(uuid.uuid4())) for url, folder in media_urls]
# Only after all futures are done, enable widgets again
for future in as_completed(futures):
if self.cancel_requested:
self.log("Cancelling remaining downloads.")
break
future.result()
self.log("Download completed for all media.")
if self.enable_widgets_callback:
self.enable_widgets_callback() # Only enable after all downloads are done
else:
self.log(f"Failed to access the profile {url_perfil}: Status {response.status_code}")
except Exception as e:
self.log(f"Failed to access the profile {url_perfil}: {e}")
if self.enable_widgets_callback:
self.enable_widgets_callback()
def set_max_downloads(self, max_downloads):
self.max_downloads = max_downloads
@@ -0,0 +1,724 @@
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Semaphore
from urllib.parse import quote_plus, urlencode, urljoin, urlparse
import os
import re
import requests
import threading
import time
import sqlite3
class Downloader:
def __init__(self, download_folder, max_workers=5, log_callback=None,
enable_widgets_callback=None, update_progress_callback=None,
update_global_progress_callback=None, headers=None,
max_retries=999999, retry_interval=1.0, stream_read_timeout=10,
download_images=True, download_videos=True, download_compressed=True,
tr=None, folder_structure='default', rate_limit_interval=1.0):
self.download_folder = download_folder
self.log_callback = log_callback
self.enable_widgets_callback = enable_widgets_callback
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.cancel_requested = threading.Event()
self.headers = headers or {
'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
'Referer': 'https://coomer.st/',
"Accept": "text/css"
}
self.media_counter = 0
self.session = requests.Session()
self.max_workers = max_workers
self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
self.rate_limit = Semaphore(self.max_workers)
self.domain_locks = defaultdict(lambda: Semaphore(self.max_workers))
self.domain_last_request = defaultdict(float)
self.rate_limit_interval = rate_limit_interval
self.download_mode = "multi"
self.video_extensions = ('.mp4', '.mkv', '.webm', '.mov', '.avi', '.flv', '.wmv', '.m4v')
self.image_extensions = ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff')
self.document_extensions = ('.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx')
self.compressed_extensions = ('.zip', '.rar', '.7z', '.tar', '.gz')
self.download_images = download_images
self.download_videos = download_videos
self.download_compressed = download_compressed
self.futures = []
self.total_files = 0
self.completed_files = 0
self.skipped_files = []
self.failed_files = []
self.start_time = None
self.tr = tr
self.shutdown_called = False
self.folder_structure = folder_structure
self.failed_retry_count = {}
self.max_retries = max_retries
self.retry_interval = retry_interval
self.file_lock = threading.Lock()
self.post_attachment_counter = defaultdict(int)
self.subdomain_cache = {}
self.subdomain_locks = defaultdict(threading.Lock)
self.stream_read_timeout = stream_read_timeout
db_folder = os.path.join("resources", "config")
os.makedirs(db_folder, exist_ok=True)
self.db_path = os.path.join(db_folder, "downloads.db")
self.db_lock = threading.Lock()
self.init_db()
self.load_download_cache()
def init_db(self):
self.db_connection = sqlite3.connect(self.db_path, check_same_thread=False)
self.db_cursor = self.db_connection.cursor()
self.db_cursor.execute("""
CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
media_url TEXT UNIQUE,
file_path TEXT,
file_size INTEGER,
user_id TEXT,
post_id TEXT,
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
self.db_connection.commit()
def load_download_cache(self):
with self.db_lock:
self.db_cursor.execute("SELECT media_url, file_path, file_size FROM downloads")
rows = self.db_cursor.fetchall()
self.download_cache = {row[0]: (row[1], row[2]) for row in rows}
def log(self, message):
if self.log_callback:
self.log_callback(self.tr(message) if self.tr else message)
def set_download_mode(self, mode, max_workers):
if mode == 'queue':
max_workers = 1
self.download_mode = mode
self.max_workers = max_workers
if self.executor:
self.executor.shutdown(wait=True)
self.executor = ThreadPoolExecutor(max_workers=max_workers)
self.rate_limit = Semaphore(max_workers)
self.log(f"Updated download mode to {mode} with max_workers = {max_workers}")
def set_retry_settings(self, max_retries, retry_interval):
self.max_retries = max_retries
self.rate_limit_interval = retry_interval
def request_cancel(self):
self.cancel_requested.set()
self.log(self.tr("Download cancellation requested."))
for future in self.futures:
future.cancel()
def shutdown_executor(self):
if not self.shutdown_called:
self.shutdown_called = True
if self.executor:
self.executor.shutdown(wait=True)
if self.enable_widgets_callback:
self.enable_widgets_callback()
self.log(self.tr("All downloads completed or cancelled."))
def safe_request(self, url, max_retries=None, headers=None):
if max_retries is None:
max_retries = self.max_retries
if headers is None:
headers = self.headers
parsed = urlparse(url)
domain = parsed.netloc
path = parsed.path
for attempt in range(max_retries + 1):
if self.cancel_requested.is_set():
return None
with self.domain_locks[domain]:
elapsed_time = time.time() - self.domain_last_request[domain]
if elapsed_time < self.rate_limit_interval:
time.sleep(self.rate_limit_interval - elapsed_time)
try:
self.domain_last_request[domain] = time.time()
response = self.session.get(url, stream=True, headers=headers, timeout=self.stream_read_timeout)
sc = response.status_code
if sc in (403, 404) and ("coomer" in domain or "kemono" in domain):
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"{sc} - probing subdomains")
with self.subdomain_locks[path]:
if path in self.subdomain_cache:
alt_url = self.subdomain_cache[path]
else:
alt_url = self._find_valid_subdomain(url)
self.subdomain_cache[path] = alt_url
if alt_url != url:
found = urlparse(alt_url).netloc
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Subdomain found: {found}")
response = self.session.get(alt_url, stream=True, headers=headers)
response.raise_for_status()
return response
else:
if self.update_progress_callback:
self.update_progress_callback(0, 0, status="Exhausted subdomains")
return None
response.raise_for_status()
return response
except requests.exceptions.RequestException as e:
status_code = getattr(e.response, 'status_code', None)
if status_code in (429, 500, 502, 503, 504):
self.log(log_message)
time.sleep(self.retry_interval)
elif status_code not in (403, 404):
url_display = getattr(e.request, 'url', url)
if len(url_display) > 60:
url_display = url_display[:60] + "..."
self.log(self.tr("Intento {attempt}/{max_retries_val}: Error al acceder a {url} - {error}").format(
attempt=attempt + 1, max_retries_val=max_retries + 1, url=url_display, error=e))
if attempt < max_retries:
time.sleep(self.retry_interval)
else:
if isinstance(e, requests.exceptions.ReadTimeout):
self.log(self.tr("Intento {attempt}/{max_retries_val}: Read timeout ({stream_timeout}s) - Reintentando...").format(
attempt=attempt + 1,
max_retries_val=max_retries + 1,
stream_timeout=self.stream_read_timeout
))
time.sleep(self.retry_interval)
else:
log_message = self.tr("Intento {attempt}/{max_retries_val}: Error {status_code} - Reintentando...").format(
attempt=attempt + 1, max_retries_val=max_retries + 1, status_code=status_code)
if status_code in (403, 404) and ("coomer" in domain or "kemono" in domain) and attempt == max_retries:
self.log(self.tr("Fallo final al acceder a {url} con error {status_code}").format(url=url, status_code=status_code))
return None
def _find_valid_subdomain(self, url, max_subdomains=10):
parsed = urlparse(url)
original_path = parsed.path
path = original_path
if not original_path.startswith("/data/"):
path = ("/data" + original_path) if not original_path.startswith("/data") else original_path
host = parsed.netloc
if "coomer" in host:
base_domains = ["coomer.st"]
elif "kemono" in host:
base_domains = ["kemono.cr", "kemono.su"]
else:
base_domains = [host]
for base in base_domains:
for i in range(1, max_subdomains + 1):
domain = f"n{i}.{base}"
test_url = parsed._replace(netloc=domain, path=path).geturl()
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Testing subdomain: {domain}")
try:
resp = self.session.get(test_url, headers=self.headers,
timeout=self.stream_read_timeout, stream=True)
if resp.status_code == 200:
return test_url
else:
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Invalid subdomain: {domain}")
except requests.exceptions.ReadTimeout:
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Timeout in: {domain}")
except Exception:
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Invalid subdomain: {domain}")
return url
def fetch_user_posts(self, site, user_id, service, query=None, specific_post_id=None, initial_offset=0, log_fetching=True):
all_posts = []
offset = initial_offset
user_id_encoded = quote_plus(user_id)
while True:
if self.cancel_requested.is_set():
return all_posts
api_url = f"https://{site}/api/v1/{service}/user/{user_id_encoded}/posts"
url_query = {"o": offset}
if query not in (None, "", 0, "0"):
url_query["q"] = query
api_url += "?" + urlencode(url_query)
if log_fetching:
self.log(self.tr("Fetching user posts from {api_url}", api_url=api_url))
try:
response = self.session.get(api_url, headers=self.headers)
if response.status_code == 400:
self.log(self.tr("End of posts at offset {offset}.", offset=offset))
break
response.raise_for_status()
try:
posts_data = response.json()
except ValueError as e:
self.log(self.tr("Error al parsear JSON: {e}", e=e))
break
if isinstance(posts_data, dict) and 'data' in posts_data:
posts = posts_data['data']
else:
posts = posts_data
if not posts:
break
if specific_post_id:
post = next((p for p in posts if p['id'] == specific_post_id), None)
if post:
return [post]
all_posts.extend(posts)
offset += 50
except Exception as e:
self.log(self.tr("Error fetching user posts: {e}", e=e))
break
if specific_post_id:
return [post for post in all_posts if post['id'] == specific_post_id]
return all_posts
def get_filename(self, media_url, post_id=None, post_name=None, attachment_index=1, post_time=None):
base_name = os.path.basename(media_url).split('?')[0]
name_no_ext, extension = os.path.splitext(base_name)
if not hasattr(self, 'file_naming_mode'):
self.file_naming_mode = 0
mode = self.file_naming_mode
def sanitize(name):
sanitized = self.sanitize_filename(name)
return sanitized.strip()
if mode == 0:
sanitized = sanitize(name_no_ext)
if not sanitized:
sanitized = "file"
final_name = f"{sanitized}_{attachment_index}{extension}"
elif mode == 1:
sanitized_post = sanitize(post_name or "")
if not sanitized_post:
sanitized_post = f"post_{post_id}" if post_id else "post"
short_hash = f"{hash(media_url) & 0xFFFF:04x}"
final_name = f"{sanitized_post}_{attachment_index}_{short_hash}{extension}"
elif mode == 2:
sanitized_post = sanitize(post_name or "")
if not sanitized_post:
sanitized_post = f"post_{post_id}" if post_id else "post"
if post_id:
final_name = f"{sanitized_post} - {post_id}_{attachment_index}{extension}"
else:
final_name = f"{sanitized_post}_{attachment_index}{extension}"
elif mode == 3:
sanitized_post = sanitize(post_name or "")
if not sanitized_post:
sanitized_post = f"post_{post_id}" if post_id else "post"
sanitized_time = sanitize(post_time or "")
short_hash = f"{hash(media_url) & 0xFFFF:04x}"
final_name = f"{sanitized_time} - {sanitized_post}_{attachment_index}_{short_hash}{extension}"
else:
final_name = sanitize(name_no_ext) + extension
return final_name
def process_post(self, post, site):
base = f"https://{site}/"
def _full(path):
if not path:
return None
p = path if str(path).startswith('/') else f'/{path}'
return urljoin(base, p)
media_urls = []
f = post.get('file') or {}
u = _full(f.get('path') or f.get('url') or f.get('name'))
if u:
media_urls.append(u)
for att in (post.get('attachments') or []):
u = _full(att.get('path') or att.get('url') or att.get('name'))
if u:
media_urls.append(u)
return media_urls
def sanitize_filename(self, filename):
return re.sub(r'[<>:"/\\|?*]', '_', filename)
def get_media_folder(self, extension, user_id, post_id=None):
if extension in self.video_extensions:
folder_name = "videos"
elif extension in self.image_extensions:
folder_name = "images"
elif extension in self.document_extensions:
folder_name = "documents"
elif extension in self.compressed_extensions:
folder_name = "compressed"
else:
folder_name = "other"
if self.folder_structure == 'post_number' and post_id:
media_folder = os.path.join(self.download_folder, user_id, f'post_{post_id}', folder_name)
else:
media_folder = os.path.join(self.download_folder, user_id, folder_name)
return media_folder
def process_media_element(self, media_url, user_id, post_id=None,
post_name=None, post_time=None, download_id=None):
if self.cancel_requested.is_set():
return
extension = os.path.splitext(media_url)[1].lower()
if (extension in self.image_extensions and not self.download_images) or \
(extension in self.video_extensions and not self.download_videos) or \
(extension in self.compressed_extensions and not self.download_compressed):
self.log(f"Skipping {media_url} due to settings.")
return
if post_id:
self.post_attachment_counter[post_id] += 1
attachment_index = self.post_attachment_counter[post_id]
else:
attachment_index = 1
filename = self.get_filename(media_url, post_id=post_id, post_name=post_name, post_time=post_time,
attachment_index=attachment_index)
media_folder = self.get_media_folder(extension, user_id, post_id)
os.makedirs(media_folder, exist_ok=True)
final_path = os.path.normpath(os.path.join(media_folder, filename))
tmp_path = final_path + ".tmp"
if media_url in self.download_cache:
self.log(f"File from {media_url} is in DB, skipping.")
with self.file_lock:
self.skipped_files.append(final_path)
return
self.log(f"Starting download from {media_url}")
for attempt in range(self.max_retries + 1):
if self.cancel_requested.is_set():
if os.path.exists(tmp_path):
os.remove(tmp_path)
self.log(f"Download cancelled from {media_url}")
return
response = self.safe_request(media_url, max_retries=self.max_retries)
if response is None:
if attempt < self.max_retries:
self.log(f"Initial request failed for {media_url}. Resuming download in {self.retry_interval}s. (Attempt {attempt+1}/{self.max_retries + 1})")
time.sleep(self.retry_interval)
continue
else:
break
try:
try:
total_size = int(response.headers.get('content-length', 0))
except Exception as e:
self.log(f"Error getting total size: {e}")
total_size = 0
downloaded_size = 0
self.start_time = time.time()
with open(tmp_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=1048576):
if self.cancel_requested.is_set():
raise Exception("Cancellation Requested")
if chunk:
f.write(chunk)
downloaded_size += len(chunk)
if self.update_progress_callback:
elapsed_time = time.time() - self.start_time
speed = downloaded_size / elapsed_time if elapsed_time > 0 else 0
remaining_time = (total_size - downloaded_size) / speed if speed > 0 else 0
self.update_progress_callback(downloaded_size, total_size,
file_id=download_id,
file_path=tmp_path,
speed=speed,
eta=remaining_time)
while total_size and downloaded_size < total_size:
resume_headers = self.headers.copy()
resume_headers['Range'] = f'bytes={downloaded_size}-'
self.log(f"Resuming download at byte {downloaded_size} for {media_url}")
part_response = self.safe_request(media_url, max_retries=self.max_retries, headers=resume_headers)
if part_response is None:
raise Exception("Resumption Failed after retries")
with open(tmp_path, 'ab') as f:
for chunk in part_response.iter_content(chunk_size=1048576):
if self.cancel_requested.is_set():
raise Exception("Cancellation Requested")
if chunk:
f.write(chunk)
downloaded_size += len(chunk)
if self.update_progress_callback:
elapsed_time = time.time() - self.start_time
speed = downloaded_size / elapsed_time if elapsed_time > 0 else 0
remaining_time = (total_size - downloaded_size) / speed if speed > 0 else 0
self.update_progress_callback(downloaded_size, total_size,
file_id=download_id,
file_path=tmp_path,
speed=speed,
eta=remaining_time)
if total_size > 0 and downloaded_size != total_size:
raise Exception(f"Final size mismatch: expected {total_size}, got {downloaded_size}")
with self.file_lock:
if os.path.exists(final_path):
os.remove(final_path)
os.rename(tmp_path, final_path)
with self.file_lock:
self.completed_files += 1
self.log(f"Download success from {media_url}")
if self.update_global_progress_callback:
self.update_global_progress_callback(self.completed_files, self.total_files)
with self.db_lock:
self.db_cursor.execute(
"""INSERT OR REPLACE INTO downloads (media_url, file_path, file_size, user_id, post_id)
VALUES (?, ?, ?, ?, ?)""",
(media_url, final_path, total_size, user_id, post_id)
)
self.db_connection.commit()
self.download_cache[media_url] = (final_path, total_size)
return
except Exception as e:
if str(e) == "Cancellation Requested":
if os.path.exists(tmp_path):
os.remove(tmp_path)
self.log(f"Download cancelled from {media_url}")
return
if attempt < self.max_retries:
time.sleep(self.retry_interval)
continue
self.log(f"Failed to download {media_url} after {self.max_retries + 1} total download attempts.")
with self.file_lock:
self.failed_files.append(media_url)
def get_remote_file_size(self, media_url, filename):
try:
response = requests.head(media_url, allow_redirects=True)
if response.status_code == 200:
size = int(response.headers.get('Content-Length', 0))
return media_url, filename, size
else:
self.log(self.tr(f"Failed to get size for {filename}: HTTP {response.status_code}"))
return media_url, filename, None
except Exception as e:
self.log(self.tr(f"Error getting size for {filename}: {e}"))
return media_url, filename, None
def download_media(self, site, user_id, service, query=None, download_all=False, initial_offset=0):
try:
self.log(self.tr("Starting download process..."))
posts = self.fetch_user_posts(
site, user_id, service,
query=query,
initial_offset=initial_offset,
log_fetching=download_all
)
if not posts:
self.log(self.tr("No posts found for this user."))
return
if not download_all:
posts = posts[:50]
self.total_files = 0
for post in posts:
current_post_id = post.get('id') or "unknown_id"
title = post.get('title') or ""
media_urls = self.process_post(post, site)
for media_url in media_urls:
ext = os.path.splitext(media_url)[1].lower()
if (ext in self.image_extensions and not self.download_images) or \
(ext in self.video_extensions and not self.download_videos) or \
(ext in self.compressed_extensions and not self.download_compressed):
continue
self.total_files += 1
futures = []
for post in posts:
current_post_id = post.get('id') or "unknown_id"
title = post.get('title') or ""
time = post.get('published') or ""
media_urls = self.process_post(post, site)
for media_url in media_urls:
ext = os.path.splitext(media_url)[1].lower()
if (ext in self.image_extensions and not self.download_images) or \
(ext in self.video_extensions and not self.download_videos) or \
(ext in self.compressed_extensions and not self.download_compressed):
continue
if self.download_mode == 'queue':
self.process_media_element(
media_url,
user_id,
post_id=current_post_id,
post_name=title,
post_time=time
)
else:
future = self.executor.submit(
self.process_media_element,
media_url,
user_id,
current_post_id,
title,
time,
media_url
)
futures.append(future)
if self.download_mode == 'multi':
for future in as_completed(futures):
if self.cancel_requested.is_set():
break
except Exception as e:
self.log(self.tr(f"Error during download: {e}"))
finally:
self.shutdown_executor()
def download_single_post(self, site, post_id, service, user_id):
try:
post = self.fetch_user_posts(site, user_id, service, specific_post_id=post_id)
if not post:
self.log(self.tr("No post found for this ID."))
return
media_urls = self.process_post(post[0], site)
futures = []
grouped_media_urls = defaultdict(list)
for media_url in media_urls:
grouped_media_urls[post[0]['id']].append(media_url)
self.total_files = len(media_urls)
self.completed_files = 0
for post_id, media_urls in grouped_media_urls.items():
for media_url in media_urls:
if self.download_mode == 'queue':
self.process_media_element(media_url, user_id, post_id)
else:
future = self.executor.submit(self.process_media_element, media_url, user_id, post_id)
futures.append(future)
if self.download_mode == 'multi':
for future in as_completed(futures):
if self.cancel_requested.is_set():
break
except Exception as e:
self.log(self.tr(f"Error during download: {e}"))
finally:
self.shutdown_executor()
def fetch_single_post(self, site, post_id, service):
api_url = f"https://{site}/api/v1/{service}/post/{post_id}"
self.log(self.tr(f"Fetching post from {api_url}"))
try:
with self.rate_limit:
response = self.session.get(api_url, headers=self.headers)
response.raise_for_status()
return response.json()
except Exception as e:
self.log(self.tr(f"Error fetching post: {e}"))
return None
def clear_database(self):
with self.db_lock:
self.db_cursor.execute("DELETE FROM downloads")
self.db_connection.commit()
self.log(self.tr("Database cleared."))
def update_max_downloads(self, new_max):
if self.executor:
self.executor.shutdown(wait=True)
self.max_workers = new_max
self.executor = ThreadPoolExecutor(max_workers=new_max)
self.rate_limit = Semaphore(new_max)
self.log(f"Updated max_workers to {new_max}")
@@ -0,0 +1,287 @@
import re
import uuid
import requests
import os
import time
import datetime
from bs4 import BeautifulSoup
from urllib.parse import urljoin, quote
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from requests.exceptions import ChunkedEncodingError
from tkinter import messagebox, simpledialog
class EromeDownloader:
def __init__(self, root, log_callback=None, enable_widgets_callback=None, update_progress_callback=None, update_global_progress_callback=None, download_images=True, download_videos=True, headers=None, language="en", is_profile_download=False, direct_download=False, tr=None, max_workers=5):
self.root = root
self.session = requests.Session()
self.headers = {k: str(v).encode('ascii', 'ignore').decode('ascii') for k, v in (headers or {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
}).items()}
self.log_messages = [] # Store log messages
self.log_callback = log_callback
self.enable_widgets_callback = enable_widgets_callback
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.download_images = download_images
self.download_videos = download_videos
self.cancel_requested = False
self.language = language
self.executor = ThreadPoolExecutor(max_workers=max_workers) # Thread pool for concurrent downloads
self.total_files = 0
self.completed_files = 0
self.is_profile_download = is_profile_download
self.direct_download = direct_download # Option for direct downloads without folder creation
self.tr = tr if tr else lambda x, **kwargs: x.format(**kwargs) # Translation function
def request_cancel(self):
self.cancel_requested = True
self.log(self.tr("Download cancelled"))
if self.is_profile_download:
self.enable_widgets_callback()
def log(self, message):
if self.log_callback is not None:
self.log_callback(message)
self.log_messages.append(message)
def shutdown_executor(self):
self.executor.shutdown(wait=False)
self.log(self.tr("Executor shut down."))
if self.is_profile_download:
self.enable_widgets_callback()
@staticmethod
def clean_filename(filename):
return re.sub(r'[<>:"/\\|?*]', '_', filename.split('?')[0])
def create_folder(self, folder_name):
try:
os.makedirs(folder_name, exist_ok=True)
except OSError as e:
self.log(self.tr("Error creating folder: {error}", error=e))
response = messagebox.askyesno(self.tr("Error"), self.tr("Couldn't create folder: {folder_name}\nWould you like to choose a new name?", folder_name=folder_name), parent=self.root)
if response:
new_folder_name = simpledialog.askstring(self.tr("New folder name"), self.tr("Enter new folder name:"), parent=self.root)
if new_folder_name:
folder_name = os.path.join(os.path.dirname(folder_name), self.clean_filename(new_folder_name))
try:
os.makedirs(folder_name, exist_ok=True)
except OSError as e:
messagebox.showerror(self.tr("Error"), self.tr("Could not create folder: {folder_name}\nError: {error}", folder_name=folder_name, error=e), parent=self.root)
return folder_name
def download_file(self, url, file_path, resource_type, file_id=None, max_retries=999999):
if self.cancel_requested:
return
# Evita sobreescrituras y descargas duplicadas
if os.path.exists(file_path):
self.log(self.tr("File already exists, skipping: {file_path}",
file_path=file_path))
return
os.makedirs(os.path.dirname(file_path), exist_ok=True)
self.log(self.tr("Start downloading {resource_type}: {file_path}",
resource_type=resource_type, file_path=file_path))
retries = 0
while retries <= max_retries:
try:
with requests.get(url, headers=self.headers,
stream=True, timeout=15) as response:
if response.status_code != 200:
self.log(self.tr("Error downloading {resource_type}, "
"status code: {status_code}",
resource_type=resource_type,
status_code=response.status_code))
break
total_size = int(response.headers.get("content-length", 0))
downloaded_size = 0
start_time = last_update = time.time()
with open(file_path, "wb") as f:
for chunk in response.iter_content(chunk_size=65536):
if self.cancel_requested:
return
f.write(chunk)
downloaded_size += len(chunk)
# Envía actualización cada 0.5 s máx.
now = time.time()
if now - last_update >= 0.5:
elapsed = now - start_time
speed = downloaded_size / elapsed if elapsed else 0
eta = ((total_size - downloaded_size) / speed
if speed else None)
self.update_progress_callback(
downloaded_size, total_size,
file_id=file_id,
file_path=file_path,
speed=speed,
eta=eta
)
last_update = now
# ─────── Fin de la descarga ───────
elapsed = time.time() - start_time
final_speed = total_size / elapsed if elapsed else 0
# 1· Fuerza la barra al 100 % (sin status)
self.update_progress_callback(
total_size, total_size,
file_id=file_id,
file_path=file_path,
speed=final_speed,
eta=0
)
# 2· Notifica “Completed” (no altera la barra)
self.update_progress_callback(
total_size, total_size,
file_id=file_id,
file_path=file_path,
status="Completed"
)
# Contabiliza y avanza la barra global
self.completed_files += 1
if self.update_global_progress_callback:
self.update_global_progress_callback(
self.completed_files, self.total_files
)
self.log(self.tr("Download successful: {resource_type}, "
"{file_path}",
resource_type=resource_type,
file_path=file_path))
break # Éxito ⇒ sal del bucle
except (requests.exceptions.ChunkedEncodingError,
requests.exceptions.ConnectionError,
requests.exceptions.Timeout) as e:
retries += 1
self.log(self.tr("Error downloading {resource_type}, "
"attempt {retries}/{max_retries}: {error}",
resource_type=resource_type,
retries=retries,
max_retries=max_retries,
error=e))
if retries == max_retries:
self.log(self.tr("Max retries reached. Failed to "
"download {resource_type}: {file_path}",
resource_type=resource_type,
file_path=file_path))
def process_album_page(self, page_url, base_folder, download_images=True, download_videos=True):
try:
if self.cancel_requested:
return
self.log(self.tr("Processing album URL: {page_url}", page_url=page_url))
response = requests.get(page_url, headers=self.headers)
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
if not self.direct_download:
folder_name = self.clean_filename(soup.find('h1').text if soup.find('h1') else self.tr("Unknown Album"))
folder_path = self.create_folder(os.path.join(base_folder, folder_name))
else:
folder_path = base_folder # Use the base folder directly
media_urls = []
seen_urls = set()
# --- vídeos ---
if download_videos:
for video in soup.find_all('video'):
source = video.find('source')
if source:
abs_video_src = urljoin(page_url, source['src'])
if abs_video_src in seen_urls: # ya lo tenemos
continue
seen_urls.add(abs_video_src)
video_name = os.path.join(
folder_path,
self.clean_filename(os.path.basename(abs_video_src))
)
media_urls.append(
(abs_video_src, video_name, 'Video')
)
# --- imágenes ---
if download_images:
for div in soup.select('div.img'):
img = div.find('img', attrs={'data-src': True})
if img:
abs_img_src = urljoin(page_url, img['data-src'])
if abs_img_src in seen_urls:
continue
seen_urls.add(abs_img_src)
img_name = os.path.join(
folder_path,
self.clean_filename(os.path.basename(abs_img_src))
)
media_urls.append(
(abs_img_src, img_name, 'Image')
)
self.total_files += len(media_urls)
futures = [self.executor.submit(self.download_file, url, file_path, resource_type, str(uuid.uuid4())) for url, file_path, resource_type in media_urls]
for future in as_completed(futures):
if self.cancel_requested:
self.log(self.tr("Cancelling remaining downloads."))
break
future.result()
self.log(self.tr("Album download complete: {folder_name}", folder_name=folder_name) if not self.direct_download else self.tr("Album download complete"))
if not self.is_profile_download:
self.enable_widgets_callback()
else:
self.log(self.tr("Error accessing page: {page_url}, status code: {status_code}", page_url=page_url, status_code=response.status_code))
if not self.is_profile_download:
self.enable_widgets_callback()
finally:
if not self.is_profile_download:
self.enable_widgets_callback()
self.export_logs()
def process_profile_page(self, url, download_folder, download_images, download_videos):
try:
if self.cancel_requested:
return
self.log(self.tr("Processing profile URL: {url}", url=url))
response = requests.get(url, headers=self.headers)
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
username = soup.find('h1', class_='username').text.strip() if soup.find('h1', class_='username') else self.tr("Unknown Profile")
base_folder = self.create_folder(os.path.join(download_folder, self.clean_filename(username)))
album_links = soup.find_all('a', class_='album-link')
for album_link in album_links:
album_href = album_link.get('href')
album_full_url = urljoin(url, album_href)
self.process_album_page(album_full_url, base_folder, download_images, download_videos)
self.log(self.tr("Profile download complete: {username}", username=username))
self.enable_widgets_callback()
else:
self.log(self.tr("Error accessing page: {url}, status code: {status_code}", url=url, status_code=response.status_code))
self.enable_widgets_callback()
finally:
if not self.is_profile_download:
self.enable_widgets_callback()
self.export_logs()
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:
with open(log_file_path, 'w') as file:
file.write("\n".join(self.log_messages))
self.log(self.tr("Logs exported successfully to {path}", path=log_file_path))
except Exception as e:
self.log(self.tr(f"Failed to export logs: {e}"))
@@ -0,0 +1,112 @@
import requests
from bs4 import BeautifulSoup
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from app import progress_manager
class Jpg5Downloader:
def __init__(self, url, carpeta_destino, progress_manager, log_callback=None, tr=None, update_progress_callback=None, update_global_progress_callback=None, max_workers=3):
self.url = url
self.carpeta_destino = carpeta_destino
self.log_callback = log_callback
self.tr = tr if tr else lambda x: x # Función de traducción por defecto
self.cancel_requested = threading.Event() # Usar un evento para manejar la cancelación
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.max_workers = max_workers
self.progress_manager = progress_manager
def log(self, message):
if self.log_callback:
self.log_callback(message)
else:
print(message)
def request_cancel(self):
self.cancel_requested.set() # Activar el evento de cancelación
self.log(self.tr("Descarga cancelada por el usuario."))
def descargar_imagenes(self):
if not os.path.exists(self.carpeta_destino):
os.makedirs(self.carpeta_destino)
self.log(self.tr(f"Iniciando descarga desde: {self.url}"))
respuesta = requests.get(self.url)
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
soup = BeautifulSoup(respuesta.content, 'html.parser')
divs = soup.find_all('div', class_='list-item c8 gutter-margin-right-bottom')
total_divs = len(divs)
self.log(self.tr(f"Total de elementos a procesar: {total_divs}"))
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
futures = []
for i, div in enumerate(divs):
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
enlaces = div.find_all('a', class_='image-container --media')
for enlace in enlaces:
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
futures.append(executor.submit(self.descargar_enlace, enlace, i, total_divs))
for future in futures:
future.result() # Esperar a que todas las descargas terminen
def descargar_enlace(self, enlace, i, total_divs):
try:
media_url = enlace['href']
self.log(self.tr(f"Procesando enlace: {media_url}"))
media_respuesta = requests.get(media_url)
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
media_soup = BeautifulSoup(media_respuesta.content, 'html.parser')
header_content = media_soup.find('div', class_='header-content-right')
if header_content:
btn_descarga = header_content.find('a', class_='btn btn-download default')
if btn_descarga and 'href' in btn_descarga.attrs:
descarga_url = btn_descarga['href']
self.log(self.tr(f"Descargando desde: {descarga_url}"))
img_respuesta = requests.get(descarga_url, stream=True)
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
img_nombre = os.path.basename(descarga_url)
img_path = os.path.join(self.carpeta_destino, img_nombre)
total_size = int(img_respuesta.headers.get('content-length', 0))
downloaded_size = 0
with open(img_path, 'wb') as f:
for chunk in img_respuesta.iter_content(chunk_size=1024):
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
f.write(chunk)
downloaded_size += len(chunk)
if self.update_progress_callback:
self.update_progress_callback(downloaded_size, total_size)
self.log(self.tr(f"Imagen descargada: {img_nombre}"))
if self.update_global_progress_callback:
self.update_global_progress_callback(i + 1, total_divs)
else:
self.log(self.tr("No se encontró el enlace de descarga."))
else:
self.log(self.tr("No se encontró la clase 'header-content-right'."))
except Exception as e:
self.log(self.tr(f"Error al procesar el enlace: {e}"))
@@ -0,0 +1,137 @@
import os
import json
import re
import queue
from pathlib import Path
from bs4 import BeautifulSoup
from urllib.parse import urlparse
import cloudscraper
from concurrent.futures import ThreadPoolExecutor
class SimpCity:
def __init__(self, download_folder, max_workers=5, log_callback=None, enable_widgets_callback=None, update_progress_callback=None, update_global_progress_callback=None, tr=None):
self.download_folder = download_folder
self.max_workers = max_workers
self.descargadas = set()
self.log_callback = log_callback
self.enable_widgets_callback = enable_widgets_callback
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.cancel_requested = False
self.total_files = 0
self.completed_files = 0
self.download_queue = queue.Queue()
self.scraper = cloudscraper.create_scraper(browser={'browser': 'chrome', 'platform': 'windows', 'mobile': False})
self.tr = tr
# Selectors from original crawler
self.title_selector = "h1[class=p-title-value]"
self.posts_selector = "div[class*=message-main]"
self.post_content_selector = "div[class*=message-userContent]"
self.images_selector = "img[class*=bbImage]"
self.videos_selector = "video source"
self.iframe_selector = "iframe[class=saint-iframe]"
self.attachments_block_selector = "section[class=message-attachments]"
self.attachments_selector = "a"
self.next_page_selector = "a[class*=pageNav-jump--next]"
self.cookies_path = "resources/config/cookies/simpcity.json"
self.set_cookies()
def log(self, message):
if self.log_callback:
self.log_callback(message)
def sanitize_folder_name(self, name):
return re.sub(r'[<>:"/\\|?*]', '_', name)
def set_cookies(self):
if os.path.exists(self.cookies_path):
with open(self.cookies_path, "r", encoding="utf-8") as f:
cookies = json.load(f)
if isinstance(cookies, dict):
cookies = [cookies]
for c in cookies:
if isinstance(c, dict) and "name" in c and "value" in c:
self.scraper.cookies.set(c["name"], c["value"])
def fetch_page(self, url):
try:
response = self.scraper.get(url)
if response.status_code == 200:
return BeautifulSoup(response.content, 'html.parser')
else:
self.log(self.tr(f"Error: {response.status_code} al acceder a {url}"))
return None
except Exception as e:
self.log(self.tr(f"Error al acceder a {url}: {e}"))
return None
def save_file(self, file_url, path):
os.makedirs(os.path.dirname(path), exist_ok=True)
response = self.scraper.get(file_url, stream=True)
if response.status_code == 200:
with open(path, 'wb') as file:
for chunk in response.iter_content(1024):
file.write(chunk)
self.log(self.tr(f"Archivo descargado: {path}"))
else:
self.log(self.tr(f"Error al descargar {file_url}: {response.status_code}"))
def process_post(self, post_content, download_folder):
# Procesar imágenes
images = post_content.select(self.images_selector)
for img in images:
src = img.get('src')
if src:
file_name = os.path.basename(urlparse(src).path)
file_path = os.path.join(download_folder, file_name)
self.save_file(src, file_path)
# Procesar videos
videos = post_content.select(self.videos_selector)
for video in videos:
src = video.get('src')
if src:
file_name = os.path.basename(urlparse(src).path)
file_path = os.path.join(download_folder, file_name)
self.save_file(src, file_path)
# Procesar archivos adjuntos
attachments_block = post_content.select_one(self.attachments_block_selector)
if attachments_block:
attachments = attachments_block.select(self.attachments_selector)
for attachment in attachments:
href = attachment.get('href')
if href:
file_name = os.path.basename(urlparse(href).path)
file_path = os.path.join(download_folder, file_name)
self.save_file(href, file_path)
def process_page(self, url):
soup = self.fetch_page(url)
if not soup:
return
title_element = soup.select_one(self.title_selector)
folder_name = self.sanitize_folder_name(title_element.text.strip()) if title_element else 'SimpCity_Download'
download_folder = os.path.join(self.download_folder, folder_name)
os.makedirs(download_folder, exist_ok=True)
message_inners = soup.select(self.posts_selector)
for post in message_inners:
post_content = post.select_one(self.post_content_selector)
if post_content:
self.process_post(post_content, download_folder)
next_page = soup.select_one(self.next_page_selector)
if next_page:
next_page_url = next_page.get('href')
if next_page_url:
self.process_page(self.base_url + next_page_url)
def download_images_from_simpcity(self, url):
self.log(self.tr(f"Procesando hilo: {url}"))
self.process_page(url)
self.log(self.tr("Descarga completada."))
@@ -0,0 +1,8 @@
from app.ui import ImageDownloaderApp
def main():
app = ImageDownloaderApp()
app.mainloop()
if __name__ == "__main__":
main()
@@ -0,0 +1,10 @@
customtkinter
beautifulsoup4
urllib3
requests
pillow
psutil
markdown2
tkinterweb
cloudscraper
selenium
@@ -0,0 +1,9 @@
{
"max_downloads": 5,
"folder_structure": "default",
"language": "en",
"theme": "Dark",
"max_retries": 4,
"retry_interval": 2.0,
"file_naming_mode": 0
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB