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 +21 -0
- open_yt-0.1.0/PKG-INFO +62 -0
- open_yt-0.1.0/README.md +45 -0
- open_yt-0.1.0/pyproject.toml +16 -0
- open_yt-0.1.0/setup.cfg +4 -0
- open_yt-0.1.0/src/config.py +111 -0
- open_yt-0.1.0/src/downloader.py +170 -0
- open_yt-0.1.0/src/main.py +375 -0
- open_yt-0.1.0/src/open_yt.egg-info/PKG-INFO +62 -0
- open_yt-0.1.0/src/open_yt.egg-info/SOURCES.txt +12 -0
- open_yt-0.1.0/src/open_yt.egg-info/dependency_links.txt +1 -0
- open_yt-0.1.0/src/open_yt.egg-info/requires.txt +8 -0
- open_yt-0.1.0/src/open_yt.egg-info/top_level.txt +4 -0
- open_yt-0.1.0/src/ui.py +272 -0
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
|
+
[](https://www.python.org/downloads/)
|
|
23
|
+
[](https://opensource.org/licenses/MIT)
|
|
24
|
+
[](https://pyinstaller.org/)
|
|
25
|
+
|
|
26
|
+

|
|
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
|
open_yt-0.1.0/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# OPEN-YT 🟥
|
|
2
|
+
|
|
3
|
+
> The high-performance, minimalist open-source YouTube engine for audio and video.
|
|
4
|
+
|
|
5
|
+
[](https://www.python.org/downloads/)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://pyinstaller.org/)
|
|
8
|
+
|
|
9
|
+

|
|
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
|
+
]
|
open_yt-0.1.0/setup.cfg
ADDED
|
@@ -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
|
+
[](https://www.python.org/downloads/)
|
|
23
|
+
[](https://opensource.org/licenses/MIT)
|
|
24
|
+
[](https://pyinstaller.org/)
|
|
25
|
+
|
|
26
|
+

|
|
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 @@
|
|
|
1
|
+
|
open_yt-0.1.0/src/ui.py
ADDED
|
@@ -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
|
+
)
|