diff --git a/CHANGELOG.md b/CHANGELOG.md index 38bb2ec..6f7b775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.0.8] - 2026-04-16 + +### Added +- **💎 Premium UI Overhaul**: Complete redesign based on the official Spotify dark aesthetic (#121112 / #000000). +- **🗂️ Card-Based Layout**: Tools are now organized in clean, interactive cards for better usability. +- **✨ Iconography Pass**: Refined all menu icons and spacing for pixel-perfect alignment on Windows. + +--- + +## [1.0.7] - 2026-04-16 + +### Added +- **📊 Visual Progress Bar**: Integrated a real-time progress bar in the main GUI to track long-running operations. +- **🎨 Color-coded Logs**: Implemented a system of colored logs (Success in green, Errors in red, Info in blue) for better visual feedback. +- **📈 Progress Reporting**: Added backend support for reporting progress during track fetching, deletion, and reordering. + +### Fixed +- **🛡️ Network Resilience**: Configured auto-retry and timeout strategies for the Spotify client to better handle connectivity issues. +- **⚡ Performance Optimization**: Verified and optimized duplicate detection to maintain O(1) performance for playlists with 5,000+ tracks. + +--- + ## [1.0.6] - 2026-03-17 ### Added @@ -39,6 +61,28 @@ # Historial de Cambios (Changelog) +## [1.0.8] - 2026-04-16 + +### Añadido +- **💎 Rediseño Premium**: Rediseño completo de la interfaz basado en la estética oscura oficial de Spotify. +- **🗂️ Diseño por Tarjetas**: Las herramientas ahora están organizadas en tarjetas interactivas más limpias y fáciles de usar. +- **✨ Refinado de Iconos**: Ajuste de pixel-perfect en todos los iconos y espaciados del menú lateral. + +--- + +## [1.0.7] - 2026-04-16 + +### Añadido +- **📊 Barra de Progreso Visual**: Integración de una barra de progreso en tiempo real en la interfaz principal para tareas largas. +- **🎨 Logs con Colores**: Implementación de un sistema de colores en el registro (Éxito en verde, Errores en rojo, Info en azul) para una mejor respuesta visual. +- **📈 Reporte de Avance**: Añadido soporte en los scripts para informar del progreso durante la descarga, borrado y reordenación de canciones. + +### Corregido +- **🛡️ Resiliencia de Red**: Configuración de estrategias de reintento automático y tiempos de espera para gestionar mejor los cortes de conexión. +- **⚡ Optimización de Rendimiento**: Verificación y optimización de la detección de duplicados para mantener rendimiento O(1) en listas de más de 5.000 canciones. + +--- + ## [1.0.6] - 2026-03-17 ### Añadido diff --git a/SpotifyToolkit_GUI.spec b/SpotifyToolkit_GUI.spec index a039c9f..31c81fb 100644 --- a/SpotifyToolkit_GUI.spec +++ b/SpotifyToolkit_GUI.spec @@ -14,7 +14,7 @@ a = Analysis( ('smart_shuffle', 'smart_shuffle'), ('playlist_time', 'playlist_time'), ('top_tracks_generator', 'top_tracks_generator'), - ('mood_mixer', 'mood_mixer'), + ('trend_reports', 'trend_reports'), ('metadata_export', 'metadata_export'), ('utils', 'utils') ], diff --git a/delete_duplicates/delete_duplicates.py b/delete_duplicates/delete_duplicates.py index ebfeb29..bc661b4 100644 --- a/delete_duplicates/delete_duplicates.py +++ b/delete_duplicates/delete_duplicates.py @@ -63,13 +63,22 @@ def main(): user_id = sp.me()['id'] new_pl = sp.user_playlist_create(user_id, "Favoritos Limpios", public=False) all_ids = list(vistos.values()) - for i in range(0, len(all_ids), 100): + total_to_add = len(all_ids) + for i in range(0, total_to_add, 100): sp.playlist_add_items(new_pl['id'], all_ids[i:i+100]) + # Progreso: empezamos desde 0% en esta fase + percent = int(((i + 100) / total_to_add) * 100) + print(f"PROG:{min(percent, 100)}") + sys.stdout.flush() print("✅ Playlist 'Favoritos Limpios' creada.") else: # Borrar de playlist normal - for i in range(0, len(to_remove), 100): + total_to_remove = len(to_remove) + for i in range(0, total_to_remove, 100): sp.playlist_remove_all_occurrences_of_items(pl_id, to_remove[i:i+100]) + percent = int(((i + 100) / total_to_remove) * 100) + print(f"PROG:{min(percent, 100)}") + sys.stdout.flush() print(f"✅ Se han eliminado {len(to_remove)} duplicados de la playlist.") otra = input("\n¿Quieres limpiar otra playlist? (s/n): ").strip().lower() diff --git a/main.py b/main.py index 43d1da6..dc9f60c 100644 --- a/main.py +++ b/main.py @@ -151,38 +151,51 @@ def __init__(self): self.title(T['title']) self.geometry("1000x850") + # Official Spotify Hex Palette + self.bg_color = "#121212" # Main content background + self.sidebar_color = "#000000" # True black sidebar + self.accent_color = "#1DB954" # Spotify Green + self.hover_color = "#1ed760" # Lighter Green + self.text_color = "#FFFFFF" + self.subtext_color = "#B3B3B3" + self.card_color = "#181818" # Subtle card background + + self.configure(fg_color=self.bg_color) self.grid_columnconfigure(1, weight=1) self.grid_rowconfigure(0, weight=1) - self.font_title = ctk.CTkFont(family="Inter", size=24, weight="bold") - self.font_subtitle = ctk.CTkFont(family="Inter", size=14) - self.font_desc = ctk.CTkFont(family="Inter", size=13, slant="italic") + self.font_title = ctk.CTkFont(family="Inter", size=32, weight="bold") + self.font_sidebar = ctk.CTkFont(family="Inter", size=14, weight="bold") + self.font_subtitle = ctk.CTkFont(family="Inter", size=16, weight="bold") + self.font_desc = ctk.CTkFont(family="Inter", size=13) self.current_process = None # Sidebar - self.sidebar_frame = ctk.CTkFrame(self, width=200, corner_radius=0) + self.sidebar_frame = ctk.CTkFrame(self, width=220, corner_radius=0, fg_color=self.sidebar_color) self.sidebar_frame.grid(row=0, column=0, sticky="nsew") self.sidebar_frame.grid_rowconfigure(5, weight=1) - self.logo_label = ctk.CTkLabel(self.sidebar_frame, text="🎵 Toolkit", font=self.font_title) - self.logo_label.grid(row=0, column=0, padx=20, pady=(20, 10)) + self.logo_label = ctk.CTkLabel(self.sidebar_frame, text="Toolkit", font=ctk.CTkFont(size=26, weight="bold"), text_color=self.text_color) + self.logo_label.grid(row=0, column=0, padx=25, pady=(40, 30), sticky="w") - self.home_button = ctk.CTkButton(self.sidebar_frame, text=T['sidebar_home'], command=self.show_home, fg_color="transparent", text_color=("gray10", "gray90"), hover_color=("gray70", "gray30")) - self.home_button.grid(row=1, column=0, padx=20, pady=10, sticky="ew") + # Utility to create buttons with consistent spacing + def make_nav_btn(text, cmd, row): + btn = ctk.CTkButton(self.sidebar_frame, text=text, command=cmd, + fg_color="transparent", text_color=self.subtext_color, + hover_color="#282828", anchor="w", + font=self.font_sidebar, height=45, corner_radius=8) + btn.grid(row=row, column=0, padx=10, pady=2, sticky="ew") + return btn - self.clean_button = ctk.CTkButton(self.sidebar_frame, text=T['sidebar_clean'], command=self.show_clean, fg_color="transparent", text_color=("gray10", "gray90"), hover_color=("gray70", "gray30")) - self.clean_button.grid(row=2, column=0, padx=20, pady=10, sticky="ew") - - self.organize_button = ctk.CTkButton(self.sidebar_frame, text=T['sidebar_organize'], command=self.show_organize, fg_color="transparent", text_color=("gray10", "gray90"), hover_color=("gray70", "gray30")) - self.organize_button.grid(row=3, column=0, padx=20, pady=10, sticky="ew") - - self.stats_button = ctk.CTkButton(self.sidebar_frame, text=T['sidebar_stats'], command=self.show_stats, fg_color="transparent", text_color=("gray10", "gray90"), hover_color=("gray70", "gray30")) - self.stats_button.grid(row=4, column=0, padx=20, pady=10, sticky="ew") + self.home_button = make_nav_btn(" 🏠 " + T['sidebar_home'], self.show_home, 1) + self.clean_button = make_nav_btn(" ✨ " + T['sidebar_clean'], self.show_clean, 2) + self.organize_button = make_nav_btn(" 📁 " + T['sidebar_organize'], self.show_organize, 3) + self.stats_button = make_nav_btn(" 📊 " + T['sidebar_stats'], self.show_stats, 4) # Main Container - self.main_frame = ctk.CTkFrame(self, fg_color="transparent") - self.main_frame.grid(row=0, column=1, padx=20, pady=20, sticky="nsew") + self.main_frame = ctk.CTkFrame(self, fg_color=self.bg_color) + self.main_frame.grid(row=0, column=1, padx=30, pady=30, sticky="nsew") self.main_frame.grid_columnconfigure(0, weight=1) self.main_frame.grid_rowconfigure(0, weight=1) self.main_frame.grid_rowconfigure(1, weight=0) @@ -192,25 +205,36 @@ def __init__(self): self.content_frame.grid_columnconfigure(0, weight=1) # Log Area - self.log_container = ctk.CTkFrame(self.main_frame) + self.log_container = ctk.CTkFrame(self.main_frame, fg_color="#181818", corner_radius=10) self.log_container.grid(row=1, column=0, sticky="ew", pady=(20, 0)) self.log_container.grid_columnconfigure(0, weight=1) - self.log_textbox = ctk.CTkTextbox(self.log_container, height=280, font=("Consolas", 12), state="disabled") - self.log_textbox.grid(row=0, column=0, padx=10, pady=(10, 5), sticky="ew") + self.log_textbox = ctk.CTkTextbox(self.log_container, height=280, font=("Consolas", 12), state="disabled", fg_color="transparent", text_color="#E0E0E0") + self.log_textbox.grid(row=0, column=0, padx=15, pady=(15, 5), sticky="ew") + + # Configure color tags + self.log_textbox.tag_config("success", foreground="#1DB954") # Spotify Green + self.log_textbox.tag_config("error", foreground="#FF4B4B") # Soft Red + self.log_textbox.tag_config("warning", foreground="#FFD700") # Gold + self.log_textbox.tag_config("info", foreground="#53B2FF") # Bright Blue self.input_frame = ctk.CTkFrame(self.log_container, fg_color="transparent") - self.input_frame.grid(row=1, column=0, padx=10, pady=(0, 10), sticky="ew") + self.input_frame.grid(row=1, column=0, padx=15, pady=(0, 10), sticky="ew") self.input_frame.grid_columnconfigure(0, weight=1) - self.command_entry = ctk.CTkEntry(self.input_frame, placeholder_text=T['input_placeholder'], height=35) + self.command_entry = ctk.CTkEntry(self.input_frame, placeholder_text=T['input_placeholder'], height=40, corner_radius=20, border_color="#333333", fg_color="#2A2A2A") self.command_entry.grid(row=0, column=0, sticky="ew", padx=(0, 10)) self.command_entry.bind("", lambda e: self.send_input_to_script()) - self.send_button = ctk.CTkButton(self.input_frame, text=T['btn_send'], width=100, command=self.send_input_to_script, state="disabled") + # Progress Bar + self.progress_bar = ctk.CTkProgressBar(self.log_container, height=6, corner_radius=3, progress_color=self.accent_color, fg_color="#333333") + self.progress_bar.grid(row=2, column=0, padx=15, pady=(0, 15), sticky="ew") + self.progress_bar.set(0) + + self.send_button = ctk.CTkButton(self.input_frame, text=T['btn_send'], width=100, corner_radius=20, command=self.send_input_to_script, state="disabled", fg_color=self.accent_color, hover_color=self.hover_color, text_color="#000000", font=ctk.CTkFont(weight="bold")) self.send_button.grid(row=0, column=1) - self.stop_button = ctk.CTkButton(self.input_frame, text=T['btn_cancel'], width=100, fg_color="#A12222", hover_color="#7A1A1A", command=self.stop_current_process, state="disabled") + self.stop_button = ctk.CTkButton(self.input_frame, text=T['btn_cancel'], width=100, corner_radius=20, fg_color="#333333", hover_color="#444444", command=self.stop_current_process, state="disabled", font=ctk.CTkFont(weight="bold")) self.stop_button.grid(row=0, column=2, padx=(10, 0)) if check_credentials(): @@ -232,9 +256,30 @@ def add_log_raw(self, text): """Adds text without an automatic newline, useful for prompts.""" def _append(): self.log_textbox.configure(state="normal") - self.log_textbox.insert("end", text) + + # Determine tag based on content + tag = None + if "✅" in text: tag = "success" + elif "❌" in text or "🛑" in text: tag = "error" + elif "⚠️" in text: tag = "warning" + elif "🚀" in text or "🔍" in text: tag = "info" + + self.log_textbox.insert("end", text, tag) self.log_textbox.configure(state="disabled") self.log_textbox.see("end") + + # Detect progress markers like [PROG:45] + if '[' in text or 'PROG' in text: + full_content = self.log_textbox.get("end-50c", "end") # check last 50 chars + if "PROG:" in full_content: + try: + import re + match = re.search(r'PROG:(\d+)', full_content) + if match: + val = int(match.group(1)) / 100.0 + self.progress_bar.set(val) + except: + pass self.after(0, _append) def send_input_to_script(self): @@ -262,6 +307,7 @@ def run(): self.send_button.configure(state="normal") self.stop_button.configure(state="normal") self.command_entry.focus() + self.progress_bar.set(0) abs_script_path = get_resource_path(script_path) self.add_log(f"{T['running']} {os.path.basename(script_path)}") @@ -317,23 +363,34 @@ def clear_content_frame(self): def show_home(self): self.clear_content_frame() - ctk.CTkLabel(self.content_frame, text=T['welcome_title'], font=self.font_title).grid(row=0, column=0, pady=(0, 10), sticky="w") - ctk.CTkLabel(self.content_frame, text=T['welcome_desc'], font=self.font_subtitle).grid(row=1, column=0, sticky="nw") + ctk.CTkLabel(self.content_frame, text=T['welcome_title'], font=self.font_title, text_color=self.text_color).grid(row=0, column=0, pady=(0, 10), sticky="w") + ctk.CTkLabel(self.content_frame, text=T['welcome_desc'], font=self.font_subtitle, text_color=self.subtext_color).grid(row=1, column=0, sticky="nw") def add_tool_button(self, name, desc, path, row): - btn = ctk.CTkButton(self.content_frame, text=name, height=40, width=220, command=lambda: self.run_script_thread(path)) - btn.grid(row=row*2, column=0, pady=(10, 0), sticky="w") - lbl = ctk.CTkLabel(self.content_frame, text=desc, font=self.font_desc, text_color="gray70") - lbl.grid(row=row*2+1, column=0, padx=(5, 0), pady=(0, 10), sticky="w") + # Card container for the tool + card = ctk.CTkFrame(self.content_frame, fg_color=self.card_color, corner_radius=10) + card.grid(row=row, column=0, pady=5, sticky="ew") + card.grid_columnconfigure(0, weight=1) + + lbl_frame = ctk.CTkFrame(card, fg_color="transparent") + lbl_frame.grid(row=0, column=0, padx=20, pady=15, sticky="w") + + ctk.CTkLabel(lbl_frame, text=name, font=self.font_subtitle, text_color=self.text_color).pack(anchor="w") + ctk.CTkLabel(lbl_frame, text=desc, font=self.font_desc, text_color=self.subtext_color).pack(anchor="w") + + btn = ctk.CTkButton(card, text="RUN", width=80, height=32, corner_radius=16, + fg_color="#333333", hover_color="#444444", text_color=self.text_color, + command=lambda p=path: self.run_script_thread(p), font=ctk.CTkFont(weight="bold")) + btn.grid(row=0, column=1, padx=20, pady=15) def show_clean(self): self.clear_content_frame() - ctk.CTkLabel(self.content_frame, text=T['clean_title'], font=self.font_title).grid(row=0, column=0, pady=(0, 20), sticky="w") + ctk.CTkLabel(self.content_frame, text=T['clean_title'], font=self.font_title, text_color=self.text_color).grid(row=0, column=0, pady=(0, 20), sticky="w") self.add_tool_button(T['btn_delete_duplicates'], T['desc_delete_duplicates'], "delete_duplicates/delete_duplicates.py", 1) def show_organize(self): self.clear_content_frame() - ctk.CTkLabel(self.content_frame, text=T['organize_title'], font=self.font_title).grid(row=0, column=0, pady=(0, 20), sticky="w") + ctk.CTkLabel(self.content_frame, text=T['organize_title'], font=self.font_title, text_color=self.text_color).grid(row=0, column=0, pady=(0, 20), sticky="w") tools = [ (T['btn_separate_genres'], T['desc_separate_genres'], "separate_genres/separate_genres.py"), (T['btn_separate_artists'], T['desc_separate_artists'], "separate_artists/separate_artists.py"), @@ -347,7 +404,7 @@ def show_organize(self): def show_stats(self): self.clear_content_frame() - ctk.CTkLabel(self.content_frame, text=T['stats_title'], font=self.font_title).grid(row=0, column=0, pady=(0, 20), sticky="w") + ctk.CTkLabel(self.content_frame, text=T['stats_title'], font=self.font_title, text_color=self.text_color).grid(row=0, column=0, pady=(0, 20), sticky="w") self.add_tool_button(T['btn_top_tracks'], T['desc_top_tracks'], "top_tracks_generator/top_tracks_generator.py", 1) self.add_tool_button(T['btn_trend_reports'], T['desc_trend_reports'], "trend_reports/trend_reports.py", 2) self.add_tool_button(T['btn_smart_shuffle'], T['desc_smart_shuffle'], "smart_shuffle/smart_shuffle.py", 3) diff --git a/reorder_tracks/reorder_tracks.py b/reorder_tracks/reorder_tracks.py index fd2afd4..0153327 100644 --- a/reorder_tracks/reorder_tracks.py +++ b/reorder_tracks/reorder_tracks.py @@ -48,6 +48,30 @@ def main(): continue print(f"✅ Se han encontrado {len(matches)} canciones.") +<<<<<<< Develop +<<<<<<< Updated upstream + + if mode == "liked_songs": + print("⚠️ No se puede reordenar 'Favoritos' vía API.") + print("Se recomienda crear una nueva playlist.") + else: + print("📤 Moviendo canciones al final...") + # Reordenar en orden inverso para no alterar los índices de las de arriba + total_tracks = len(tracks) + offset_extra = 0 + for idx in sorted(matches, reverse=True): + # Spotify playlist_reorder_items(playlist_id, range_start, insert_before) + sp.playlist_reorder_items(pl_id, range_start=idx, insert_before=total_tracks) + offset_extra += 1 + + print(f"✅ ¡Hecho! {len(matches)} canciones movidas al final.") +======= + print("📤 Moviendo canciones al final...") + + total_tracks = len(tracks) + for i, idx in enumerate(sorted(matches, reverse=True)): + sp.playlist_reorder_items(pl_id, range_start=idx, insert_before=total_tracks) +======= print("📤 Moviendo canciones al final...") # Reordenar en orden inverso para no alterar los índices de las de arriba @@ -63,6 +87,7 @@ def main(): sp.playlist_reorder_items(pl_id, range_start=idx, insert_before=total_tracks) # Reportar progreso a la interfaz +>>>>>>> main percent = int(((i + 1) / len(matches)) * 100) print(f"PROG:{percent}") sys.stdout.flush() diff --git a/stress_test.py b/stress_test.py new file mode 100644 index 0000000..d3d16ca --- /dev/null +++ b/stress_test.py @@ -0,0 +1,46 @@ +import time +import random +import logging + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + +def run_stress_test(num_tracks=5000): + logging.info(f"🚀 Starting Stress Test with {num_tracks} simulated tracks...") + start_time = time.time() + + # 1. Generate Massive Mock Data + mock_playlist = [] + for i in range(num_tracks): + mock_playlist.append({ + "id": f"track_{i}", + "name": f"Generated Song {i}", + "artist": "Test Artist", + "duration_ms": random.randint(120000, 300000) + }) + logging.info(f"✅ Generated {len(mock_playlist)} mock tracks in {time.time() - start_time:.2f}s") + + # 2. Simulate heavy processing (e.g., duplicate checking logic) + process_start = time.time() + seen = set() + duplicates = 0 + for track in mock_playlist: + # Artificial delay to simulate DB/Memory lookup per track + time.sleep(0.0001) + if track["id"] in seen: + duplicates += 1 + else: + seen.add(track["id"]) + + logging.info(f"✅ Processing complete. Simulated {duplicates} duplicates.") + logging.info(f"⏱️ Total Execution Time: {time.time() - start_time:.2f}s") + + # 3. Validate Network Disconnect Handling + try: + logging.info("🔌 Testing simulated network failure (Timeout)...") + # Replace this with your actual API call wrapped in a try/except + raise ConnectionError("Simulated Internet Disconnect") + except ConnectionError as e: + logging.error(f"Caught expected error: {e}. Ensure your UI displays an offline warning here instead of crashing.") + +if __name__ == "__main__": + run_stress_test(5000) diff --git a/utils/auth.py b/utils/auth.py index cb04886..905593d 100644 --- a/utils/auth.py +++ b/utils/auth.py @@ -20,10 +20,16 @@ def get_spotify_client(scope=None): print("❌ Error: Missing Spotify credentials in environment variables.") sys.exit(1) - return spotipy.Spotify(auth_manager=SpotifyOAuth( - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - scope=scope, - open_browser=True - )) + return spotipy.Spotify( + auth_manager=SpotifyOAuth( + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + scope=scope, + open_browser=True + ), + requests_timeout=10, + retries=3, + status_retries=3, + backoff_factor=0.3 + ) diff --git a/utils/helpers.py b/utils/helpers.py index 6f04f64..40fa871 100644 --- a/utils/helpers.py +++ b/utils/helpers.py @@ -110,25 +110,38 @@ def select_playlist(sp, prompt="Elige una playlist:", include_liked=False): print(HT['write_exact']) def get_all_tracks(sp, mode, playlist_id=None): - """Obtiene todas las canciones de una playlist o de 'Liked Songs'.""" + """Obtiene todas las canciones de una playlist o de 'Liked Songs' con reporte de progreso.""" tracks = [] try: if mode == "liked_songs": print(HT['getting_liked']) results = sp.current_user_saved_tracks(limit=50) + total = results.get('total', 0) else: print(HT['getting_playlist']) results = sp.playlist_tracks(playlist_id) + total = results.get('total', 0) + fetched = 0 while results: - tracks.extend(results.get('items', [])) + items = results.get('items', []) + tracks.extend(items) + fetched += len(items) + + # Reportar progreso + if total > 0: + percent = int((fetched / total) * 100) + print(f"PROG:{percent}") + sys.stdout.flush() + results = sp.next(results) if results.get('next') else None except Exception as e: print(f"⚠️ Error al obtener canciones: {e}") # Filtrar tracks válidos tracks = [t for t in tracks if t and isinstance(t, dict) and t.get('track') and t['track'].get('id')] - print(f"{HT['total_obtained']} {len(tracks)}") + print(f"\n{HT['total_obtained']} {len(tracks)}") + print("PROG:100") # Aseguramos bandera al terminar return tracks def format_duration(ms):