Major overhaul to the theme and now functioning.
@@ -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 @@
|
||||

|
||||

|
||||
|
||||
# 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:
|
||||
|
||||
[](https://buymeacoffee.com/emy_69)
|
||||
[](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:
|
||||
|
||||
[](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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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}')
|
||||
@@ -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 @@
|
||||
{"language": "en"}
|
||||
@@ -0,0 +1 @@
|
||||
False
|
||||
@@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 220 B |
|
After Width: | Height: | Size: 268 B |
|
After Width: | Height: | Size: 287 B |
|
After Width: | Height: | Size: 480 B |
|
After Width: | Height: | Size: 365 B |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 229 B |
|
After Width: | Height: | Size: 268 B |
|
After Width: | Height: | Size: 210 B |
|
After Width: | Height: | Size: 290 B |
|
After Width: | Height: | Size: 587 B |
|
After Width: | Height: | Size: 693 B |
|
After Width: | Height: | Size: 412 B |
|
After Width: | Height: | Size: 242 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 645 KiB |
|
After Width: | Height: | Size: 21 KiB |