From b35d2d9049d7ee3b5e5bd7a445783efea402fd13 Mon Sep 17 00:00:00 2001 From: Daniel <166744714+DarksAces@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:27:06 +0200 Subject: [PATCH] Update Add export CSV --- .env.example | 10 +++ CHANGELOG.md | 84 ++++++++++++++++++++++ SpotifyToolkit.spec | 13 +++- cli_menu.py | 6 +- library_backup/library_backup.py | 94 ++++++++++++++++++++++++ main.py | 67 +++++++++++++++--- metadata_export/metadata_export.py | 110 ++++++++++++++++++++--------- mood_mixer/mood_mixer.py | 104 +++++++++++++++++++++++++++ playlist_merger/playlist_merger.py | 78 ++++++++++++++++++++ utils/helpers.py | 23 ++++++ 10 files changed, 543 insertions(+), 46 deletions(-) create mode 100644 library_backup/library_backup.py create mode 100644 mood_mixer/mood_mixer.py create mode 100644 playlist_merger/playlist_merger.py diff --git a/.env.example b/.env.example index e3d254b..016df2f 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,13 @@ +<<<<<<< Updated upstream SPOTIFY_CLIENT_ID=your_client_id_here SPOTIFY_CLIENT_SECRET=your_client_secret_here SPOTIFY_REDIRECT_URI=http://localhost:8888/callback +======= +# Spotify API Credentials +# IMPORTANTE: NO uses comillas ni espacios alrededor de los valores. +# Ejemplo: SPOTIFY_CLIENT_ID=abc123def456... + +SPOTIFY_CLIENT_ID=tu_client_id_de_32_caracteres +SPOTIFY_CLIENT_SECRET=tu_client_secret_de_32_caracteres +SPOTIFY_REDIRECT_URI=http://127.0.0.1:8888/callback +>>>>>>> Stashed changes diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f7b775..a916c9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +<<<<<<< Updated upstream +======= +## [1.2.0] - 2026-04-27 + +### Added +- **🛠️ New Tools Category**: Added a dedicated "Utilities" section in the GUI to better organize the toolkit. +- **🔗 Playlist Merger**: New tool to combine multiple playlists into a new one, with duplicate detection. +- **🎭 Mood Mixer**: Filter any playlist by audio features (Energy, Chill, Danceable, Happy) using Spotify's AI analysis. +- **💾 Library Backup**: One-click full backup of all your playlists and Liked Songs to JSON files. +- **🐛 CLI/GUI Fix**: Resolved a critical bug where sub-scripts would fail to parse arguments when launched from the GUI. + +## [1.1.1] - 2026-04-27 + +### Added +- **📤 Enhanced Metadata Export**: Added CLI arguments support to the export tool. You can now specify playlist IDs and formats via command line. +- **📄 Robust Data Flattening**: Improved CSV export with better flattening of nested Spotify data (Artists, Albums, ISRC, etc.) for better Excel compatibility. +- **🖥️ CLI Menu Expansion**: Integrated Metadata Export and Trend Reports into the CLI menu (`cli_menu.py`). + +## [1.1.0] - 2026-04-26 + +### Added +- **🎵 Full "Liked Songs" Support**: Unified track fetching logic across the entire toolkit. Now you can use your favorite songs in all tools, including Smart Shuffle and Artist Extractor. +- **🛠️ Build System Overhaul**: Updated GitHub Actions and PyInstaller configuration to ensure all dependencies (like `tqdm`) are correctly bundled. +- **🛡️ Credential Robustness**: Improved `.env` loading to handle accidental spaces and quotes. + +### Fixed +- **🧹 Bug Fixes**: Resolved merge conflicts in `reorder_tracks.py` and improved progress reporting accuracy. + +--- + +## [1.0.9] - 2026-04-26 + +### Added +- **🚀 CLI Progress Bars**: Integrated `tqdm` for real-time visual feedback in the terminal for all long-running operations (fetching tracks, processing artists, shuffling, etc.). + +### Fixed +- **🧹 Code Cleanup**: Resolved merge conflicts and cleaned up logic in `reorder_tracks.py`. +- **🛠️ Robustness**: Improved progress tracking accuracy across all modules. + +--- + +>>>>>>> Stashed changes ## [1.0.8] - 2026-04-16 ### Added @@ -61,6 +103,48 @@ # Historial de Cambios (Changelog) +<<<<<<< Updated upstream +======= +## [1.2.0] - 2026-04-27 + +### Añadido +- **🛠️ Nueva Categoría de Herramientas**: Sección dedicada de "Herramientas" en la interfaz para una mejor organización. +- **🔗 Fusionador de Playlists**: Nueva herramienta para combinar varias listas en una nueva, con detección de duplicados. +- **🎭 Mood Mixer**: Filtra cualquier playlist por características de audio (Energética, Relajada, Bailable, Feliz). +- **💾 Respaldo de Biblioteca**: Copia de seguridad completa de todas tus playlists y canciones favoritas a archivos JSON en un solo clic. +- **🐛 Corrección CLI/GUI**: Solucionado un error crítico donde los sub-scripts fallaban al procesar argumentos al lanzarse desde la interfaz. + +## [1.1.1] - 2026-04-27 + +### Añadido +- **📤 Exportación de Metadatos Mejorada**: Soporte para argumentos de línea de comandos en la herramienta de exportación. Ahora puedes especificar IDs de playlist y formatos vía CLI. +- **📄 Aplanamiento de Datos Robusto**: Mejora en la exportación CSV con un mejor aplanamiento de los datos anidados de Spotify (Artistas, Álbumes, ISRC, etc.) para una mejor compatibilidad con Excel. +- **🖥️ Expansión del Menú CLI**: Integración de la Exportación de Metadatos e Informe de Tendencias en el menú de consola (`cli_menu.py`). + +## [1.1.0] - 2026-04-26 + +### Añadido +- **🎵 Soporte Completo de "Liked Songs"**: Unificada la lógica de obtención de canciones en todo el toolkit. Ahora puedes usar tus canciones favoritas en todas las herramientas, incluyendo Smart Shuffle y Artist Extractor. +- **🛠️ Mejora del Sistema de Build**: Actualizada la GitHub Action y la configuración de PyInstaller para asegurar que todas las librerías (como `tqdm`) se incluyan correctamente. +- **🛡️ Robustez de Credenciales**: Mejora en la carga del archivo `.env` para gestionar espacios y comillas accidentales. + +### Corregido +- **🧹 Corrección de Errores**: Resolución de conflictos en `reorder_tracks.py` y mejora en la precisión de las barras de progreso. + +--- + +## [1.0.9] - 2026-04-26 + +### Añadido +- **🚀 Barras de Progreso en CLI**: Integración de `tqdm` para ofrecer retroalimentación visual en tiempo real en la terminal durante operaciones largas (descarga de canciones, análisis de artistas, mezclas, etc.). + +### Corregido +- **🧹 Limpieza de Código**: Resolución de conflictos de fusión y limpieza de lógica en `reorder_tracks.py`. +- **🛠️ Robustez**: Mejora en la precisión del seguimiento de progreso en todos los módulos. + +--- + +>>>>>>> Stashed changes ## [1.0.8] - 2026-04-16 ### Añadido diff --git a/SpotifyToolkit.spec b/SpotifyToolkit.spec index 834da8b..d6c1f18 100644 --- a/SpotifyToolkit.spec +++ b/SpotifyToolkit.spec @@ -1,6 +1,11 @@ # -*- mode: python ; coding: utf-8 -*- +import customtkinter +import os + +ctk_path = os.path.dirname(customtkinter.__file__) + a = Analysis( ['main.py'], pathex=[], @@ -16,13 +21,17 @@ a = Analysis( ('top_tracks_generator', 'top_tracks_generator'), ('mood_mixer', 'mood_mixer'), ('metadata_export', 'metadata_export'), - ('utils', 'utils') + ('utils', 'utils'), + (ctk_path, 'customtkinter'), + ('playlist_merger', 'playlist_merger'), + ('mood_mixer', 'mood_mixer'), + ('library_backup', 'library_backup') ], hiddenimports=['spotipy', 'customtkinter', 'difflib'], hookspath=[], hooksconfig={}, runtime_hooks=[], - excludes=[], + excludes=['torch', 'numpy', 'scipy', 'matplotlib', 'pandas', 'tensorflow', 'onnxruntime', 'torchvision', 'lxml', 'fsspec', 'imageio', 'ffmpeg', 'cv2', 'IPython', 'PIL.ImageQt'], noarchive=False, optimize=0, ) diff --git a/cli_menu.py b/cli_menu.py index 857b03d..41ad34d 100644 --- a/cli_menu.py +++ b/cli_menu.py @@ -20,7 +20,11 @@ def main_menu(): "6": ("Smart Shuffle", "smart_shuffle/smart_shuffle.py"), "7": ("Playlist Duration (Time)", "playlist_time/playlist_time.py"), "8": ("Reorder Tracks", "reorder_tracks/reorder_tracks.py"), - "9": ("Mood Mixer", "mood_mixer/mood_mixer.py") + "9": ("Trend Reports", "trend_reports/trend_reports.py"), + "10": ("Metadata Export", "metadata_export/metadata_export.py"), + "11": ("Playlist Merger", "playlist_merger/playlist_merger.py"), + "12": ("Mood Mixer", "mood_mixer/mood_mixer.py"), + "13": ("Library Backup", "library_backup/library_backup.py") } while True: diff --git a/library_backup/library_backup.py b/library_backup/library_backup.py new file mode 100644 index 0000000..daca402 --- /dev/null +++ b/library_backup/library_backup.py @@ -0,0 +1,94 @@ +import os +import sys +import spotipy +import json +from datetime import datetime +from spotipy.oauth2 import SpotifyOAuth +from tqdm import tqdm + +# --- CONFIGURACIÓN Y AUTENTICACIÓN --- +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.append(project_root) + +try: + from utils.auth import get_spotify_client + from utils.helpers import get_user_playlists, get_all_tracks, get_export_dir + sp = get_spotify_client() +except ImportError: + sp = spotipy.Spotify(auth_manager=SpotifyOAuth( + client_id=os.getenv("SPOTIFY_CLIENT_ID"), + client_secret=os.getenv("SPOTIFY_CLIENT_SECRET"), + redirect_uri=os.getenv("SPOTIFY_REDIRECT_URI"), + scope='user-library-read playlist-read-private' + )) + +def main(): + print("\n=== LIBRARY BACKUP (Copia de Seguridad Total) ===") + print("Esta herramienta exportará todas tus playlists y canciones favoritas.") + + confirm = input("\n¿Deseas iniciar el respaldo completo? (s/n): ").strip().lower() + if confirm != 's': return + + date_str = datetime.now().strftime("%Y-%m-%d_%H-%M") + export_dir = get_export_dir() + backup_dir = os.path.join(export_dir, f"Spotify_Backup_{date_str}") + os.makedirs(backup_dir, exist_ok=True) + + print(f"\n📂 Los archivos se guardarán en la carpeta: {backup_dir}") + + # 1. Liked Songs + print("\n📦 Respaldando 'Canciones Favoritas'...") + liked_tracks = get_all_tracks(sp, "liked_songs") + + liked_data = [] + for item in liked_tracks: + track = item.get('track') + if track: + liked_data.append({ + "name": track.get('name', 'Unknown'), + "artists": ", ".join([a['name'] for a in track.get('artists', [])]), + "album": track.get('album', {}).get('name', 'Unknown'), + "uri": track.get('uri', '') + }) + + with open(os.path.join(backup_dir, "Liked_Songs.json"), 'w', encoding='utf-8') as f: + json.dump(liked_data, f, indent=4, ensure_ascii=False) + + # 2. All Playlists + playlists = get_user_playlists(sp) + print(f"\n📦 Se han encontrado {len(playlists)} playlists para respaldar.") + + total_playlists = len(playlists) + for idx, pl in enumerate(playlists): + name = pl.get('name', f"Playlist_{idx}").replace(" ", "_").replace("/", "-").replace("\\", "-") + print(f"\n[{idx+1}/{total_playlists}] Procesando: {name}") + + tracks = get_all_tracks(sp, "playlist", pl.get('id')) + + pl_data = [] + for item in tracks: + track = item.get('track') + if track: + pl_data.append({ + "name": track.get('name', 'Unknown'), + "artists": ", ".join([a['name'] for a in track.get('artists', [])]), + "album": track.get('album', {}).get('name', 'Unknown'), + "uri": track.get('uri', '') + }) + + filename = f"{name}.json" + try: + with open(os.path.join(backup_dir, filename), 'w', encoding='utf-8') as f: + json.dump(pl_data, f, indent=4, ensure_ascii=False) + except Exception as e: + print(f"⚠️ Error al guardar {filename}: {e}") + + # Progreso para la GUI + percent = int(((idx + 1) / total_playlists) * 100) + print(f"PROG:{min(percent, 100)}") + sys.stdout.flush() + + print(f"\n✅ ¡Respaldo completado! Revisa la carpeta '{backup_dir}'.") + +if __name__ == "__main__": + main() diff --git a/main.py b/main.py index dc9f60c..ce0d5e2 100644 --- a/main.py +++ b/main.py @@ -46,18 +46,24 @@ def get_system_lang(): TEXTS = { 'es': { +<<<<<<< Updated upstream 'title': "Spotify Toolkit v1.0.6", +======= + 'title': "Spotify Toolkit v1.2.0", +>>>>>>> Stashed changes 'sidebar_home': "Inicio", 'sidebar_clean': "Limpieza", 'sidebar_organize': "Organizar", + 'sidebar_utils': "Herramientas", 'sidebar_stats': "Estadísticas", 'ready': "✅ Sistema listo", 'no_creds': "⚠️ Credenciales no encontradas (.env)", 'welcome_title': "Spotify Toolkit", 'welcome_desc': "Tus herramientas están listas. Elige una del menú lateral.", - 'clean_title': "Limpieza profunda", + 'clean_title': "Limpieza y Orden", 'organize_title': "Organización", - 'stats_title': "Estadísticas y Mezcla", + 'utils_title': "Utilidades y Respaldo", + 'stats_title': "Estadísticas y Análisis", 'btn_delete_duplicates': "Borrar Duplicados", 'desc_delete_duplicates': "Busca y elimina canciones repetidas en tus playlists para mantenerlas limpias.", 'btn_separate_genres': "Separar por Géneros", @@ -77,7 +83,17 @@ def get_system_lang(): 'btn_smart_shuffle': "Smart Shuffle", 'desc_smart_shuffle': "Mezcla tus listas evitando que suenen dos canciones seguidas del mismo artista.", 'btn_metadata_export': "Exportar Metadatos", +<<<<<<< Updated upstream 'desc_metadata_export': "Exporta una playlist a CSV o JSON para usar en otras plataformas.", +======= + 'desc_metadata_export': "Exporta una playlist a CSV o JSON.", + 'btn_playlist_merger': "Fusionar Playlists", + 'desc_playlist_merger': "Une varias listas en una nueva.", + 'btn_mood_mixer': "Mood Mixer", + 'desc_mood_mixer': "Mezcla por estado de ánimo.", + 'btn_library_backup': "Copia de Seguridad", + 'desc_library_backup': "Respalda toda tu biblioteca.", +>>>>>>> Stashed changes 'input_placeholder': "Escribir aquí...", 'btn_send': "Enviar", 'btn_cancel': "Cancelar", @@ -89,18 +105,24 @@ def get_system_lang(): 'error_fatal': "❌ Error fatal en script: " }, 'en': { +<<<<<<< Updated upstream 'title': "Spotify Toolkit v1.0.6", +======= + 'title': "Spotify Toolkit v1.2.0", +>>>>>>> Stashed changes 'sidebar_home': "Home", 'sidebar_clean': "Clean", 'sidebar_organize': "Organize", + 'sidebar_utils': "Utilities", 'sidebar_stats': "Statistics", 'ready': "✅ System ready", 'no_creds': "⚠️ Credentials not found (.env)", 'welcome_title': "Spotify Toolkit", 'welcome_desc': "Your tools are ready. Choose one from the sidebar.", - 'clean_title': "Deep Cleaning", + 'clean_title': "Cleaning & Order", 'organize_title': "Organization", - 'stats_title': "Stats & Mixing", + 'utils_title': "Utilities & Backup", + 'stats_title': "Stats & Analysis", 'btn_delete_duplicates': "Delete Duplicates", 'desc_delete_duplicates': "Find and remove repeated songs in your playlists to keep them clean.", 'btn_separate_genres': "Separate by Genres", @@ -120,7 +142,17 @@ def get_system_lang(): 'btn_smart_shuffle': "Smart Shuffle", 'desc_smart_shuffle': "Shuffle your lists while avoiding two songs from the same artist in a row.", 'btn_metadata_export': "Export Metadata", +<<<<<<< Updated upstream 'desc_metadata_export': "Export a playlist to CSV or JSON for use on other platforms.", +======= + 'desc_metadata_export': "Export to CSV or JSON.", + 'btn_playlist_merger': "Playlist Merger", + 'desc_playlist_merger': "Combine multiple lists into a new one.", + 'btn_mood_mixer': "Mood Mixer", + 'desc_mood_mixer': "Mix based on your mood.", + 'btn_library_backup': "Library Backup", + 'desc_library_backup': "Backup your entire library.", +>>>>>>> Stashed changes 'input_placeholder': "Type here...", 'btn_send': "Send", 'btn_cancel': "Cancel", @@ -191,7 +223,8 @@ def make_nav_btn(text, cmd, row): 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) + self.utils_button = make_nav_btn(" 🛠️ " + T['sidebar_utils'], self.show_utils, 4) + self.stats_button = make_nav_btn(" 📊 " + T['sidebar_stats'], self.show_stats, 5) # Main Container self.main_frame = ctk.CTkFrame(self, fg_color=self.bg_color) @@ -387,17 +420,28 @@ def show_clean(self): self.clear_content_frame() 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) + self.add_tool_button(T['btn_smart_shuffle'], T['desc_smart_shuffle'], "smart_shuffle/smart_shuffle.py", 2) def show_organize(self): self.clear_content_frame() 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"), - (T['btn_reorder_tracks'], T['desc_reorder_tracks'], "reorder_tracks/reorder_tracks.py"), - (T['btn_artist_extractor'], T['desc_artist_extractor'], "artist_extractor/artist_extractor.py"), + (T['btn_separate_artists'], T['desc_separate_artists'], "separate_artists/separate_artists.py") + ] + for i, (name, desc, path) in enumerate(tools): + self.add_tool_button(name, desc, path, i+1) + + def show_utils(self): + self.clear_content_frame() + ctk.CTkLabel(self.content_frame, text=T['utils_title'], font=self.font_title, text_color=self.text_color).grid(row=0, column=0, pady=(0, 20), sticky="w") + tools = [ + (T['btn_metadata_export'], T['desc_metadata_export'], "metadata_export/metadata_export.py"), + (T['btn_library_backup'], T['desc_library_backup'], "library_backup/library_backup.py"), + (T['btn_playlist_merger'], T['desc_playlist_merger'], "playlist_merger/playlist_merger.py"), (T['btn_playlist_time'], T['desc_playlist_time'], "playlist_time/playlist_time.py"), - (T['btn_metadata_export'], T['desc_metadata_export'], "metadata_export/metadata_export.py") + (T['btn_artist_extractor'], T['desc_artist_extractor'], "artist_extractor/artist_extractor.py"), + (T['btn_reorder_tracks'], T['desc_reorder_tracks'], "reorder_tracks/reorder_tracks.py") ] for i, (name, desc, path) in enumerate(tools): self.add_tool_button(name, desc, path, i+1) @@ -407,7 +451,7 @@ def show_stats(self): 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) + self.add_tool_button(T['btn_mood_mixer'], T['desc_mood_mixer'], "mood_mixer/mood_mixer.py", 3) if __name__ == "__main__": if len(sys.argv) > 2 and sys.argv[1] == "--run": @@ -417,6 +461,9 @@ def show_stats(self): if hasattr(sys, '_MEIPASS'): sys.path.append(sys._MEIPASS) try: + # Limpiamos sys.argv para que el script hijo no reciba los argumentos del padre (--run) + original_argv = sys.argv[:] + sys.argv = [target_script] + sys.argv[3:] runpy.run_path(target_script, run_name="__main__") except Exception as e: print(f"{T['error_fatal']}{e}") diff --git a/metadata_export/metadata_export.py b/metadata_export/metadata_export.py index 5e779ae..e808804 100644 --- a/metadata_export/metadata_export.py +++ b/metadata_export/metadata_export.py @@ -11,7 +11,7 @@ try: from utils.auth import get_spotify_client - from utils.helpers import select_playlist, get_all_tracks, format_duration + from utils.helpers import select_playlist, get_all_tracks, format_duration, get_export_dir sp = get_spotify_client() except ImportError: sp = spotipy.Spotify(auth_manager=SpotifyOAuth( @@ -21,7 +21,30 @@ scope='user-library-read playlist-read-private' )) +import argparse + +def flatten_track(item): + """Extrae y aplanan los datos de una canción para su exportación.""" + track = item.get('track', {}) + if not track: + return None + + return { + "Name": track.get('name', 'Unknown'), + "Artists": ", ".join([a['name'] for a in track.get('artists', [])]), + "Album": track.get('album', {}).get('name', 'Unknown'), + "Release Date": track.get('album', {}).get('release_date', ''), + "Duration": format_duration(track.get('duration_ms', 0)), + "Popularity": track.get('popularity', 0), + "ISRC": track.get('external_ids', {}).get('isrc', ''), + "URI": track.get('uri', '') + } + def export_to_csv(tracks, filename, is_migration=False): + if not tracks: + print("⚠️ No hay canciones para exportar.") + return + if is_migration: # Formato compatible con Soundiiz/TuneMyMusic keys = ["title", "artist", "album", "isrc"] @@ -33,46 +56,32 @@ def export_to_csv(tracks, filename, is_migration=False): dict_writer = csv.DictWriter(output_file, fieldnames=keys) dict_writer.writeheader() for t in tracks: - track = t['track'] - isrc = track.get('external_ids', {}).get('isrc', '') + flat_track = flatten_track(t) + if not flat_track: continue if is_migration: dict_writer.writerow({ - "title": track['name'], - "artist": ", ".join([a['name'] for a in track['artists']]), - "album": track['album']['name'], - "isrc": isrc + "title": flat_track['Name'], + "artist": flat_track['Artists'], + "album": flat_track['Album'], + "isrc": flat_track['ISRC'] }) else: - dict_writer.writerow({ - "Name": track['name'], - "Artists": ", ".join([a['name'] for a in track['artists']]), - "Album": track['album']['name'], - "Release Date": track['album']['release_date'], - "Duration": format_duration(track['duration_ms']), - "Popularity": track['popularity'], - "ISRC": isrc, - "URI": track['uri'] - }) + dict_writer.writerow(flat_track) print(f"✅ Exportado a CSV: {filename}") except Exception as e: print(f"❌ Error al exportar CSV: {e}") def export_to_json(tracks, filename): + if not tracks: + print("⚠️ No hay canciones para exportar.") + return + data = [] for t in tracks: - track = t['track'] - data.append({ - "name": track['name'], - "artists": [a['name'] for a in track['artists']], - "album": track['album']['name'], - "release_date": track['album']['release_date'], - "duration_ms": track['duration_ms'], - "duration_readable": format_duration(track['duration_ms']), - "popularity": track['popularity'], - "isrc": track.get('external_ids', {}).get('isrc', ''), - "uri": track['uri'] - }) + flat = flatten_track(t) + if flat: + data.append(flat) try: with open(filename, 'w', encoding='utf-8') as f: json.dump(data, f, indent=4, ensure_ascii=False) @@ -81,6 +90,40 @@ def export_to_json(tracks, filename): print(f"❌ Error al exportar JSON: {e}") def main(): + parser = argparse.ArgumentParser(description="Exporta metadatos de Spotify a CSV o JSON.") + parser.add_argument("--playlist", help="ID de la playlist (o 'liked' para canciones favoritas)") + parser.add_argument("--format", choices=["csv", "json", "all", "migration"], help="Formato de exportación") + parser.add_argument("--output", help="Nombre del archivo de salida (opcional)") + + args = parser.parse_args() + + # Si se pasan argumentos, usamos el modo CLI + if args.playlist and args.format: + mode = "liked_songs" if args.playlist.lower() == "liked" else "playlist" + pl_id = None if mode == "liked_songs" else args.playlist + + tracks = get_all_tracks(sp, mode, pl_id) + if not tracks: return + + if args.output: + base_name = args.output.rsplit('.', 1)[0] + else: + if mode == "liked_songs": + base_name = "Liked_Songs" + else: + pl_info = sp.playlist(pl_id, fields="name") + base_name = pl_info['name'].replace(" ", "_").replace("/", "-") + + export_dir = get_export_dir() + if args.format in ['csv', 'all']: + export_to_csv(tracks, os.path.join(export_dir, f"{base_name}.csv")) + if args.format in ['migration', 'all']: + export_to_csv(tracks, os.path.join(export_dir, f"{base_name}_migration.csv"), is_migration=True) + if args.format in ['json', 'all']: + export_to_json(tracks, os.path.join(export_dir, f"{base_name}.json")) + return + + # Si no hay argumentos, iniciamos el modo interactivo print("\n=== EXPORTAR METADATOS (CSV/JSON) ===") mode, pl_id = select_playlist(sp, "SELECCIONA UNA LISTA PARA EXPORTAR", include_liked=True) if not mode: return @@ -100,22 +143,23 @@ def main(): choice = input("\nElige una opción: ").strip().lower() if choice == 'q': return - # Buscar nombre de la playlist para el nombre del archivo if mode == "liked_songs": base_name = "Liked_Songs" else: pl_info = sp.playlist(pl_id, fields="name") base_name = pl_info['name'].replace(" ", "_").replace("/", "-") + export_dir = get_export_dir() if choice in ['1', '4']: - export_to_csv(tracks, f"{base_name}_metadata.csv") + export_to_csv(tracks, os.path.join(export_dir, f"{base_name}_metadata.csv")) if choice in ['2', '4']: - export_to_csv(tracks, f"{base_name}_migration.csv", is_migration=True) + export_to_csv(tracks, os.path.join(export_dir, f"{base_name}_migration.csv"), is_migration=True) if choice in ['3', '4']: - export_to_json(tracks, f"{base_name}_metadata.json") + export_to_json(tracks, os.path.join(export_dir, f"{base_name}_metadata.json")) if choice not in ['1', '2', '3', '4']: print("❌ Opción no válida.") if __name__ == "__main__": main() + diff --git a/mood_mixer/mood_mixer.py b/mood_mixer/mood_mixer.py new file mode 100644 index 0000000..02057c3 --- /dev/null +++ b/mood_mixer/mood_mixer.py @@ -0,0 +1,104 @@ +import os +import sys +import spotipy +from spotipy.oauth2 import SpotifyOAuth +from tqdm import tqdm + +# --- CONFIGURACIÓN Y AUTENTICACIÓN --- +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.append(project_root) + +try: + from utils.auth import get_spotify_client + from utils.helpers import select_playlist, get_all_tracks + sp = get_spotify_client() +except ImportError: + sp = spotipy.Spotify(auth_manager=SpotifyOAuth( + client_id=os.getenv("SPOTIFY_CLIENT_ID"), + client_secret=os.getenv("SPOTIFY_CLIENT_SECRET"), + redirect_uri=os.getenv("SPOTIFY_REDIRECT_URI"), + scope='playlist-modify-public playlist-modify-private user-library-read' + )) + +def main(): + print("\n=== MOOD MIXER (Mezcla por Ánimo) ===") + mode, pl_id = select_playlist(sp, "Elige una playlist para filtrar", include_liked=True) + if not mode: return + + tracks = get_all_tracks(sp, mode, pl_id) + if not tracks: return + + print("\n¿Qué ambiente buscas?") + print("1: Energético (High Energy)") + print("2: Relajado (Chill/Acoustic)") + print("3: Bailable (Danceable)") + print("4: Feliz (Positive Vibes)") + print("q: Cancelar") + + choice = input("\nElige una opción: ").strip().lower() + if choice == 'q': return + + mood_filters = { + "1": {"energy": (0.7, 1.0)}, + "2": {"energy": (0.0, 0.4), "acousticness": (0.5, 1.0)}, + "3": {"danceability": (0.7, 1.0)}, + "4": {"valence": (0.7, 1.0)} + } + + selected_filter = mood_filters.get(choice) + if not selected_filter: + print("❌ Opción no válida.") + return + + print("\n🧬 Analizando características de las canciones...") + uris = [t['track']['uri'] for t in tracks if t['track'] and t['track'].get('uri')] + filtered_uris = [] + + total_uris = len(uris) + with tqdm(total=total_uris, desc="Analizando audio", unit="track") as pbar: + for i in range(0, total_uris, 100): + batch = uris[i:i+100] + try: + features = sp.audio_features(batch) + for feat in features: + if not feat: continue + match = True + for key, (vmin, vmax) in selected_filter.items(): + val = feat.get(key, 0) + if not (vmin <= val <= vmax): + match = False + break + if match: + filtered_uris.append(feat['uri']) + except Exception as e: + print(f"\n⚠️ Error al obtener características: {e}") + + pbar.update(len(batch)) + # Progreso para la GUI + percent = int(((i + len(batch)) / total_uris) * 100) + print(f"PROG:{min(percent, 100)}") + sys.stdout.flush() + + if not filtered_uris: + print("\n⚠️ No se encontraron canciones que coincidan con ese ánimo.") + return + + print(f"\n✅ Encontradas {len(filtered_uris)} canciones.") + new_name = input("Nombre para la nueva playlist: ").strip() + if not new_name: new_name = "Mood Mix" + + print("\n📤 Creando nueva playlist en Spotify...") + user_id = sp.me()['id'] + new_pl = sp.user_playlist_create(user_id, new_name, public=False) + + total_filtered = len(filtered_uris) + with tqdm(total=total_filtered, desc="Creando playlist", unit="track") as pbar: + for i in range(0, total_filtered, 100): + batch = filtered_uris[i:i+100] + sp.playlist_add_items(new_pl['id'], batch) + pbar.update(len(batch)) + + print(f"\n✅ ¡Hecho! Playlist '{new_name}' creada.") + +if __name__ == "__main__": + main() diff --git a/playlist_merger/playlist_merger.py b/playlist_merger/playlist_merger.py new file mode 100644 index 0000000..db8a770 --- /dev/null +++ b/playlist_merger/playlist_merger.py @@ -0,0 +1,78 @@ +import os +import sys +import spotipy +from spotipy.oauth2 import SpotifyOAuth +from tqdm import tqdm + +# --- CONFIGURACIÓN Y AUTENTICACIÓN --- +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.append(project_root) + +try: + from utils.auth import get_spotify_client + from utils.helpers import select_playlist, get_all_tracks + sp = get_spotify_client() +except ImportError: + sp = spotipy.Spotify(auth_manager=SpotifyOAuth( + client_id=os.getenv("SPOTIFY_CLIENT_ID"), + client_secret=os.getenv("SPOTIFY_CLIENT_SECRET"), + redirect_uri=os.getenv("SPOTIFY_REDIRECT_URI"), + scope='playlist-modify-public playlist-modify-private user-library-read' + )) + +def main(): + print("\n=== PLAYLIST MERGER (Fusionar Listas) ===") + selected_playlists = [] + + while True: + print(f"\nHas seleccionado {len(selected_playlists)} playlists.") + mode, pl_id = select_playlist(sp, "Elige una playlist para añadir a la fusión", include_liked=True) + + if not mode: + if not selected_playlists: return + break + + selected_playlists.append((mode, pl_id)) + otra = input("\n¿Añadir otra? (s/n): ").strip().lower() + if otra != 's': break + + if not selected_playlists: return + + new_name = input("\nNombre para la nueva playlist: ").strip() + if not new_name: new_name = "Fusión de Playlists" + + print("\n🚀 Recopilando todas las canciones...") + all_uris = [] + for mode, pl_id in selected_playlists: + tracks = get_all_tracks(sp, mode, pl_id) + all_uris.extend([t['track']['uri'] for t in tracks if t['track'] and t['track'].get('uri')]) + + if not all_uris: + print("❌ No se encontraron canciones para fusionar.") + return + + # Eliminar duplicados si el usuario quiere + limpiar = input(f"\nSe encontraron {len(all_uris)} canciones en total. ¿Eliminar duplicados? (s/n): ").strip().lower() + if limpiar == 's': + all_uris = list(dict.fromkeys(all_uris)) + print(f"✅ Quedan {len(all_uris)} canciones únicas.") + + print("\n📤 Creando nueva playlist en Spotify...") + user_id = sp.me()['id'] + new_pl = sp.user_playlist_create(user_id, new_name, public=False) + + total_uris = len(all_uris) + with tqdm(total=total_uris, desc="Subiendo canciones", unit="track") as pbar: + for i in range(0, total_uris, 100): + batch = all_uris[i:i+100] + sp.playlist_add_items(new_pl['id'], batch) + pbar.update(len(batch)) + # Progreso para la GUI + percent = int(((i + len(batch)) / total_uris) * 100) + print(f"PROG:{min(percent, 100)}") + sys.stdout.flush() + + print(f"\n✅ ¡Hecho! Playlist '{new_name}' creada con éxito.") + +if __name__ == "__main__": + main() diff --git a/utils/helpers.py b/utils/helpers.py index 40fa871..430d13e 100644 --- a/utils/helpers.py +++ b/utils/helpers.py @@ -2,6 +2,29 @@ import os import locale +def get_export_dir(): + """ + Retorna la ruta absoluta de la carpeta 'exports'. + Si se ejecuta como EXE, usa la ruta del ejecutable. + Si se ejecuta como script, usa la raíz del proyecto. + """ + if hasattr(sys, '_MEIPASS'): + # PyInstaller: Directorio del ejecutable (.exe) + base_path = os.path.dirname(sys.executable) + else: + # Script: Raíz del proyecto (subiendo un nivel desde utils/) + base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + + export_path = os.path.join(base_path, "exports") + if not os.path.exists(export_path): + try: + os.makedirs(export_path, exist_ok=True) + except: + # Fallback a directorio actual si no hay permisos + return os.getcwd() + + return export_path + def get_sys_lang(): try: lang = locale.getlocale()[0]