Siphon-TUI 2.1.0__py3-none-any.whl

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.
siphon_tui/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .tui.app import SiphonTUI
2
+ from .utils import configer
3
+ from .utils.download import Download
4
+
5
+ __all__ = ["SiphonTUI", "Download", "configer"]
siphon_tui/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
siphon_tui/cli.py ADDED
@@ -0,0 +1,18 @@
1
+ def run_cli():
2
+ from cliss import CLI
3
+
4
+ from .tui.app import get_version
5
+
6
+ app = CLI(
7
+ name="Siphon-TUI",
8
+ description="Siphon-TUI is a TUI audio/video downloader based on yt-dlp",
9
+ version=get_version(),
10
+ )
11
+
12
+ @app.command()
13
+ def config(path: str):
14
+ from .utils.configer import set_path
15
+
16
+ print(set_path(path))
17
+
18
+ app.run()
siphon_tui/main.py ADDED
@@ -0,0 +1,26 @@
1
+ def main():
2
+ import sys
3
+
4
+ from color_kiss import GREEN, RESET
5
+ from color_kiss.utils import error
6
+
7
+ if len(sys.argv) <= 1:
8
+ from .tui.app import run_tui
9
+
10
+ try:
11
+ run_tui()
12
+ except KeyboardInterrupt:
13
+ print(f"{GREEN}Goodbye!{RESET}")
14
+ sys.exit(0)
15
+ except Exception as e:
16
+ sys.exit(error(str(e)))
17
+ else:
18
+ from .cli import run_cli
19
+
20
+ try:
21
+ run_cli()
22
+ except KeyboardInterrupt:
23
+ print(f"{GREEN}Goodbye!{RESET}")
24
+ sys.exit(0)
25
+ except Exception as e:
26
+ sys.exit(error(str(e)))
siphon_tui/tui/app.py ADDED
@@ -0,0 +1,234 @@
1
+ from threading import Thread
2
+
3
+ from textual import on
4
+ from textual.app import App, ComposeResult
5
+ from textual.containers import Container, Horizontal, Vertical
6
+ from textual.widgets import Button, Footer, Header, Input, Select
7
+
8
+ from siphon_tui.utils.configer import get_path
9
+
10
+ AUDIO_CODECS = ["M4A", "MP3", "FLAC", "Opus", "Vorbis", "WAV"]
11
+ VIDEO_CONTAINERS = ["MP4", "MKV", "WebM", "MOV", "AVI", "FLV"]
12
+ LINES_KBPS = ["320", "256", "128", "64"]
13
+
14
+ VIDEO_CONTAINER_AUDIO_MAP = {
15
+ "mp4": "m4a",
16
+ "mov": "m4a",
17
+ "mkv": "opus",
18
+ "webm": "opus",
19
+ "avi": "mp3",
20
+ "flv": "aac",
21
+ }
22
+
23
+ SELECT_IDS = {
24
+ "codec": "audio_codec_select",
25
+ "container": "video_container_select",
26
+ "kbps": "kbps_select",
27
+ }
28
+
29
+
30
+ def get_version() -> str:
31
+ """Get version from installed package metadata."""
32
+ try:
33
+ from importlib.metadata import version
34
+
35
+ return version("Siphon-TUI")
36
+ except Exception:
37
+ return "unknown"
38
+
39
+
40
+ class SiphonTUI(App):
41
+ CSS_PATH = "style.tcss"
42
+
43
+ def __init__(self):
44
+ super().__init__()
45
+ self.theme = "rose-pine"
46
+ self.codec = None
47
+ self.container = None
48
+ self.kbps = 256
49
+
50
+ path, error_msg = get_path()
51
+ self.download_path = path
52
+
53
+ self.downloading = False
54
+ self.cancelled = False
55
+ self._path_error = error_msg
56
+
57
+ def compose(self) -> ComposeResult:
58
+ yield Header()
59
+ with Container(id="main_container"):
60
+ yield Input(id="url_input", placeholder="Enter your URL", type="text")
61
+ with Vertical(id="select_section"):
62
+ with Horizontal(id="codecs_row"):
63
+ yield Select(
64
+ ((c, c.lower()) for c in AUDIO_CODECS),
65
+ id=SELECT_IDS["codec"],
66
+ prompt="Audio codec",
67
+ )
68
+ yield Select(
69
+ ((c, c.lower()) for c in VIDEO_CONTAINERS),
70
+ id=SELECT_IDS["container"],
71
+ prompt="Container (optional)",
72
+ )
73
+ yield Select(
74
+ ((k, k) for k in LINES_KBPS),
75
+ id=SELECT_IDS["kbps"],
76
+ prompt="Bitrate (kbps)",
77
+ )
78
+ with Horizontal(id="button_row"):
79
+ yield Button("Download", variant="success", id="accept_button")
80
+ yield Button(
81
+ "Cancel", variant="error", id="cancel_button", disabled=True
82
+ )
83
+ yield Footer()
84
+
85
+ def on_mount(self) -> None:
86
+ self.title = "Siphon-TUI"
87
+ self.sub_title = f"v{get_version()}"
88
+
89
+ if self._path_error:
90
+ self.notify(
91
+ f"⚠️ {self._path_error}\nUsing: {self.download_path}",
92
+ severity="warning",
93
+ timeout=10,
94
+ )
95
+
96
+ @on(Select.Changed)
97
+ def select_changed(self, event: Select.Changed) -> None:
98
+ if event.value in (Select.BLANK, Select.NULL):
99
+ setattr(
100
+ self,
101
+ {
102
+ "audio_codec_select": "codec",
103
+ "video_container_select": "container",
104
+ "kbps_select": "kbps",
105
+ }[event.select.id],
106
+ None,
107
+ )
108
+ return
109
+
110
+ if event.select.id == SELECT_IDS["codec"]:
111
+ self.codec = str(event.value).lower()
112
+ elif event.select.id == SELECT_IDS["container"]:
113
+ self.container = str(event.value).lower()
114
+ if self.container:
115
+ audio_codec = VIDEO_CONTAINER_AUDIO_MAP.get(self.container)
116
+ if audio_codec:
117
+ self.codec = audio_codec
118
+ codec_select = self.query_one(f"#{SELECT_IDS['codec']}", Select)
119
+ for option in codec_select._options:
120
+ if (
121
+ option[1] not in (Select.BLANK, Select.NULL)
122
+ and str(option[1]).lower() == audio_codec
123
+ ):
124
+ codec_select.value = option[1]
125
+ break
126
+ elif event.select.id == SELECT_IDS["kbps"]:
127
+ self.kbps = int(event.value)
128
+
129
+ @on(Button.Pressed, "#accept_button")
130
+ def action_download(self) -> None:
131
+ url = self.query_one("#url_input", Input).value.strip()
132
+ if not url:
133
+ return self.notify("❌ Please enter a URL", severity="warning")
134
+ if not url.startswith(("http://", "https://")):
135
+ return self.notify("❌ Invalid URL", severity="error")
136
+ if not self._validate_settings():
137
+ return
138
+
139
+ self.cancelled = False
140
+ self.downloading = True
141
+ self.query_one("#accept_button", Button).disabled = True
142
+ self.query_one("#cancel_button", Button).disabled = False
143
+ self.query_one("#url_input", Input).disabled = True
144
+
145
+ msg = (
146
+ f"⬇️ Downloading {self.codec.upper()} -> {self.container.upper()} @ {self.kbps}kbps..."
147
+ if self.container
148
+ else f"⬇️ Downloading {self.codec.upper()} @ {self.kbps}kbps (audio only)..."
149
+ )
150
+ self.notify(msg)
151
+
152
+ self.download_thread = Thread(
153
+ target=self._start_download, args=(url,), daemon=True
154
+ )
155
+ self.download_thread.start()
156
+
157
+ @on(Button.Pressed, "#cancel_button")
158
+ def action_cancel(self) -> None:
159
+ if self.downloading:
160
+ self.cancelled = True
161
+ self.notify("Cancelling...", severity="warning")
162
+ else:
163
+ self.notify("Nothing to cancel", severity="warning")
164
+ self._reset_ui()
165
+
166
+ def _start_download(self, url: str) -> None:
167
+ from siphon_tui.utils.download import (
168
+ Download,
169
+ DownloadCancelledError,
170
+ DownloadError,
171
+ )
172
+
173
+ try:
174
+ downloader = Download(
175
+ url=url,
176
+ codec=self.container or self.codec,
177
+ kbps=self.kbps,
178
+ download_path=self.download_path,
179
+ )
180
+ downloader.set_cancel_check(lambda: self.cancelled)
181
+ downloader.download()
182
+ self.call_from_thread(
183
+ self._download_complete,
184
+ True,
185
+ f"Download completed on {self.download_path}",
186
+ )
187
+ except DownloadCancelledError:
188
+ self.call_from_thread(self._download_complete, False, "Download cancelled")
189
+ except DownloadError as e:
190
+ self.call_from_thread(self._download_complete, False, str(e))
191
+ except Exception as e:
192
+ self.call_from_thread(self._download_complete, False, f"Error: {e}")
193
+
194
+ def _download_complete(self, success: bool, message: str) -> None:
195
+ self.downloading = False
196
+ self.cancelled = False
197
+ self._reset_ui()
198
+ self.notify(
199
+ f"{'✅' if success else '❌'} {message}",
200
+ severity="information" if success else "error",
201
+ )
202
+
203
+ def _reset_ui(self) -> None:
204
+ self.query_one("#accept_button", Button).disabled = False
205
+ self.query_one("#cancel_button", Button).disabled = True
206
+ url_input = self.query_one("#url_input", Input)
207
+ url_input.disabled = False
208
+ url_input.value = ""
209
+ url_input.focus()
210
+
211
+ def _validate_settings(self) -> bool:
212
+ if self.container:
213
+ if not self.codec:
214
+ self.notify(
215
+ "❌ Failed to set audio codec for container", severity="error"
216
+ )
217
+ return False
218
+ elif not self.codec:
219
+ codec_select = self.query_one(f"#{SELECT_IDS['codec']}", Select)
220
+ if codec_select.value not in (Select.BLANK, Select.NULL):
221
+ self.codec = str(codec_select.value).lower()
222
+ else:
223
+ self.notify(
224
+ "❌ Please select audio codec or video container",
225
+ severity="warning",
226
+ )
227
+ return False
228
+ self.kbps = self.kbps or 256
229
+ return True
230
+
231
+
232
+ def run_tui():
233
+ app = SiphonTUI()
234
+ app.run()
File without changes
@@ -0,0 +1,89 @@
1
+ import json
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from color_kiss.utils import error, success
6
+ from platformdirs import user_config_dir
7
+
8
+ # Cross-platform paths:
9
+ # Windows: %APPDATA%/Siphon/
10
+ # macOS: ~/Library/Application Support/Siphon/
11
+ # Linux: ~/.config/Siphon/
12
+ CONFIG_DIR = Path(user_config_dir("Siphon"))
13
+ CONFIG_FILE = CONFIG_DIR / "config.json"
14
+ HOME_PATH = str(Path.home())
15
+
16
+ KEY_NAME = "path"
17
+
18
+
19
+ def _ensure_config_dir() -> None:
20
+ """Create config directory if it doesn't exist."""
21
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
22
+
23
+
24
+ def _load_config() -> dict:
25
+ """Load config file or return empty dict if not exists."""
26
+ if not CONFIG_FILE.exists():
27
+ return {}
28
+
29
+ try:
30
+ return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
31
+ except (json.JSONDecodeError, FileNotFoundError):
32
+ error("Config file is corrupted. Creating new one...")
33
+ return {}
34
+
35
+
36
+ def _save_config(data: dict) -> None:
37
+ """Save config data to file."""
38
+ _ensure_config_dir()
39
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
40
+ json.dump(data, f, ensure_ascii=False, indent=4)
41
+
42
+
43
+ def set_path(path: str) -> str:
44
+ """
45
+ Manage download directory storage.
46
+ - With path: saves to config.json
47
+ - Without path: displays current config
48
+ """
49
+ try:
50
+ input_path = Path(path).expanduser().resolve()
51
+ if not input_path.is_dir():
52
+ sys.exit(error("Please enter the correct path!"))
53
+
54
+ path_str = str(input_path)
55
+
56
+ _ensure_config_dir()
57
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
58
+ json.dump({KEY_NAME: path_str}, f, ensure_ascii=False, indent=4)
59
+
60
+ return success(f"\nPath: {path_str}\nConfig file: {CONFIG_FILE}")
61
+ except PermissionError:
62
+ return error(f"\nPermission denied! Cannot write to {CONFIG_FILE}")
63
+ except OSError as e:
64
+ return error(f"\nError saving configuration: {e}")
65
+
66
+
67
+ def get_path() -> tuple[str, str | None]:
68
+ """
69
+ Read download path from config.json.
70
+ Returns (path, error_message).
71
+ If error_message is None, path is valid.
72
+ """
73
+ if not CONFIG_FILE.exists():
74
+ return HOME_PATH, None
75
+
76
+ try:
77
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
78
+ config = json.load(f)
79
+
80
+ if not config or KEY_NAME not in config:
81
+ return HOME_PATH, "Download path not set in config"
82
+
83
+ path = config[KEY_NAME]
84
+ if not Path(path).is_dir():
85
+ return HOME_PATH, f"Download path does not exist: {path}"
86
+
87
+ return path, None
88
+ except (json.JSONDecodeError, FileNotFoundError):
89
+ return HOME_PATH, "Config file is corrupted! Run: siphon-tui set /path"
@@ -0,0 +1,133 @@
1
+ """
2
+ Sync YouTube downloader with cancellation support for Rhythmer TUI.
3
+ """
4
+
5
+ import shutil
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Callable, Optional
9
+
10
+ AUDIO_CODECS = frozenset({"mp3", "aac", "flac", "m4a", "opus", "vorbis", "wav"})
11
+
12
+ VIDEO_CONTAINER_AUDIO_MAP = {
13
+ "mp4": "m4a",
14
+ "mov": "m4a",
15
+ "mkv": "opus",
16
+ "webm": "opus",
17
+ "avi": "mp3",
18
+ "flv": "aac",
19
+ }
20
+
21
+
22
+ class DownloadError(Exception):
23
+ pass
24
+
25
+
26
+ class DownloadCancelledError(DownloadError):
27
+ pass
28
+
29
+
30
+ @dataclass
31
+ class Download:
32
+ """Synchronous audio/video downloader using yt-dlp with cancellation support."""
33
+
34
+ url: str
35
+ codec: str
36
+ kbps: int
37
+ download_path: str
38
+ max_concurrent: int = 3
39
+
40
+ _cancel_callback: Optional[Callable[[], bool]] = field(default=None, repr=False)
41
+ _cancelled: bool = field(default=False, init=False, repr=False)
42
+
43
+ def __post_init__(self):
44
+ """Validate inputs on instantiation."""
45
+ self.codec = self.codec.lower()
46
+ self.is_audio = self.codec in AUDIO_CODECS
47
+
48
+ if shutil.which("ffmpeg") is None:
49
+ raise DownloadError(
50
+ "FFmpeg not found in PATH! Please install FFmpeg first."
51
+ )
52
+ if not Path(self.download_path).exists():
53
+ raise DownloadError(f"Download path does not exist: {self.download_path}")
54
+
55
+ def set_cancel_check(self, callback: Callable[[], bool]) -> None:
56
+ """Set a callback that returns True if download should be cancelled."""
57
+ self._cancel_callback = callback
58
+
59
+ def cancel(self) -> None:
60
+ """Mark the download as cancelled."""
61
+ self._cancelled = True
62
+
63
+ def _check_cancelled(self) -> bool:
64
+ """Check if download has been cancelled via callback or flag."""
65
+ if self._cancelled:
66
+ return True
67
+ if self._cancel_callback and self._cancel_callback():
68
+ self._cancelled = True
69
+ return True
70
+ return False
71
+
72
+ def _get_opts(self) -> dict:
73
+ """Build yt-dlp options dict based on codec type (audio or video)."""
74
+ base_opts = {
75
+ "quiet": True,
76
+ "no_warnings": True,
77
+ "nooverwrites": True,
78
+ "outtmpl": str(Path(self.download_path) / "%(title)s.%(ext)s"),
79
+ "concurrent_fragment_downloads": self.max_concurrent,
80
+ }
81
+
82
+ if self.is_audio:
83
+ base_opts["format"] = "bestaudio/best"
84
+ base_opts["postprocessors"] = [
85
+ {
86
+ "key": "FFmpegExtractAudio",
87
+ "preferredcodec": self.codec,
88
+ "preferredquality": str(self.kbps),
89
+ },
90
+ {"key": "FFmpegMetadata"},
91
+ {"key": "EmbedThumbnail"},
92
+ ]
93
+
94
+ if self.codec == "wav":
95
+ base_opts["postprocessors"] = [
96
+ p
97
+ for p in base_opts["postprocessors"]
98
+ if p["key"] not in ["FFmpegMetadata", "EmbedThumbnail"]
99
+ ]
100
+ elif self.codec in {"m4a", "aac"}:
101
+ base_opts["format"] = (
102
+ "bestaudio[ext=m4a]/bestaudio[ext=aac]/bestaudio/best"
103
+ )
104
+ base_opts["postprocessors"] = [
105
+ p
106
+ for p in base_opts["postprocessors"]
107
+ if p["key"] != "FFmpegExtractAudio"
108
+ ]
109
+ else:
110
+ audio_ext = VIDEO_CONTAINER_AUDIO_MAP.get(self.codec, "m4a")
111
+ format_str = (
112
+ f"bestvideo[ext=mp4]+bestaudio[ext={audio_ext}]/bestvideo+bestaudio/best"
113
+ if self.codec == "mp4"
114
+ else f"bestvideo+bestaudio[ext={audio_ext}]/bestvideo+bestaudio/best"
115
+ )
116
+ base_opts.update(format=format_str, merge_output_format=self.codec)
117
+
118
+ return base_opts
119
+
120
+ def download(self) -> None:
121
+ """Download a single URL synchronously. Raises DownloadError or DownloadCancelledError."""
122
+ if self._check_cancelled():
123
+ raise DownloadCancelledError("Download was cancelled before starting")
124
+
125
+ try:
126
+ from yt_dlp import YoutubeDL
127
+
128
+ with YoutubeDL(self._get_opts()) as ydl:
129
+ ydl.download([self.url])
130
+ except Exception as e:
131
+ if self._cancelled:
132
+ raise DownloadCancelledError("Download was cancelled") from e
133
+ raise DownloadError(f"Download failed: {e}") from e
@@ -0,0 +1,192 @@
1
+ Metadata-Version: 2.4
2
+ Name: Siphon-TUI
3
+ Version: 2.1.0
4
+ Summary: Siphon-TUI is a TUI audio/video downloader based on yt-dlp
5
+ Project-URL: Homepage, https://github.com/Fkernel653/Siphon-TUI
6
+ Project-URL: Repository, https://github.com/Fkernel653/Siphon-TUI.git
7
+ Project-URL: Documentation, https://github.com/Fkernel653/Siphon-TUI#readme
8
+ Keywords: yt-dlp,downloader,tui,terminal,textual,video,audio,youtube,cli,multimedia
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: End Users/Desktop
11
+ Classifier: Natural Language :: English
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Multimedia :: Sound/Audio
20
+ Classifier: Topic :: Multimedia :: Video
21
+ Classifier: Topic :: Internet :: WWW/HTTP
22
+ Classifier: Topic :: Terminals
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: yt-dlp
28
+ Requires-Dist: textual
29
+ Requires-Dist: mutagen
30
+ Requires-Dist: color-kiss
31
+ Requires-Dist: cliss
32
+ Requires-Dist: platformdirs
33
+ Provides-Extra: dev
34
+ Requires-Dist: ruff; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # Siphon-TUI — Download audio/video from YouTube, SoundCloud, and 1000+ sites via interactive terminal UI
38
+
39
+ [![Python](https://img.shields.io/badge/python-3.10+-blue.svg)](https://python.org)
40
+ [![PyPI](https://img.shields.io/pypi/v/siphon-tui.svg)](https://pypi.org/project/siphon-tui/)
41
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
42
+ [![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macOS%20%7C%20windows-lightgrey)]()
43
+ [![TUI](https://img.shields.io/badge/TUI-textual-purple.svg)](https://github.com/Textualize/textual)
44
+ [![Ruff](https://img.shields.io/badge/code%20style-ruff-261230?logo=ruff&logoColor=white)](https://docs.astral.sh/ruff/)
45
+
46
+ Download and tag high-quality music and video from YouTube, YouTube Music, SoundCloud, and 1000+ sites — all from an interactive terminal UI.
47
+
48
+ ![Screenshot](screenshot.png)
49
+
50
+ ## ✨ Features
51
+
52
+ - **Interactive TUI** — Dropdown selectors, real-time notifications, cancel support
53
+ - **1000+ Supported Sites** — Any site yt-dlp supports
54
+ - **Audio/Video Formats** — MP3, AAC, FLAC, M4A, Opus, Vorbis, WAV, MP4, MKV, WebM, and more with configurable bitrate (64–320 kbps)
55
+ - **Smart Codec Mapping** — Automatically pairs containers with optimal audio codecs (e.g., MP4→AAC, MKV→Opus)
56
+ - **Metadata Embedding** — Title, artist, album tags + cover art thumbnails
57
+ - **Thread-safe** — Responsive UI during downloads with background processing
58
+ - **Cross-platform Config** — XDG-compliant (Linux), Application Support (macOS), AppData (Windows)
59
+
60
+ ## 🚀 Quick Start
61
+
62
+ ### Prerequisites
63
+ - Python 3.10+ & FFmpeg
64
+
65
+ ### Installation
66
+ ```bash
67
+ pip install siphon-tui # pip
68
+ uv pip install siphon-tui # uv
69
+ pipx install siphon-tui # pipx
70
+ ```
71
+
72
+ ### Usage
73
+ ```bash
74
+ siphon-tui config ~/Downloads # Set download directory (optional)
75
+ siphon-tui # Launch TUI (no arguments)
76
+ ```
77
+
78
+ If you skip `config`, files will be saved to `~/Downloads` (or platform equivalent).
79
+
80
+ ## ⌨️ Controls
81
+
82
+ | Key | Action |
83
+ |-----|--------|
84
+ | `Tab` | Navigate between fields |
85
+ | `↑`/`↓` | Navigate dropdown options |
86
+ | `Enter` | Confirm selection / Start download |
87
+ | `Esc` | Close dropdown |
88
+ | `Ctrl+C` | Exit application |
89
+
90
+ ## 📋 Interface Elements
91
+
92
+ ### Input Fields
93
+ | Field | Description |
94
+ |-------|-------------|
95
+ | **URL Input** | Paste video/audio URL from any supported platform |
96
+ | **Audio Codec** | Select audio format: MP3, AAC, FLAC, M4A, Opus, Vorbis, WAV |
97
+ | **Container** | Optional video container: MP4, MKV, WebM, MOV, AVI, FLV |
98
+ | **Bitrate** | Audio quality: 64, 128, 256, 320 kbps |
99
+
100
+ ### Buttons
101
+ | Button | Action |
102
+ |--------|--------|
103
+ | **Download** | Start download with selected settings |
104
+ | **Cancel** | Cancel ongoing download |
105
+
106
+ ### Smart Codec Mapping
107
+ When a video container is selected, the optimal audio codec is automatically set:
108
+
109
+ | Container | Auto Audio Codec |
110
+ |-----------|:----------------:|
111
+ | MP4, MOV | AAC |
112
+ | MKV, WebM | Opus |
113
+ | AVI | MP3 |
114
+ | FLV | AAC |
115
+
116
+ ## 📖 Examples
117
+
118
+ ```bash
119
+ # Audio download
120
+ siphon-tui
121
+ # → Paste URL → Select "mp3" → Select "320" kbps → Press Download
122
+
123
+ # Video download
124
+ siphon-tui
125
+ # → Paste URL → Select Container "mp4" → Bitrate auto-sets → Press Download
126
+
127
+ # Cancel download
128
+ # Press "Cancel" button during active download
129
+ ```
130
+
131
+ ## 📁 Project Structure
132
+ ```
133
+ siphon_tui/
134
+ ├── __init__.py
135
+ ├── main.py # Entry point & CLI/TUI routing
136
+ ├── cli.py # CLI interface (cliss)
137
+ ├── tui/
138
+ │ ├── app.py # Textual TUI application
139
+ │ └── style.tcss # TUI theme & layout
140
+ └── utils/
141
+ ├── configer.py # JSON config manager
142
+ └── download.py # Download engine (yt-dlp + mutagen)
143
+ ```
144
+
145
+ ## ⚙️ Configuration
146
+
147
+ The download path is stored in a JSON config file and can be set via CLI:
148
+
149
+ ```bash
150
+ siphon-tui config ~/Music # Set directory
151
+ siphon-tui config # View current path (if implemented)
152
+ ```
153
+
154
+ Config locations (auto-managed):
155
+ - **Linux:** `~/.config/siphon-tui/config.json`
156
+ - **macOS:** `~/Library/Application Support/siphon-tui/config.json`
157
+ - **Windows:** `%APPDATA%\siphon-tui\config.json`
158
+
159
+ ## 🔧 Requirements
160
+
161
+ | Dependency | Purpose |
162
+ |------------|---------|
163
+ | `textual` | TUI framework for interactive terminal apps |
164
+ | `yt-dlp` | Media extraction from 1000+ platforms |
165
+ | `mutagen` | Audio metadata tagging and cover art embedding |
166
+ | `platformdirs` | Cross-platform config paths |
167
+ | `color-kiss` | Terminal colors |
168
+ | `cliss` | CLI framework |
169
+ | **FFmpeg** | Audio/video conversion (system) |
170
+
171
+ ## 📄 License
172
+
173
+ MIT License — see [LICENSE](LICENSE) file.
174
+
175
+ ## 🙏 Acknowledgments
176
+
177
+ - [Textual](https://github.com/Textualize/textual) – Modern TUI framework
178
+ - [yt-dlp](https://github.com/yt-dlp/yt-dlp) – Download engine
179
+ - [mutagen](https://github.com/quodlibet/mutagen) – Metadata tagging
180
+ - [platformdirs](https://github.com/platformdirs/platformdirs) – Config paths
181
+ - [color-kiss](https://github.com/Fkernel653/color-kiss) – Terminal colors
182
+ - [cliss](https://github.com/Fkernel653/cliss) – CLI framework
183
+
184
+ ## ⚠️ Disclaimer
185
+
186
+ **For educational purposes only.** Users are responsible for complying with platform Terms of Service and applicable copyright laws. Download only content you have permission to download.
187
+
188
+ ---
189
+
190
+ **Author:** [Fkernel653](https://github.com/Fkernel653)
191
+ **Repository:** [github.com/Fkernel653/Siphon-TUI](https://github.com/Fkernel653/Siphon-TUI)
192
+ **PyPI:** [pypi.org/project/siphon-tui](https://pypi.org/project/siphon-tui/)
@@ -0,0 +1,14 @@
1
+ siphon_tui/__init__.py,sha256=7lm2OXLtvKW2f5Yr4n0s03LJDfIdiS9On91zwhwWIt8,145
2
+ siphon_tui/__main__.py,sha256=Vdhw8YA1K3wPMlbJQYL5WqvRzAKVeZ16mZQFO9VRmCo,62
3
+ siphon_tui/cli.py,sha256=VFodQQTrxtmYa8EAlZqkLldhgQWo1xrsiaGLL_PNlJM,379
4
+ siphon_tui/main.py,sha256=OUu7sICGZrurnIH7y-U7VsdkZVilhaOwn_W0xba-WPk,627
5
+ siphon_tui/tui/app.py,sha256=p39TJ6H3uYrlhDnvylaBxlRGNk4mCn93C7WZ2S-wcEc,8109
6
+ siphon_tui/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ siphon_tui/utils/configer.py,sha256=1B4RbMzxognuuY7hgligKsOauQ-E0nwwZXlmRNUaXE0,2673
8
+ siphon_tui/utils/download.py,sha256=FpDE8JCB3wFAf494pwXNg7k8S-b7TOFIFSNczB9FBxw,4448
9
+ siphon_tui-2.1.0.dist-info/licenses/LICENSE,sha256=W8pkVWBfRl4j60erOAE3PfKcLjNEc9V6OPbhxmf3O3k,1063
10
+ siphon_tui-2.1.0.dist-info/METADATA,sha256=p-dNjtDo3TBCQLDHqjvpyE5-1rygMJQQDIKffLaz2XY,6958
11
+ siphon_tui-2.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ siphon_tui-2.1.0.dist-info/entry_points.txt,sha256=d3D9zZDeTZr1RuLNishvByE1PliXCKKYniTNpJusf9w,52
13
+ siphon_tui-2.1.0.dist-info/top_level.txt,sha256=GV2hfTPp4C3fXPMm_DHPJa5r1rLLpMkHZ0fUEbWMS5o,11
14
+ siphon_tui-2.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ siphon-tui = siphon_tui.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kernel
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.
@@ -0,0 +1 @@
1
+ siphon_tui