open-yt 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
open_yt-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 epadev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
open_yt-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: open-yt
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: mutagen>=1.47.0
9
+ Requires-Dist: pydantic-settings>=2.14.1
10
+ Requires-Dist: pyfiglet>=1.0.4
11
+ Requires-Dist: questionary>=2.1.1
12
+ Requires-Dist: rich>=15.0.0
13
+ Requires-Dist: tomli-w>=1.2.0
14
+ Requires-Dist: typer>=0.25.1
15
+ Requires-Dist: yt-dlp>=2026.3.17
16
+ Dynamic: license-file
17
+
18
+ # OPEN-YT 🟥
19
+
20
+ > The high-performance, minimalist open-source YouTube engine for audio and video.
21
+
22
+ [![Python 3.10+](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/downloads/)
23
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
24
+ [![Build: PyInstaller](https://img.shields.io/badge/Build-PyInstaller-orange.svg)](https://pyinstaller.org/)
25
+
26
+ ![OPEN-YT Demo](assets/open-yt-demo.gif)
27
+
28
+ OPEN-YT es una Interfaz de Línea de Comandos (CLI) de grado industrial diseñada para extraer audio y video con la máxima eficiencia. Combina la potencia de `yt-dlp` con una experiencia de usuario (UX) minimalista, elegante y altamente configurable.
29
+
30
+ ## ✨ Características
31
+
32
+ * **UI/UX Premium:** Interfaz de terminal renderizada con `Rich` y menús interactivos fluidos potenciados por `Questionary`.
33
+ * **Motor Asíncrono:** Descargas ultra rápidas y extracción de metadatos sin bloquear la interfaz.
34
+ * **Persistencia de Estado:** Recuerda tus preferencias de formato (MP3, FLAC, MP4, MKV), resolución y rutas de descarga localmente.
35
+ * **Multiplataforma y Portable:** Distribuido como un binario único. Cero dependencias requeridas para el usuario final.
36
+
37
+ ---
38
+
39
+ ## 🚀 Instalación (Recomendado)
40
+
41
+ Para usar OPEN-YT en **Linux, Windows o Mac NO es necesario instalar Python ni configurar entornos**.
42
+
43
+ 1. Ve a la sección de [Releases](../../releases) del repositorio.
44
+ 2. Descarga el ejecutable nativo correspondiente a tu sistema operativo (ej. `open-yt-linux` o `open-yt-windows.exe`).
45
+ 3. Abre tu terminal y ejecútalo directamente.
46
+
47
+ ---
48
+
49
+ ## 💻 Instalación para Desarrolladores
50
+
51
+ Si deseas explorar el código fuente, modificar la herramienta o compilarla tú mismo:
52
+
53
+ ```bash
54
+ # 1. Clonar el repositorio
55
+ git clone https://github.com/elisbanpaco/open-yt.git
56
+ cd open-yt
57
+
58
+ # 2. Crear el entorno virtual e instalar dependencias
59
+ uv sync
60
+
61
+ # 3. Ejecutar
62
+ uv run python src/main.py
@@ -0,0 +1,45 @@
1
+ # OPEN-YT 🟥
2
+
3
+ > The high-performance, minimalist open-source YouTube engine for audio and video.
4
+
5
+ [![Python 3.10+](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/downloads/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
7
+ [![Build: PyInstaller](https://img.shields.io/badge/Build-PyInstaller-orange.svg)](https://pyinstaller.org/)
8
+
9
+ ![OPEN-YT Demo](assets/open-yt-demo.gif)
10
+
11
+ OPEN-YT es una Interfaz de Línea de Comandos (CLI) de grado industrial diseñada para extraer audio y video con la máxima eficiencia. Combina la potencia de `yt-dlp` con una experiencia de usuario (UX) minimalista, elegante y altamente configurable.
12
+
13
+ ## ✨ Características
14
+
15
+ * **UI/UX Premium:** Interfaz de terminal renderizada con `Rich` y menús interactivos fluidos potenciados por `Questionary`.
16
+ * **Motor Asíncrono:** Descargas ultra rápidas y extracción de metadatos sin bloquear la interfaz.
17
+ * **Persistencia de Estado:** Recuerda tus preferencias de formato (MP3, FLAC, MP4, MKV), resolución y rutas de descarga localmente.
18
+ * **Multiplataforma y Portable:** Distribuido como un binario único. Cero dependencias requeridas para el usuario final.
19
+
20
+ ---
21
+
22
+ ## 🚀 Instalación (Recomendado)
23
+
24
+ Para usar OPEN-YT en **Linux, Windows o Mac NO es necesario instalar Python ni configurar entornos**.
25
+
26
+ 1. Ve a la sección de [Releases](../../releases) del repositorio.
27
+ 2. Descarga el ejecutable nativo correspondiente a tu sistema operativo (ej. `open-yt-linux` o `open-yt-windows.exe`).
28
+ 3. Abre tu terminal y ejecútalo directamente.
29
+
30
+ ---
31
+
32
+ ## 💻 Instalación para Desarrolladores
33
+
34
+ Si deseas explorar el código fuente, modificar la herramienta o compilarla tú mismo:
35
+
36
+ ```bash
37
+ # 1. Clonar el repositorio
38
+ git clone https://github.com/elisbanpaco/open-yt.git
39
+ cd open-yt
40
+
41
+ # 2. Crear el entorno virtual e instalar dependencias
42
+ uv sync
43
+
44
+ # 3. Ejecutar
45
+ uv run python src/main.py
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "open-yt"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "mutagen>=1.47.0",
9
+ "pydantic-settings>=2.14.1",
10
+ "pyfiglet>=1.0.4",
11
+ "questionary>=2.1.1",
12
+ "rich>=15.0.0",
13
+ "tomli-w>=1.2.0",
14
+ "typer>=0.25.1",
15
+ "yt-dlp>=2026.3.17",
16
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,111 @@
1
+ import tomllib
2
+ from pathlib import Path
3
+ from typing import Any, Dict, Optional
4
+
5
+ from pydantic import BaseModel, Field
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+
9
+ class Settings(BaseSettings):
10
+ model_config = SettingsConfigDict(
11
+ config_file="config.toml",
12
+ config_file_parser=tomllib,
13
+ extra="ignore",
14
+ )
15
+
16
+ download_path: Path = Field(
17
+ default=Path.home() / "Downloads",
18
+ description="Directorio de descargas predeterminado",
19
+ )
20
+ default_audio_format: str = Field(
21
+ default="mp3",
22
+ description="Formato de audio predeterminado",
23
+ )
24
+ default_video_res: str = Field(
25
+ default="1080",
26
+ description="Resolución de video predeterminada",
27
+ )
28
+ default_audio_quality: str = Field(
29
+ default="320",
30
+ description="Calidad de audio predeterminada en kbps",
31
+ )
32
+ default_video_format: str = Field(
33
+ default="mp4",
34
+ description="Formato de video predeterminado",
35
+ )
36
+ embed_thumbnail: bool = Field(
37
+ default=True,
38
+ description="Incrustar portada en archivos de audio",
39
+ )
40
+
41
+ @classmethod
42
+ def get_config_path(cls) -> Path:
43
+ """Obtiene la ruta del archivo de configuración."""
44
+ config_dir = Path.home() / ".config" / "music-free"
45
+ config_dir.mkdir(parents=True, exist_ok=True)
46
+ return config_dir / "config.toml"
47
+
48
+ @classmethod
49
+ def load(cls) -> "Settings":
50
+ """Carga la configuración desde el archivo TOML."""
51
+ config_path = cls.get_config_path()
52
+ if config_path.exists():
53
+ with open(config_path, "rb") as f:
54
+ data = tomllib.load(f)
55
+ return cls(**data)
56
+ return cls()
57
+
58
+ def save(self) -> None:
59
+ """Guarda la configuración en el archivo TOML."""
60
+ config_path = self.get_config_path()
61
+ config_dict = {
62
+ "download_path": str(self.download_path),
63
+ "default_audio_format": self.default_audio_format,
64
+ "default_video_res": self.default_video_res,
65
+ "default_audio_quality": self.default_audio_quality,
66
+ "default_video_format": self.default_video_format,
67
+ "embed_thumbnail": self.embed_thumbnail,
68
+ }
69
+
70
+ try:
71
+ import tomli_w
72
+ with open(config_path, "wb") as f:
73
+ tomli_w.dump(config_dict, f)
74
+ except ImportError:
75
+ toml_content = f"""# Configuración de music-free
76
+
77
+ download_path = "{self.download_path}"
78
+ default_audio_format = "{self.default_audio_format}"
79
+ default_video_res = "{self.default_video_res}"
80
+ default_audio_quality = "{self.default_audio_quality}"
81
+ default_video_format = "{self.default_video_format}"
82
+ """
83
+ with open(config_path, "w") as f:
84
+ f.write(toml_content)
85
+
86
+ @classmethod
87
+ def get_default_download_path(cls) -> Path:
88
+ """Obtiene la carpeta de Descargas del sistema operativo."""
89
+ if (Path.home() / "Downloads").exists():
90
+ return Path.home() / "Downloads"
91
+ if (Path.home() / "downloads").exists():
92
+ return Path.home() / "downloads"
93
+ return Path.home()
94
+
95
+
96
+ _settings_instance: Optional[Settings] = None
97
+
98
+
99
+ def get_settings() -> Settings:
100
+ """Obtiene la instancia global de configuración."""
101
+ global _settings_instance
102
+ if _settings_instance is None:
103
+ _settings_instance = Settings.load()
104
+ return _settings_instance
105
+
106
+
107
+ def reload_settings() -> Settings:
108
+ """Recarga la configuración desde el archivo."""
109
+ global _settings_instance
110
+ _settings_instance = Settings.load()
111
+ return _settings_instance
@@ -0,0 +1,170 @@
1
+ from pathlib import Path
2
+ from typing import Any, Dict, Optional
3
+
4
+ import yt_dlp
5
+
6
+ from config import Settings
7
+ from ui import ProgressHook, console, show_media_info, get_status_spinner
8
+
9
+
10
+ class MediaDownloader:
11
+ """Clase principal para descargas de medios usando yt-dlp."""
12
+
13
+ def __init__(self) -> None:
14
+ """Inicializa el descargador con la configuración."""
15
+ self.settings = Settings.load()
16
+ self._ydl_opts: Dict[str, Any] = {}
17
+
18
+ _THUMB_FORMATS = {"mp3", "m4a", "flac", "ogg", "opus"}
19
+
20
+ def _get_audio_opts(self, quality: str, output_path: Path) -> Dict[str, Any]:
21
+ """Genera opciones de yt-dlp para descarga de audio."""
22
+ codec = self.settings.default_audio_format
23
+ embed = self.settings.embed_thumbnail and codec in self._THUMB_FORMATS
24
+
25
+ postprocessors = [
26
+ {
27
+ "key": "FFmpegExtractAudio",
28
+ "preferredcodec": codec,
29
+ "preferredquality": quality,
30
+ }
31
+ ]
32
+
33
+ if embed:
34
+ postprocessors.append({"key": "EmbedThumbnail"})
35
+
36
+ return {
37
+ "format": "bestaudio/best",
38
+ "outtmpl": str(output_path / "%(title)s.%(ext)s"),
39
+ "postprocessors": postprocessors,
40
+ "writethumbnail": embed,
41
+ "embedthumbnail": embed,
42
+ "progress_hooks": [ProgressHook()],
43
+ "quiet": True,
44
+ "no_warnings": True,
45
+ "noplaylist": True,
46
+ }
47
+
48
+ def _get_video_opts(self, quality: str, output_path: Path) -> Dict[str, Any]:
49
+ """Genera opciones de yt-dlp para descarga de video."""
50
+ format_str = f"bestvideo[height<={quality}]+bestaudio/best" if quality != "best" else "best"
51
+ return {
52
+ "format": format_str,
53
+ "outtmpl": str(output_path / "%(title)s.%(ext)s"),
54
+ "progress_hooks": [ProgressHook()],
55
+ "quiet": True,
56
+ "no_warnings": True,
57
+ "noplaylist": True,
58
+ }
59
+
60
+ def _extract_info(self, url: str) -> Dict[str, Any]:
61
+ """Extrae metadatos del video sin descargar."""
62
+ opts: Dict[str, Any] = {
63
+ "quiet": True,
64
+ "no_warnings": True,
65
+ "extract_flat": False,
66
+ "noplaylist": True,
67
+ }
68
+ with get_status_spinner("Extrayendo metadatos..."):
69
+ with yt_dlp.YoutubeDL(opts) as ydl:
70
+ info = ydl.extract_info(url, download=False)
71
+ if info is None:
72
+ raise ValueError("No se pudo obtener información del video")
73
+ return info
74
+
75
+ def _show_info(self, info: Dict[str, Any]) -> None:
76
+ """Muestra los metadatos del video en una tabla."""
77
+ display_info: Dict[str, Any] = {
78
+ "title": info.get("title", "N/A"),
79
+ "duration": info.get("duration", 0),
80
+ "uploader": info.get("uploader", info.get("channel", "N/A")),
81
+ "upload_date": info.get("upload_date", "N/A"),
82
+ "view_count": info.get("view_count", 0),
83
+ "like_count": info.get("like_count", 0),
84
+ }
85
+ if "resolution" in info:
86
+ display_info["resolution"] = info["resolution"]
87
+ if "format" in info:
88
+ display_info["format"] = info["format"]
89
+
90
+ show_media_info(display_info)
91
+
92
+ def download_audio(
93
+ self,
94
+ url: str,
95
+ quality: Optional[str] = None,
96
+ output: Optional[Path] = None,
97
+ ) -> bool:
98
+ """Descarga audio de YouTube."""
99
+ output_path = output or self.settings.download_path
100
+ audio_quality = quality or self.settings.default_audio_quality
101
+
102
+ console.print(f"\n[cyan]🎵 Obteniendo información del audio...[/cyan]\n")
103
+
104
+ try:
105
+ info = self._extract_info(url)
106
+ self._show_info(info)
107
+ except Exception as e:
108
+ console.print(f"[red]Error al obtener información:[/red] {e}")
109
+ return False
110
+
111
+ console.print(f"\n[cyan]⬇️ Descargando audio en formato {self.settings.default_audio_format} ({audio_quality}kbps)...[/cyan]\n")
112
+
113
+ opts = self._get_audio_opts(audio_quality, output_path)
114
+
115
+ try:
116
+ with yt_dlp.YoutubeDL(opts) as ydl:
117
+ ydl.download([url])
118
+ console.print(f"\n[green]✓[/green] Audio descargado en: {output_path}")
119
+ return True
120
+ except yt_dlp.utils.DownloadError as e:
121
+ console.print(f"\n[red]✗[/red] Error en la descarga: {e}")
122
+ return False
123
+ except Exception as e:
124
+ console.print(f"\n[red]✗[/red] Error inesperado: {e}")
125
+ return False
126
+
127
+ def get_info(self, url: str) -> Optional[Dict[str, Any]]:
128
+ """Obtiene información del video/audio sin descargar."""
129
+ try:
130
+ info = self._extract_info(url)
131
+ self._show_info(info)
132
+ return info
133
+ except Exception as e:
134
+ console.print(f"[red]Error al obtener información:[/red] {e}")
135
+ return None
136
+
137
+ def download_video(
138
+ self,
139
+ url: str,
140
+ quality: Optional[str] = None,
141
+ output: Optional[Path] = None,
142
+ ) -> bool:
143
+ """Descarga video de YouTube."""
144
+ output_path = output or self.settings.download_path
145
+ video_quality = quality or self.settings.default_video_res
146
+
147
+ console.print(f"\n[cyan]📺 Obteniendo información del video...[/cyan]\n")
148
+
149
+ try:
150
+ info = self._extract_info(url)
151
+ self._show_info(info)
152
+ except Exception as e:
153
+ console.print(f"[red]Error al obtener información:[/red] {e}")
154
+ return False
155
+
156
+ console.print(f"\n[cyan]⬇️ Descargando video en calidad {video_quality}p...[/cyan]\n")
157
+
158
+ opts = self._get_video_opts(video_quality, output_path)
159
+
160
+ try:
161
+ with yt_dlp.YoutubeDL(opts) as ydl:
162
+ ydl.download([url])
163
+ console.print(f"\n[green]✓[/green] Video descargado en: {output_path}")
164
+ return True
165
+ except yt_dlp.utils.DownloadError as e:
166
+ console.print(f"\n[red]✗[/red] Error en la descarga: {e}")
167
+ return False
168
+ except Exception as e:
169
+ console.print(f"\n[red]✗[/red] Error inesperado: {e}")
170
+ return False
@@ -0,0 +1,375 @@
1
+ import os
2
+ import sys
3
+ import typer
4
+ from pathlib import Path
5
+ from typing import Any, Optional
6
+
7
+ import questionary
8
+ from questionary import Style
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from config import Settings, get_settings, reload_settings
13
+ from downloader import MediaDownloader
14
+ from ui import show_panel, show_welcome_screen, show_current_config
15
+
16
+ app = typer.Typer(
17
+ name="wolfcode",
18
+ help="CLI profesional para descargar contenido de YouTube",
19
+ add_completion=False,
20
+ )
21
+ console = Console()
22
+
23
+ video_app = typer.Typer(help="Descargar videos de YouTube")
24
+ audio_app = typer.Typer(help="Descargar audio de YouTube")
25
+ config_app = typer.Typer(help="Gestionar configuración")
26
+
27
+ app.add_typer(video_app, name="video")
28
+ app.add_typer(audio_app, name="audio")
29
+ app.add_typer(config_app, name="config")
30
+
31
+ WAVE_STYLE = Style([
32
+ ("qmark", "fg:cyan bold"),
33
+ ("pointer", "fg:cyan bold"),
34
+ ("selected", "fg:cyan bold"),
35
+ ("separator", "fg:cyan"),
36
+ ("question", "bold #ffffff"),
37
+ ("answer", "fg:cyan bold"),
38
+ ])
39
+
40
+
41
+ def safe_questionary(prompt_func) -> Optional[Any]:
42
+ """Envuelve questionary para manejo elegante de excepciones."""
43
+ try:
44
+ return prompt_func()
45
+ except (KeyboardInterrupt, EOFError):
46
+ console.print("\n[dim]Sesion finalizada.[/dim]")
47
+ sys.exit(0)
48
+ except Exception:
49
+ console.print("\n[dim]Sesion finalizada.[/dim]")
50
+ sys.exit(0)
51
+
52
+
53
+ def run_interactive_menu() -> None:
54
+ """Ejecuta el menú interactivo con questionary."""
55
+ choice = safe_questionary(lambda: questionary.select(
56
+ "Que deseas hacer?",
57
+ choices=[
58
+ "Descargar Audio",
59
+ "Descargar Video",
60
+ "Configuracion",
61
+ "Salir",
62
+ ],
63
+ qmark=">",
64
+ style=WAVE_STYLE,
65
+ ).ask())
66
+
67
+ if choice == "Salir" or choice is None:
68
+ console.print("\n[dim]Hasta luego.[/dim]")
69
+ return
70
+
71
+ if choice == "Configuracion":
72
+ config_menu()
73
+ run_interactive_menu()
74
+ return
75
+
76
+ url = safe_questionary(lambda: questionary.text(
77
+ "Ingresa la URL de YouTube:",
78
+ qmark=">",
79
+ style=WAVE_STYLE,
80
+ validate=lambda x: len(x) > 0 or "Por favor ingresa una URL valida",
81
+ ).ask())
82
+
83
+ if not url:
84
+ console.print("[red]URL requerida[/red]")
85
+ run_interactive_menu()
86
+ return
87
+
88
+ downloader = MediaDownloader()
89
+
90
+ if choice == "Descargar Audio":
91
+ success = downloader.download_audio(url)
92
+ else:
93
+ success = downloader.download_video(url)
94
+
95
+ if success:
96
+ console.print("\n[green]✓[/green] Descarga completada")
97
+ else:
98
+ console.print("\n[red]✗[/red] La descarga fallo")
99
+
100
+ continue_choice = safe_questionary(lambda: questionary.confirm(
101
+ "Deseas realizar otra accion?",
102
+ default=True,
103
+ style=WAVE_STYLE,
104
+ ).ask())
105
+
106
+ if continue_choice:
107
+ run_interactive_menu()
108
+ else:
109
+ console.print("\n[dim]Hasta luego.[/dim]")
110
+
111
+
112
+ @app.callback(invoke_without_command=True)
113
+ def main_callback(
114
+ ctx: typer.Context,
115
+ ) -> None:
116
+ """Callback principal que detecta si no hay argumentos."""
117
+ if ctx.invoked_subcommand is None:
118
+ show_welcome_screen()
119
+ run_interactive_menu()
120
+
121
+
122
+ @video_app.command("download")
123
+ def video_download(
124
+ url: str = typer.Argument(..., help="URL del video de YouTube"),
125
+ quality: str = typer.Option("best", "--quality", "-q", help="Calidad del video (best, 1080p, 720p, etc.)"),
126
+ output: Optional[str] = typer.Option(None, "--output", "-o", help="Directorio de salida"),
127
+ ):
128
+ """Descargar video de YouTube"""
129
+ output_path = Path(output) if output else None
130
+ downloader = MediaDownloader()
131
+
132
+ success = downloader.download_video(url, quality=quality, output=output_path)
133
+
134
+ if not success:
135
+ raise typer.Exit(1)
136
+
137
+
138
+ @video_app.command("info")
139
+ def video_info(
140
+ url: str = typer.Argument(..., help="URL del video de YouTube"),
141
+ ):
142
+ """Mostrar información del video sin descargar"""
143
+ downloader = MediaDownloader()
144
+ success = downloader.get_info(url)
145
+ if not success:
146
+ raise typer.Exit(1)
147
+
148
+
149
+ @audio_app.command("download")
150
+ def audio_download(
151
+ url: str = typer.Argument(..., help="URL del video/playlist de YouTube"),
152
+ format: str = typer.Option("mp3", "--format", "-f", help="Formato de audio (mp3, flac, wav, etc.)"),
153
+ quality: str = typer.Option("320", "--quality", "-q", help="Calidad del audio (320, 256, 128 kbps)"),
154
+ output: Optional[str] = typer.Option(None, "--output", "-o", help="Directorio de salida"),
155
+ playlist: bool = typer.Option(False, "--playlist", "-p", help="Descargar playlist completa"),
156
+ ):
157
+ """Descargar audio de YouTube"""
158
+ output_path = Path(output) if output else None
159
+ downloader = MediaDownloader()
160
+
161
+ success = downloader.download_audio(url, quality=quality, output=output_path)
162
+
163
+ if not success:
164
+ raise typer.Exit(1)
165
+
166
+
167
+ @audio_app.command("info")
168
+ def audio_info(
169
+ url: str = typer.Argument(..., help="URL del video de YouTube"),
170
+ ):
171
+ """Mostrar información del audio sin descargar"""
172
+ downloader = MediaDownloader()
173
+ success = downloader.get_info(url)
174
+ if not success:
175
+ raise typer.Exit(1)
176
+
177
+
178
+ @config_app.command("show")
179
+ def config_show():
180
+ """Mostrar configuración actual"""
181
+ settings = get_settings()
182
+ config_dict = {
183
+ "download_path": settings.download_path,
184
+ "default_audio_format": settings.default_audio_format,
185
+ "default_audio_quality": settings.default_audio_quality,
186
+ "default_video_format": settings.default_video_format,
187
+ "default_video_res": settings.default_video_res,
188
+ "embed_thumbnail": settings.embed_thumbnail,
189
+ }
190
+ show_current_config(config_dict)
191
+
192
+
193
+ def config_menu() -> None:
194
+ """Menu interactivo de configuracion."""
195
+ while True:
196
+ choice = safe_questionary(lambda: questionary.select(
197
+ "Configuracion",
198
+ choices=[
199
+ "Ver configuracion actual",
200
+ "Cambiar directorio de descarga",
201
+ "Cambiar formato de audio",
202
+ "Cambiar calidad de audio",
203
+ "Cambiar formato de video",
204
+ "Cambiar resolucion de video",
205
+ "Alternar portada incrustada",
206
+ "Volver al menu principal",
207
+ ],
208
+ qmark=">",
209
+ style=WAVE_STYLE,
210
+ ).ask())
211
+
212
+ if choice == "Volver al menu principal" or choice is None:
213
+ return
214
+
215
+ if choice == "Ver configuracion actual":
216
+ config_show()
217
+ continue
218
+
219
+ if choice == "Cambiar directorio de descarga":
220
+ settings = get_settings()
221
+ current_path = str(settings.download_path)
222
+ new_path = safe_questionary(lambda: questionary.path(
223
+ f"Nueva ruta (actual: {current_path}):",
224
+ default=current_path,
225
+ only_directories=True,
226
+ qmark=">",
227
+ style=WAVE_STYLE,
228
+ ).ask())
229
+
230
+ if new_path and new_path != current_path:
231
+ path = Path(new_path)
232
+ if not path.exists():
233
+ create = safe_questionary(lambda: questionary.confirm(
234
+ f"El directorio '{new_path}' no existe. Desea crearlo?",
235
+ default=True,
236
+ style=WAVE_STYLE,
237
+ ).ask())
238
+ if create:
239
+ path.mkdir(parents=True, exist_ok=True)
240
+ else:
241
+ console.print("[red]Directorio no creado. Configuracion sin cambios.[/red]")
242
+ continue
243
+
244
+ if path.exists():
245
+ settings.download_path = path
246
+ settings.save()
247
+ reload_settings()
248
+ console.print("[green]Configuracion actualizada y guardada.[/green]")
249
+ else:
250
+ console.print("[red]Directorio no valido.[/red]")
251
+ continue
252
+
253
+ if choice == "Cambiar formato de audio":
254
+ settings = get_settings()
255
+ new_format = safe_questionary(lambda: questionary.select(
256
+ f"Selecciona el formato de audio (actual: {settings.default_audio_format}):",
257
+ choices=["mp3", "m4a", "wav", "flac"],
258
+ qmark=">",
259
+ style=WAVE_STYLE,
260
+ ).ask())
261
+
262
+ if new_format:
263
+ settings.default_audio_format = new_format
264
+ settings.save()
265
+ reload_settings()
266
+ console.print("[green]Configuracion actualizada y guardada.[/green]")
267
+ continue
268
+
269
+ if choice == "Cambiar calidad de audio":
270
+ settings = get_settings()
271
+ new_quality = safe_questionary(lambda: questionary.select(
272
+ f"Selecciona la calidad de audio en kbps (actual: {settings.default_audio_quality}):",
273
+ choices=["320", "256", "192", "128"],
274
+ qmark=">",
275
+ style=WAVE_STYLE,
276
+ ).ask())
277
+
278
+ if new_quality:
279
+ settings.default_audio_quality = new_quality
280
+ settings.save()
281
+ reload_settings()
282
+ console.print("[green]Configuracion actualizada y guardada.[/green]")
283
+ continue
284
+
285
+ if choice == "Cambiar formato de video":
286
+ settings = get_settings()
287
+ new_format = safe_questionary(lambda: questionary.select(
288
+ f"Selecciona el formato de video (actual: {settings.default_video_format}):",
289
+ choices=["mp4", "mkv", "webm"],
290
+ qmark=">",
291
+ style=WAVE_STYLE,
292
+ ).ask())
293
+
294
+ if new_format:
295
+ settings.default_video_format = new_format
296
+ settings.save()
297
+ reload_settings()
298
+ console.print("[green]Configuracion actualizada y guardada.[/green]")
299
+ continue
300
+
301
+ if choice == "Cambiar resolucion de video":
302
+ settings = get_settings()
303
+ new_res = safe_questionary(lambda: questionary.select(
304
+ f"Selecciona la resolucion de video (actual: {settings.default_video_res}p):",
305
+ choices=["2160", "1080", "720", "480", "360"],
306
+ qmark=">",
307
+ style=WAVE_STYLE,
308
+ ).ask())
309
+
310
+ if new_res:
311
+ settings.default_video_res = new_res
312
+ settings.save()
313
+ reload_settings()
314
+ console.print("[green]Configuracion actualizada y guardada.[/green]")
315
+ continue
316
+
317
+ if choice == "Alternar portada incrustada":
318
+ settings = get_settings()
319
+ settings.embed_thumbnail = not settings.embed_thumbnail
320
+ settings.save()
321
+ reload_settings()
322
+ status = "activada" if settings.embed_thumbnail else "desactivada"
323
+ console.print(f"[green]Portada incrustada {status}.[/green]")
324
+ continue
325
+
326
+
327
+ @config_app.command("set")
328
+ def config_set(
329
+ key: str = typer.Argument(..., help="Clave de configuracion a modificar"),
330
+ value: str = typer.Argument(..., help="Nuevo valor"),
331
+ ):
332
+ """Modificar configuracion"""
333
+ settings = Settings.load()
334
+
335
+ if key == "download_path":
336
+ settings.download_path = Path(value)
337
+ elif key == "default_audio_format":
338
+ settings.default_audio_format = value
339
+ elif key == "default_video_res":
340
+ settings.default_video_res = value
341
+ elif key == "default_audio_quality":
342
+ settings.default_audio_quality = value
343
+ elif key == "default_video_format":
344
+ settings.default_video_format = value
345
+ elif key == "embed_thumbnail":
346
+ settings.embed_thumbnail = value.lower() in ("true", "1", "yes")
347
+ else:
348
+ console.print(f"[red]Clave desconocida:[/red] {key}")
349
+ return
350
+
351
+ settings.save()
352
+ console.print(f"[green]Configuracion actualizada:[/green] {key} = {value}")
353
+
354
+
355
+ @config_app.command("reset")
356
+ def config_reset(
357
+ confirm: bool = typer.Option(False, "--yes", "-y", help="Confirmar reset sin preguntar"),
358
+ ):
359
+ """Restablecer configuración por defecto"""
360
+ if confirm:
361
+ settings = Settings()
362
+ settings.save()
363
+ console.print("[yellow]✓[/yellow] Configuración restablecida a valores por defecto")
364
+ else:
365
+ console.print("[red]Usa --yes para confirmar el reset[/red]")
366
+
367
+
368
+ @app.command()
369
+ def version():
370
+ """Mostrar version de la aplicacion"""
371
+ console.print("[cyan bold]WOLFCODE v1.0.0[/cyan bold]")
372
+
373
+
374
+ if __name__ == "__main__":
375
+ app()
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: open-yt
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: mutagen>=1.47.0
9
+ Requires-Dist: pydantic-settings>=2.14.1
10
+ Requires-Dist: pyfiglet>=1.0.4
11
+ Requires-Dist: questionary>=2.1.1
12
+ Requires-Dist: rich>=15.0.0
13
+ Requires-Dist: tomli-w>=1.2.0
14
+ Requires-Dist: typer>=0.25.1
15
+ Requires-Dist: yt-dlp>=2026.3.17
16
+ Dynamic: license-file
17
+
18
+ # OPEN-YT 🟥
19
+
20
+ > The high-performance, minimalist open-source YouTube engine for audio and video.
21
+
22
+ [![Python 3.10+](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/downloads/)
23
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
24
+ [![Build: PyInstaller](https://img.shields.io/badge/Build-PyInstaller-orange.svg)](https://pyinstaller.org/)
25
+
26
+ ![OPEN-YT Demo](assets/open-yt-demo.gif)
27
+
28
+ OPEN-YT es una Interfaz de Línea de Comandos (CLI) de grado industrial diseñada para extraer audio y video con la máxima eficiencia. Combina la potencia de `yt-dlp` con una experiencia de usuario (UX) minimalista, elegante y altamente configurable.
29
+
30
+ ## ✨ Características
31
+
32
+ * **UI/UX Premium:** Interfaz de terminal renderizada con `Rich` y menús interactivos fluidos potenciados por `Questionary`.
33
+ * **Motor Asíncrono:** Descargas ultra rápidas y extracción de metadatos sin bloquear la interfaz.
34
+ * **Persistencia de Estado:** Recuerda tus preferencias de formato (MP3, FLAC, MP4, MKV), resolución y rutas de descarga localmente.
35
+ * **Multiplataforma y Portable:** Distribuido como un binario único. Cero dependencias requeridas para el usuario final.
36
+
37
+ ---
38
+
39
+ ## 🚀 Instalación (Recomendado)
40
+
41
+ Para usar OPEN-YT en **Linux, Windows o Mac NO es necesario instalar Python ni configurar entornos**.
42
+
43
+ 1. Ve a la sección de [Releases](../../releases) del repositorio.
44
+ 2. Descarga el ejecutable nativo correspondiente a tu sistema operativo (ej. `open-yt-linux` o `open-yt-windows.exe`).
45
+ 3. Abre tu terminal y ejecútalo directamente.
46
+
47
+ ---
48
+
49
+ ## 💻 Instalación para Desarrolladores
50
+
51
+ Si deseas explorar el código fuente, modificar la herramienta o compilarla tú mismo:
52
+
53
+ ```bash
54
+ # 1. Clonar el repositorio
55
+ git clone https://github.com/elisbanpaco/open-yt.git
56
+ cd open-yt
57
+
58
+ # 2. Crear el entorno virtual e instalar dependencias
59
+ uv sync
60
+
61
+ # 3. Ejecutar
62
+ uv run python src/main.py
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/config.py
5
+ src/downloader.py
6
+ src/main.py
7
+ src/ui.py
8
+ src/open_yt.egg-info/PKG-INFO
9
+ src/open_yt.egg-info/SOURCES.txt
10
+ src/open_yt.egg-info/dependency_links.txt
11
+ src/open_yt.egg-info/requires.txt
12
+ src/open_yt.egg-info/top_level.txt
@@ -0,0 +1,8 @@
1
+ mutagen>=1.47.0
2
+ pydantic-settings>=2.14.1
3
+ pyfiglet>=1.0.4
4
+ questionary>=2.1.1
5
+ rich>=15.0.0
6
+ tomli-w>=1.2.0
7
+ typer>=0.25.1
8
+ yt-dlp>=2026.3.17
@@ -0,0 +1,4 @@
1
+ config
2
+ downloader
3
+ main
4
+ ui
@@ -0,0 +1,272 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from rich import box
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+ from rich.progress import (
7
+ BarColumn,
8
+ Progress,
9
+ TextColumn,
10
+ TimeRemainingColumn,
11
+ TransferSpeedColumn,
12
+ )
13
+ from rich.rule import Rule
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+
18
+ console = Console()
19
+
20
+
21
+ _C1 = "#8B1538" # rojo oscuro profundo (inicio del gradiente)
22
+ _C2 = "#C4374B" # rojo medio vibrante
23
+ _C3 = "#E56B6F" # coral/rosa medio
24
+ _C4 = "#F4A382" # salmón/naranja claro (fin del gradiente)
25
+ _DIM = "grey50" # separadores y texto secundario
26
+ _LABEL = "misty_rose3" # etiquetas (rosado neutro, no compite con el rojo)
27
+ _OK = "dark_sea_green3" # éxito (verde apagado, no chilla junto al rojo)
28
+ _ERR = "bright_red" # error (único rojo puro, solo para errores reales)
29
+
30
+
31
+ _LOGO_ROWS = [
32
+ (_C1, " ██████╗ ██████╗ ███████╗███╗ ██╗", _C1, "██╗ ██╗████████╗"),
33
+ (_C1, " ██╔═══██╗██╔══██╗██╔════╝████╗ ██║", _C1, "╚██╗ ██╔╝╚══██╔══╝"),
34
+ (_C2, " ██║ ██║██████╔╝█████╗ ██╔██╗ ██║", _C2, " ╚████╔╝ ██║ "),
35
+ (_C2, " ██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║", _C2, " ╚██╔╝ ██║ "),
36
+ (_C3, " ╚██████╔╝██║ ███████╗██║ ╚████║", _C3, " ██║ ██║ "),
37
+ (_C4, " ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝", _C4, " ╚═╝ ╚═╝ "),
38
+ ]
39
+ _SEP_LEFT = " █████╗ "
40
+ _SEP_RIGHT = " ╚════╝ "
41
+
42
+
43
+ def _build_logo() -> Text:
44
+ """Construye el logo con gradiente usando Rich Text."""
45
+ logo = Text(justify="center")
46
+ for i, (lc, left, rc, right) in enumerate(_LOGO_ROWS):
47
+ # separador central solo en filas 2 y 3 (índice 2 y 3)
48
+ mid = ""
49
+ mid_style = _DIM
50
+ if i == 2:
51
+ mid = " █████╗ "
52
+ elif i == 3:
53
+ mid = " ╚════╝ "
54
+
55
+ logo.append(left, style=f"bold {lc}")
56
+ if mid:
57
+ logo.append(mid, style=f"dim {mid_style}")
58
+ else:
59
+ logo.append(" ")
60
+ logo.append(right, style=f"bold {rc}")
61
+ logo.append("\n")
62
+ return logo
63
+
64
+
65
+ def show_welcome_screen() -> None:
66
+ """Pantalla de bienvenida estilo Claude Code / Gemini CLI."""
67
+ console.clear()
68
+
69
+ # Logo
70
+ console.print()
71
+ console.print(_build_logo())
72
+
73
+ console.print(Rule(style=_DIM), width=68)
74
+
75
+ meta = Text(justify="center")
76
+ meta.append("v0.0.1", style=f"bold {_C2}")
77
+ meta.append(" · ", style=_DIM)
78
+ meta.append("Open Source Media Engine", style=_DIM)
79
+ console.print(meta)
80
+
81
+ console.print(Rule(style=_DIM), width=68)
82
+ console.print()
83
+
84
+ def format_duration(seconds: int) -> str:
85
+ hours, remainder = divmod(seconds, 3600)
86
+ minutes, seconds = divmod(remainder, 60)
87
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}" if hours else f"{minutes:02d}:{seconds:02d}"
88
+
89
+
90
+ def format_number(num: int) -> str:
91
+ return f"{num:,}"
92
+
93
+
94
+ def show_media_card(info_dict: Dict[str, Any]) -> None:
95
+ """Tarjeta de metadatos con paleta naranja-dorada."""
96
+ title = info_dict.get("title", "Unknown")
97
+ duration = format_duration(info_dict.get("duration", 0))
98
+ channel = info_dict.get("uploader") or info_dict.get("channel", "Unknown")
99
+ views = format_number(info_dict.get("view_count", 0))
100
+ likes = format_number(info_dict.get("like_count", 0))
101
+ upload_date = info_dict.get("upload_date", "Unknown")
102
+
103
+ tbl = Table(box=None, show_header=False, padding=(0, 1))
104
+ tbl.add_column("Label", style=_LABEL, width=14)
105
+ tbl.add_column("Value", style="white")
106
+
107
+ tbl.add_row("Titulo:", title)
108
+ tbl.add_row("Canal:", channel)
109
+ tbl.add_row("Duracion:", duration)
110
+ tbl.add_row("Vistas:", views)
111
+ tbl.add_row("Likes:", likes)
112
+ tbl.add_row("Fecha:", upload_date)
113
+
114
+ if info_dict.get("resolution") and info_dict.get("resolution") != "N/A":
115
+ tbl.add_row("Resolucion:", info_dict["resolution"])
116
+
117
+ console.print(
118
+ Panel(
119
+ tbl,
120
+ box=box.ROUNDED,
121
+ border_style=_DIM,
122
+ padding=(1, 2),
123
+ width=75,
124
+ title=f"[{_C2}]Información[/{_C2}]",
125
+ title_align="left",
126
+ )
127
+ )
128
+
129
+
130
+ def show_current_config(config_dict: Dict[str, Any]) -> None:
131
+ """Panel de configuración con secciones y paleta coherente."""
132
+ download_path = str(config_dict.get("download_path", "N/A"))
133
+ audio_format = config_dict.get("default_audio_format", "N/A")
134
+ audio_quality = config_dict.get("default_audio_quality", "N/A")
135
+ video_format = config_dict.get("default_video_format", "N/A")
136
+ video_res = config_dict.get("default_video_res", "N/A")
137
+
138
+ content = Text()
139
+ content.append("Rutas\n", style=f"bold {_C2}")
140
+ content.append(f" Directorio: {download_path}\n\n", style="white")
141
+ content.append("Audio\n", style=f"bold {_C2}")
142
+ content.append(
143
+ f" Formato: {audio_format} | Calidad: {audio_quality} kbps\n\n",
144
+ style="white",
145
+ )
146
+ content.append("Video\n", style=f"bold {_C2}")
147
+ content.append(
148
+ f" Formato: {video_format} | Resolución: {video_res}p\n\n",
149
+ style="white",
150
+ )
151
+ content.append("Extras\n", style=f"bold {_C2}")
152
+ embed_thumb = config_dict.get("embed_thumbnail", True)
153
+ thumb_status = "Activada" if embed_thumb else "Desactivada"
154
+ content.append(
155
+ f" Portada incrustada: {thumb_status}",
156
+ style="white",
157
+ )
158
+
159
+ console.print(
160
+ Panel(
161
+ content,
162
+ box=box.ROUNDED,
163
+ border_style=_DIM,
164
+ padding=(1, 2),
165
+ width=75,
166
+ title=f"[{_C2}]Configuración[/{_C2}]",
167
+ title_align="left",
168
+ )
169
+ )
170
+
171
+
172
+ # Alias de compatibilidad
173
+ def show_media_info(info_dict: Dict[str, Any]) -> None:
174
+ show_media_card(info_dict)
175
+
176
+
177
+ class ProgressHook:
178
+ """Hook de progreso para yt-dlp compatible con Rich."""
179
+
180
+ def __init__(
181
+ self,
182
+ description: str = "Descargando",
183
+ bar_style: str = _C2,
184
+ text_style: str = "white",
185
+ ) -> None:
186
+ self.description = description
187
+ self.bar_style = bar_style
188
+ self.text_style = text_style
189
+ self._progress: Optional[Progress] = None
190
+ self._task_id: Optional[int] = None
191
+
192
+ def __call__(self, d: Dict[str, Any]) -> None:
193
+ status = d.get("status")
194
+ if status == "starting": self._handle_starting(d)
195
+ elif status == "downloading": self._handle_downloading(d)
196
+ elif status == "finished": self._handle_finished(d)
197
+ elif status == "error": self._handle_error(d)
198
+
199
+ def _init_progress(self, total: float, title: str) -> None:
200
+ if self._progress is not None:
201
+ return
202
+ self._progress = Progress(
203
+ TextColumn(
204
+ f"[{self.text_style}]{self.description}: {{task.description}}",
205
+ ),
206
+ BarColumn(bar_width=40, style=self.bar_style, complete_style=_C3),
207
+ TextColumn(
208
+ f"[{_C4}]{{task.percentage:>3.0f}}%",
209
+ ),
210
+ TransferSpeedColumn(),
211
+ TimeRemainingColumn(),
212
+ console=console,
213
+ transient=False,
214
+ )
215
+ self._progress.start()
216
+ self._task_id = self._progress.add_task(title, total=total)
217
+
218
+ def _handle_starting(self, d: Dict[str, Any]) -> None:
219
+ total = d.get("total_bytes") or d.get("total_bytes_estimate", 0)
220
+ filename = d.get("filename", "Archivo")
221
+ self._init_progress(total, filename)
222
+
223
+ def _handle_downloading(self, d: Dict[str, Any]) -> None:
224
+ if self._progress is None or self._task_id is None:
225
+ total = d.get("total_bytes") or d.get("total_bytes_estimate", 0)
226
+ filename = d.get("filename", "Archivo")
227
+ self._init_progress(total, filename)
228
+
229
+ downloaded = d.get("downloaded_bytes", 0)
230
+ total = d.get("total_bytes") or d.get("total_bytes_estimate", 1)
231
+
232
+ if self._progress and self._task_id is not None:
233
+ self._progress.update(self._task_id, completed=downloaded, total=total)
234
+
235
+ def _handle_finished(self, d: Dict[str, Any]) -> None:
236
+ if self._progress:
237
+ self._progress.stop()
238
+ self._progress = None
239
+ self._task_id = None
240
+ console.print(
241
+ f"[{_OK}]✓[/{_OK}] [white]Descarga completada:[/white] "
242
+ f"[{_C2}]{d.get('filename', '')}[/{_C2}]"
243
+ )
244
+
245
+ def _handle_error(self, d: Dict[str, Any]) -> None:
246
+ if self._progress:
247
+ self._progress.stop()
248
+ self._progress = None
249
+ self._task_id = None
250
+ console.print(
251
+ f"[{_ERR}]✗[/{_ERR}] [white]Error:[/white] "
252
+ f"[{_ERR}]{d.get('error', 'Error desconocido')}[/{_ERR}]"
253
+ )
254
+
255
+
256
+ def show_panel(
257
+ title: str,
258
+ content: str,
259
+ style: str = _C2,
260
+ expand: bool = True,
261
+ ) -> None:
262
+ console.print(
263
+ Panel(content, title=title, border_style=_DIM, expand=expand)
264
+ )
265
+
266
+
267
+ def get_status_spinner(message: str):
268
+ return console.status(
269
+ f"[{_C2}]{message}[/{_C2}]",
270
+ spinner="dots",
271
+ spinner_style=_C2,
272
+ )