tugyt 0.2.3__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.
- tugyt-0.2.3/LICENSE +21 -0
- tugyt-0.2.3/PKG-INFO +34 -0
- tugyt-0.2.3/README.md +21 -0
- tugyt-0.2.3/pyproject.toml +26 -0
- tugyt-0.2.3/setup.cfg +4 -0
- tugyt-0.2.3/setup.py +4 -0
- tugyt-0.2.3/tests/test_tugyt.py +267 -0
- tugyt-0.2.3/tugyt/__init__.py +5 -0
- tugyt-0.2.3/tugyt/__main__.py +3 -0
- tugyt-0.2.3/tugyt/tugyt.py +282 -0
- tugyt-0.2.3/tugyt.egg-info/PKG-INFO +34 -0
- tugyt-0.2.3/tugyt.egg-info/SOURCES.txt +14 -0
- tugyt-0.2.3/tugyt.egg-info/dependency_links.txt +1 -0
- tugyt-0.2.3/tugyt.egg-info/entry_points.txt +2 -0
- tugyt-0.2.3/tugyt.egg-info/requires.txt +2 -0
- tugyt-0.2.3/tugyt.egg-info/top_level.txt +1 -0
tugyt-0.2.3/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ftnick
|
|
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.
|
tugyt-0.2.3/PKG-INFO
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tugyt
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: A focused YouTube downloader powered by yt-dlp.
|
|
5
|
+
Author: ftnick
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: yt-dlp==2026.8.19
|
|
11
|
+
Requires-Dist: rich==15.0.0
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<img src="icon.ico" width="160">
|
|
16
|
+
</p>
|
|
17
|
+
<p align="center">
|
|
18
|
+
<sub>Fetch (icon) by <a href="https://thenounproject.com/browse/icons/term/fetch/" target="_blank" title="highlight Icons">Gregor Cresnar</a> from <a href="https://thenounproject.com/browse/icons/term/fetch/" target="_blank" title="highlight Icons">Noun Project</a> (CC BY 3.0)</sub>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
<h1 align="center">tugyt</h1>
|
|
22
|
+
|
|
23
|
+
<p align="center">
|
|
24
|
+
is a lightweight command-line YouTube downloader powered by <a href="https://github.com/yt-dlp/yt-dlp" target="_blank">yt-dlp</a>, with support for videos, playlists, audio extraction, subtitles, metadata, and download archives.
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<a href="https://github.com/ftnick/tugyt/actions/workflows/python-package.yml"><img src="https://github.com/ftnick/tugyt/actions/workflows/python-package.yml/badge.svg" alt="CI"></a>
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
<p align="center">
|
|
32
|
+
<a href="https://github.com/ftnick/tugyt/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ftnick/tugyt" alt="License"></a>
|
|
33
|
+
<a href="https://github.com/ftnick/tugyt/commits/main"><img src="https://img.shields.io/github/last-commit/ftnick/tugyt" alt="Last commit"></a>
|
|
34
|
+
</p>
|
tugyt-0.2.3/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="icon.ico" width="160">
|
|
3
|
+
</p>
|
|
4
|
+
<p align="center">
|
|
5
|
+
<sub>Fetch (icon) by <a href="https://thenounproject.com/browse/icons/term/fetch/" target="_blank" title="highlight Icons">Gregor Cresnar</a> from <a href="https://thenounproject.com/browse/icons/term/fetch/" target="_blank" title="highlight Icons">Noun Project</a> (CC BY 3.0)</sub>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<h1 align="center">tugyt</h1>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
is a lightweight command-line YouTube downloader powered by <a href="https://github.com/yt-dlp/yt-dlp" target="_blank">yt-dlp</a>, with support for videos, playlists, audio extraction, subtitles, metadata, and download archives.
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://github.com/ftnick/tugyt/actions/workflows/python-package.yml"><img src="https://github.com/ftnick/tugyt/actions/workflows/python-package.yml/badge.svg" alt="CI"></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<a href="https://github.com/ftnick/tugyt/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ftnick/tugyt" alt="License"></a>
|
|
20
|
+
<a href="https://github.com/ftnick/tugyt/commits/main"><img src="https://img.shields.io/github/last-commit/ftnick/tugyt" alt="Last commit"></a>
|
|
21
|
+
</p>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools==84.0.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tugyt"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "A focused YouTube downloader powered by yt-dlp."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "ftnick" }]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"yt-dlp==2026.8.19",
|
|
16
|
+
"rich==15.0.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
tugyt = "tugyt.tugyt:cli"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
include = ["tugyt*"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.dynamic]
|
|
26
|
+
version = { attr = "tugyt.tugyt.__version__" }
|
tugyt-0.2.3/setup.cfg
ADDED
tugyt-0.2.3/setup.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
import tugyt.tugyt as app
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeProgress:
|
|
9
|
+
entered = 0
|
|
10
|
+
|
|
11
|
+
def __enter__(self):
|
|
12
|
+
self.__class__.entered += 1
|
|
13
|
+
return self
|
|
14
|
+
|
|
15
|
+
def __exit__(self, exception_type, exception, traceback):
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
def update(self, status):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FakeDownloader:
|
|
23
|
+
instances = []
|
|
24
|
+
result = 0
|
|
25
|
+
error: Optional[type[Exception]] = None
|
|
26
|
+
|
|
27
|
+
def __init__(self, options):
|
|
28
|
+
self.options = options
|
|
29
|
+
self.__class__.instances.append(self)
|
|
30
|
+
|
|
31
|
+
def __enter__(self):
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
def __exit__(self, exception_type, exception, traceback):
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
def download(self, urls):
|
|
38
|
+
if self.error:
|
|
39
|
+
raise self.error("download failed")
|
|
40
|
+
self.urls = urls
|
|
41
|
+
return self.result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FakeYtdlp:
|
|
45
|
+
class utils:
|
|
46
|
+
class DownloadError(Exception):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
YoutubeDL = FakeDownloader
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture(autouse=True)
|
|
53
|
+
def reset_fake_downloader(monkeypatch):
|
|
54
|
+
FakeDownloader.instances = []
|
|
55
|
+
FakeDownloader.result = 0
|
|
56
|
+
FakeDownloader.error = None
|
|
57
|
+
FakeProgress.entered = 0
|
|
58
|
+
monkeypatch.setattr(app, "yt_dlp", FakeYtdlp)
|
|
59
|
+
monkeypatch.setattr(app, "DownloadProgress", FakeProgress)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_parser_defaults_and_input():
|
|
63
|
+
args = app.create_parser().parse_args(["https://www.youtube.com/watch?v=example"])
|
|
64
|
+
|
|
65
|
+
assert args.input == ["https://www.youtube.com/watch?v=example"]
|
|
66
|
+
assert args.output == app.DEFAULT_OUTPUT
|
|
67
|
+
assert args.format == "bv*+ba/b"
|
|
68
|
+
assert args.retries == 10
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_read_inputs_expands_files_and_preserves_order(tmp_path):
|
|
72
|
+
input_file = tmp_path / "urls.txt"
|
|
73
|
+
input_file.write_text(
|
|
74
|
+
"# ignored\nhttps://example.test/one\n\n https://example.test/two \n",
|
|
75
|
+
encoding="utf-8",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
assert app._read_inputs(["https://example.test/direct", str(input_file)]) == [
|
|
79
|
+
"https://example.test/direct",
|
|
80
|
+
"https://example.test/one",
|
|
81
|
+
"https://example.test/two",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_normalize_subtitle_languages_expands_all_alias():
|
|
86
|
+
assert app._normalize_subtitle_languages("en.*,all,ja") == ["en.*", ".*", "ja"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_build_options_translates_all_optional_flags():
|
|
90
|
+
args = app.create_parser().parse_args([
|
|
91
|
+
"https://www.youtube.com/watch?v=example",
|
|
92
|
+
"--quiet",
|
|
93
|
+
"--verbose",
|
|
94
|
+
"--keep-video",
|
|
95
|
+
"--ignore-errors",
|
|
96
|
+
"--no-continue",
|
|
97
|
+
"--output",
|
|
98
|
+
"%(id)s.%(ext)s",
|
|
99
|
+
"--format",
|
|
100
|
+
"best",
|
|
101
|
+
"--extract-audio",
|
|
102
|
+
"--audio-format",
|
|
103
|
+
"opus",
|
|
104
|
+
"--audio-quality",
|
|
105
|
+
"128K",
|
|
106
|
+
"--list-formats",
|
|
107
|
+
"--dump-json",
|
|
108
|
+
"--skip-download",
|
|
109
|
+
"--write-thumbnail",
|
|
110
|
+
"--write-info-json",
|
|
111
|
+
"--write-description",
|
|
112
|
+
"--write-subs",
|
|
113
|
+
"--write-auto-subs",
|
|
114
|
+
"--embed-metadata",
|
|
115
|
+
"--embed-thumbnail",
|
|
116
|
+
"--embed-subs",
|
|
117
|
+
"--no-playlist",
|
|
118
|
+
"--flat-playlist",
|
|
119
|
+
"--playlist-start",
|
|
120
|
+
"2",
|
|
121
|
+
"--playlist-end",
|
|
122
|
+
"8",
|
|
123
|
+
"--playlist-items",
|
|
124
|
+
"2,4-6",
|
|
125
|
+
"--download-archive",
|
|
126
|
+
"archive.txt",
|
|
127
|
+
"--sponsorblock-remove",
|
|
128
|
+
"sponsor,intro",
|
|
129
|
+
"--proxy",
|
|
130
|
+
"http://proxy.test:8080",
|
|
131
|
+
"--user-agent",
|
|
132
|
+
"test-agent",
|
|
133
|
+
"--concurrent-fragments",
|
|
134
|
+
"4",
|
|
135
|
+
"--retries",
|
|
136
|
+
"3",
|
|
137
|
+
"--rate-limit",
|
|
138
|
+
"1000",
|
|
139
|
+
"--sub-langs",
|
|
140
|
+
"en,all",
|
|
141
|
+
"--sub-format",
|
|
142
|
+
"vtt",
|
|
143
|
+
])
|
|
144
|
+
|
|
145
|
+
options = app.build_options(args)
|
|
146
|
+
|
|
147
|
+
assert options["format"] == "best"
|
|
148
|
+
assert options["outtmpl"] == "%(id)s.%(ext)s"
|
|
149
|
+
assert options["quiet"] is True
|
|
150
|
+
assert options["no_warnings"] is True
|
|
151
|
+
assert options["continuedl"] is False
|
|
152
|
+
assert options["concurrent_fragment_downloads"] == 4
|
|
153
|
+
assert options["subtitleslangs"] == ["en", ".*"]
|
|
154
|
+
assert options["subtitlesformat"] == "vtt"
|
|
155
|
+
assert options["postprocessors"] == [{
|
|
156
|
+
"key": "FFmpegExtractAudio",
|
|
157
|
+
"preferredcodec": "opus",
|
|
158
|
+
"preferredquality": "128K",
|
|
159
|
+
}]
|
|
160
|
+
assert options["http_headers"] == {"User-Agent": "test-agent"}
|
|
161
|
+
assert options["proxy"] == "http://proxy.test:8080"
|
|
162
|
+
assert options["verbose"] is True
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_main_downloads_expanded_inputs_and_adds_progress_hook(tmp_path):
|
|
166
|
+
input_file = tmp_path / "urls.txt"
|
|
167
|
+
input_file.write_text("https://example.test/video\n", encoding="utf-8")
|
|
168
|
+
args = app.create_parser().parse_args([str(input_file)])
|
|
169
|
+
FakeDownloader.result = 7
|
|
170
|
+
|
|
171
|
+
assert app.main(args) == 7
|
|
172
|
+
|
|
173
|
+
downloader = FakeDownloader.instances[0]
|
|
174
|
+
assert downloader.urls == ["https://example.test/video"]
|
|
175
|
+
assert downloader.options["noprogress"] is True
|
|
176
|
+
assert len(downloader.options["progress_hooks"]) == 1
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_main_quiet_mode_does_not_register_progress_hook():
|
|
180
|
+
args = app.create_parser().parse_args(["https://example.test/video", "--quiet"])
|
|
181
|
+
|
|
182
|
+
assert app.main(args) == 0
|
|
183
|
+
|
|
184
|
+
options = FakeDownloader.instances[0].options
|
|
185
|
+
assert FakeProgress.entered == 0
|
|
186
|
+
assert "noprogress" not in options
|
|
187
|
+
assert "progress_hooks" not in options
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_main_parses_fresh_arguments_for_each_call(monkeypatch):
|
|
191
|
+
first_args = app.create_parser().parse_args(["https://example.test/first"])
|
|
192
|
+
second_args = app.create_parser().parse_args(["https://example.test/second"])
|
|
193
|
+
parsed_args = iter([first_args, second_args])
|
|
194
|
+
monkeypatch.setattr(app.cmdl_parser, "parse_args", lambda: next(parsed_args))
|
|
195
|
+
|
|
196
|
+
assert app.main() == 0
|
|
197
|
+
assert app.main() == 0
|
|
198
|
+
|
|
199
|
+
assert [instance.urls for instance in FakeDownloader.instances] == [
|
|
200
|
+
["https://example.test/first"],
|
|
201
|
+
["https://example.test/second"],
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_main_translates_downloader_error():
|
|
206
|
+
FakeDownloader.error = FakeYtdlp.utils.DownloadError
|
|
207
|
+
args = app.create_parser().parse_args(["https://example.test/video"])
|
|
208
|
+
|
|
209
|
+
with pytest.raises(app.DownloadError, match="download failed"):
|
|
210
|
+
app.main(args)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_main_rejects_missing_dependency(monkeypatch):
|
|
214
|
+
monkeypatch.setattr(app, "yt_dlp", None)
|
|
215
|
+
args = app.create_parser().parse_args(["https://example.test/video"])
|
|
216
|
+
|
|
217
|
+
with pytest.raises(app.DownloadError, match="yt-dlp is not installed"):
|
|
218
|
+
app.main(args)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_main_rejects_input_file_without_urls(tmp_path):
|
|
222
|
+
input_file = tmp_path / "empty.txt"
|
|
223
|
+
input_file.write_text("# no URLs\n\n", encoding="utf-8")
|
|
224
|
+
args = app.create_parser().parse_args([str(input_file)])
|
|
225
|
+
|
|
226
|
+
with pytest.raises(app.DownloadError, match="No URLs were found"):
|
|
227
|
+
app.main(args)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_execute_passes_parsed_arguments_to_main(monkeypatch):
|
|
231
|
+
captured = {}
|
|
232
|
+
|
|
233
|
+
def fake_main(args):
|
|
234
|
+
captured["args"] = args
|
|
235
|
+
return 4
|
|
236
|
+
|
|
237
|
+
monkeypatch.setattr(app, "main", fake_main)
|
|
238
|
+
|
|
239
|
+
assert app.execute("https://example.test/video", "--quiet") == 4
|
|
240
|
+
assert captured["args"].input == ["https://example.test/video"]
|
|
241
|
+
assert captured["args"].quiet is True
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_logger_respects_quiet_mode(capsys):
|
|
245
|
+
logger = app.YtdlpLogger(quiet=True)
|
|
246
|
+
|
|
247
|
+
logger.debug("ordinary message")
|
|
248
|
+
logger.info("info message")
|
|
249
|
+
logger.warning("warning message")
|
|
250
|
+
logger.error("error message")
|
|
251
|
+
|
|
252
|
+
output = capsys.readouterr()
|
|
253
|
+
assert output.out == ""
|
|
254
|
+
assert output.err == "WARNING: warning message\nERROR: error message\n"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_cli_reports_download_errors(monkeypatch, capsys):
|
|
258
|
+
def fail_main():
|
|
259
|
+
raise app.DownloadError("bad input")
|
|
260
|
+
|
|
261
|
+
monkeypatch.setattr(app, "main", fail_main)
|
|
262
|
+
|
|
263
|
+
with pytest.raises(SystemExit) as error:
|
|
264
|
+
app.cli()
|
|
265
|
+
|
|
266
|
+
assert error.value.code == 1
|
|
267
|
+
assert capsys.readouterr().err.endswith("tugyt: bad input\n")
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Command-line YouTube downloader powered by yt-dlp."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from contextlib import nullcontext
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Iterable, List, Optional
|
|
9
|
+
|
|
10
|
+
from rich.progress import (
|
|
11
|
+
BarColumn,
|
|
12
|
+
DownloadColumn,
|
|
13
|
+
Progress,
|
|
14
|
+
TaskProgressColumn,
|
|
15
|
+
TextColumn,
|
|
16
|
+
TimeRemainingColumn,
|
|
17
|
+
TransferSpeedColumn,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_YT_DLP_IMPORT_ERROR: Optional[ImportError] = None
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import yt_dlp
|
|
24
|
+
except ImportError as error: # pragma: no cover
|
|
25
|
+
yt_dlp = None
|
|
26
|
+
_YT_DLP_IMPORT_ERROR = error
|
|
27
|
+
|
|
28
|
+
__version__ = "0.2.3"
|
|
29
|
+
__author__ = "ftnick"
|
|
30
|
+
__license__ = "MIT"
|
|
31
|
+
|
|
32
|
+
MODULE_NAME = "tugyt"
|
|
33
|
+
DEFAULT_OUTPUT = "%(title)s [%(id)s].%(ext)s"
|
|
34
|
+
logger = logging.getLogger(MODULE_NAME)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DownloadError(Exception):
|
|
38
|
+
"""Raised when yt-dlp cannot complete a requested operation."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class YtdlpLogger:
|
|
42
|
+
"""Bridge yt-dlp messages into the standard logger and CLI output."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, quiet: bool = False):
|
|
45
|
+
self.quiet = quiet
|
|
46
|
+
|
|
47
|
+
def debug(self, message: str) -> None:
|
|
48
|
+
if message.startswith("[debug]"):
|
|
49
|
+
logger.debug(message)
|
|
50
|
+
elif not self.quiet:
|
|
51
|
+
print(message)
|
|
52
|
+
|
|
53
|
+
def info(self, message: str) -> None:
|
|
54
|
+
if not self.quiet:
|
|
55
|
+
print(message)
|
|
56
|
+
|
|
57
|
+
def warning(self, message: str) -> None:
|
|
58
|
+
print(f"WARNING: {message}", file=sys.stderr)
|
|
59
|
+
|
|
60
|
+
def error(self, message: str) -> None:
|
|
61
|
+
print(f"ERROR: {message}", file=sys.stderr)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DownloadProgress:
|
|
65
|
+
"""Render yt-dlp download events as a single Rich progress display."""
|
|
66
|
+
|
|
67
|
+
def __init__(self):
|
|
68
|
+
self.progress = Progress(
|
|
69
|
+
TextColumn("[progress.description]{task.description}"),
|
|
70
|
+
BarColumn(),
|
|
71
|
+
TaskProgressColumn(),
|
|
72
|
+
DownloadColumn(),
|
|
73
|
+
TransferSpeedColumn(),
|
|
74
|
+
TimeRemainingColumn(),
|
|
75
|
+
)
|
|
76
|
+
self.tasks = {}
|
|
77
|
+
|
|
78
|
+
def __enter__(self):
|
|
79
|
+
self.progress.start()
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def __exit__(self, exception_type, exception, traceback):
|
|
83
|
+
self.progress.stop()
|
|
84
|
+
|
|
85
|
+
def update(self, status: dict) -> None:
|
|
86
|
+
if status["status"] == "downloading":
|
|
87
|
+
filename = Path(status.get("filename", "download")).name
|
|
88
|
+
task_id = self.tasks.get(filename)
|
|
89
|
+
total = status.get("total_bytes") or status.get("total_bytes_estimate")
|
|
90
|
+
if task_id is None:
|
|
91
|
+
task_id = self.progress.add_task(filename, total=total)
|
|
92
|
+
self.tasks[filename] = task_id
|
|
93
|
+
self.progress.update(
|
|
94
|
+
task_id,
|
|
95
|
+
total=total,
|
|
96
|
+
completed=status.get("downloaded_bytes", 0),
|
|
97
|
+
)
|
|
98
|
+
elif status["status"] == "finished":
|
|
99
|
+
filename = Path(status.get("filename", "download")).name
|
|
100
|
+
task_id = self.tasks.get(filename)
|
|
101
|
+
if task_id is not None:
|
|
102
|
+
self.progress.update(task_id, completed=status.get("total_bytes"))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _read_inputs(inputs: Iterable[str]) -> List[str]:
|
|
106
|
+
"""Expand text files into URLs while preserving command-line order."""
|
|
107
|
+
expanded = []
|
|
108
|
+
for value in inputs:
|
|
109
|
+
path = Path(value).expanduser()
|
|
110
|
+
if path.is_file():
|
|
111
|
+
with path.open("r", encoding="utf-8") as input_file:
|
|
112
|
+
expanded.extend(
|
|
113
|
+
line.strip() for line in input_file
|
|
114
|
+
if line.strip() and not line.lstrip().startswith("#")
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
expanded.append(value)
|
|
118
|
+
return expanded
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _add_optional(options: dict, key: str, value: Any) -> None:
|
|
122
|
+
if value is not None:
|
|
123
|
+
options[key] = value
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _normalize_subtitle_languages(value: str) -> List[str]:
|
|
127
|
+
"""Convert yt-dlp's CLI all-languages alias to its API regex form."""
|
|
128
|
+
return [".*" if language == "all" else language for language in value.split(",")]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_options(args: argparse.Namespace) -> dict:
|
|
132
|
+
"""Translate CLI arguments into yt-dlp options."""
|
|
133
|
+
options = {
|
|
134
|
+
"format": args.format,
|
|
135
|
+
"outtmpl": args.output,
|
|
136
|
+
"noplaylist": args.no_playlist,
|
|
137
|
+
"quiet": args.quiet,
|
|
138
|
+
"no_warnings": args.quiet,
|
|
139
|
+
"ignoreerrors": args.ignore_errors,
|
|
140
|
+
"continuedl": not args.no_continue,
|
|
141
|
+
"retries": args.retries,
|
|
142
|
+
"fragment_retries": args.retries,
|
|
143
|
+
"concurrent_fragment_downloads": args.concurrent_fragments,
|
|
144
|
+
"logger": YtdlpLogger(args.quiet),
|
|
145
|
+
"writethumbnail": args.write_thumbnail,
|
|
146
|
+
"writeinfojson": args.write_info_json,
|
|
147
|
+
"writedescription": args.write_description,
|
|
148
|
+
"writesubtitles": args.write_subs,
|
|
149
|
+
"writeautomaticsub": args.write_auto_subs,
|
|
150
|
+
"embedmetadata": args.embed_metadata,
|
|
151
|
+
"embedthumbnail": args.embed_thumbnail,
|
|
152
|
+
"embedsubtitles": args.embed_subs,
|
|
153
|
+
"listformats": args.list_formats,
|
|
154
|
+
"dump_single_json": args.dump_json,
|
|
155
|
+
"skip_download": args.skip_download,
|
|
156
|
+
"playliststart": args.playlist_start,
|
|
157
|
+
"playlistend": args.playlist_end,
|
|
158
|
+
"playlist_items": args.playlist_items,
|
|
159
|
+
"flat_playlist": args.flat_playlist,
|
|
160
|
+
"keepvideo": args.keep_video,
|
|
161
|
+
}
|
|
162
|
+
_add_optional(options, "proxy", args.proxy)
|
|
163
|
+
_add_optional(options, "ratelimit", args.rate_limit)
|
|
164
|
+
_add_optional(options, "download_archive", args.download_archive)
|
|
165
|
+
_add_optional(options, "subtitleslangs", _normalize_subtitle_languages(args.sub_langs))
|
|
166
|
+
_add_optional(options, "subtitlesformat", args.sub_format)
|
|
167
|
+
if args.extract_audio:
|
|
168
|
+
options["postprocessors"] = [{
|
|
169
|
+
"key": "FFmpegExtractAudio",
|
|
170
|
+
"preferredcodec": args.audio_format,
|
|
171
|
+
"preferredquality": args.audio_quality,
|
|
172
|
+
}]
|
|
173
|
+
if args.sponsorblock_remove:
|
|
174
|
+
options["sponsorblock_remove"] = args.sponsorblock_remove
|
|
175
|
+
if args.user_agent:
|
|
176
|
+
options["http_headers"] = {"User-Agent": args.user_agent}
|
|
177
|
+
if args.verbose:
|
|
178
|
+
options["verbose"] = True
|
|
179
|
+
return options
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
183
|
+
parser = argparse.ArgumentParser(
|
|
184
|
+
prog=MODULE_NAME,
|
|
185
|
+
description="Download YouTube videos, playlists, channels, and audio with yt-dlp.",
|
|
186
|
+
)
|
|
187
|
+
parser.add_argument("input", nargs="+", help="YouTube URLs or text files containing URLs")
|
|
188
|
+
parser.add_argument("-v", "--version", action="version", version=__version__)
|
|
189
|
+
parser.add_argument("-q", "--quiet", action="store_true", help="suppress yt-dlp output")
|
|
190
|
+
parser.add_argument("--verbose", action="store_true", help="show yt-dlp debug output")
|
|
191
|
+
parser.add_argument("-k", "--keep-video", action="store_true",
|
|
192
|
+
help="keep the original downloaded fragments after merging")
|
|
193
|
+
parser.add_argument("--ignore-errors", action="store_true", help="continue when an item fails")
|
|
194
|
+
parser.add_argument("--no-continue", action="store_true", help="restart partial downloads")
|
|
195
|
+
parser.add_argument("-o", "--output", "--output-path", default=DEFAULT_OUTPUT,
|
|
196
|
+
help="output template (default: %%(title)s [%%(id)s].%%(ext)s)")
|
|
197
|
+
parser.add_argument("-f", "--format", default="bv*+ba/b",
|
|
198
|
+
help="yt-dlp format selector (default: bv*+ba/b)")
|
|
199
|
+
parser.add_argument("-x", "--extract-audio", action="store_true", help="extract audio with ffmpeg")
|
|
200
|
+
parser.add_argument(
|
|
201
|
+
"--audio-format",
|
|
202
|
+
choices=("best", "aac", "flac", "mp3", "m4a", "opus", "vorbis", "wav"),
|
|
203
|
+
default="mp3",
|
|
204
|
+
)
|
|
205
|
+
parser.add_argument("--audio-quality", default="192K", help="audio bitrate passed to ffmpeg")
|
|
206
|
+
parser.add_argument("-F", "--list-formats", action="store_true", help="list available formats without downloading")
|
|
207
|
+
parser.add_argument("--dump-json", action="store_true", help="print metadata as JSON")
|
|
208
|
+
parser.add_argument("--skip-download", action="store_true", help="process metadata without downloading media")
|
|
209
|
+
parser.add_argument("--write-thumbnail", action="store_true", help="save the video thumbnail")
|
|
210
|
+
parser.add_argument("--write-info-json", action="store_true", help="save metadata beside the media")
|
|
211
|
+
parser.add_argument("--write-description", action="store_true", help="save the video description")
|
|
212
|
+
parser.add_argument("--write-subs", action="store_true", help="download manually created subtitles")
|
|
213
|
+
parser.add_argument("--write-auto-subs", action="store_true", help="download automatic subtitles")
|
|
214
|
+
parser.add_argument("--sub-langs", default="en.*,ja.*,all", help="subtitle languages")
|
|
215
|
+
parser.add_argument("--sub-format", default="best", help="subtitle format")
|
|
216
|
+
parser.add_argument("--embed-metadata", action="store_true", help="embed metadata in the media")
|
|
217
|
+
parser.add_argument("--embed-thumbnail", action="store_true", help="embed the thumbnail in the media")
|
|
218
|
+
parser.add_argument("--embed-subs", action="store_true", help="embed downloaded subtitles")
|
|
219
|
+
parser.add_argument("--no-playlist", action="store_true", help="download only the supplied video")
|
|
220
|
+
parser.add_argument("--flat-playlist", action="store_true", help="do not resolve playlist entries")
|
|
221
|
+
parser.add_argument("--playlist-start", type=int, metavar="N", help="playlist item to start at")
|
|
222
|
+
parser.add_argument("--playlist-end", type=int, metavar="N", help="playlist item to stop at")
|
|
223
|
+
parser.add_argument("--playlist-items", metavar="ITEMS", help="playlist items, e.g. 1,3,5-7")
|
|
224
|
+
parser.add_argument("--download-archive", metavar="FILE", help="skip IDs already recorded in FILE")
|
|
225
|
+
parser.add_argument("--sponsorblock-remove", metavar="CATEGORIES",
|
|
226
|
+
help="remove SponsorBlock categories, e.g. sponsor,intro")
|
|
227
|
+
parser.add_argument("-y", "--proxy", help="HTTP/SOCKS proxy URL")
|
|
228
|
+
parser.add_argument("--user-agent", help="custom HTTP User-Agent")
|
|
229
|
+
parser.add_argument("--concurrent-fragments", type=int, default=1, metavar="N",
|
|
230
|
+
help="number of fragments to download concurrently")
|
|
231
|
+
parser.add_argument("--retries", type=int, default=10, metavar="N", help="retry count")
|
|
232
|
+
parser.add_argument("--rate-limit", type=int, metavar="BYTES",
|
|
233
|
+
help="maximum download rate in bytes per second")
|
|
234
|
+
return parser
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
cmdl_parser = create_parser()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def main(args: Optional[argparse.Namespace] = None) -> int:
|
|
241
|
+
"""Download all supplied URLs and return a process exit code."""
|
|
242
|
+
args = args or cmdl_parser.parse_args()
|
|
243
|
+
if yt_dlp is None:
|
|
244
|
+
raise DownloadError("yt-dlp is not installed. Run `python -m pip install yt-dlp`.") from _YT_DLP_IMPORT_ERROR
|
|
245
|
+
urls = _read_inputs(args.input)
|
|
246
|
+
if not urls:
|
|
247
|
+
raise DownloadError("No URLs were found in the supplied input.")
|
|
248
|
+
yt_dlp_module: Any = yt_dlp
|
|
249
|
+
try:
|
|
250
|
+
progress_context = nullcontext() if args.quiet else DownloadProgress()
|
|
251
|
+
with progress_context as progress:
|
|
252
|
+
options = build_options(args)
|
|
253
|
+
if not args.quiet:
|
|
254
|
+
assert progress is not None
|
|
255
|
+
options["noprogress"] = True
|
|
256
|
+
options["progress_hooks"] = [progress.update]
|
|
257
|
+
with yt_dlp_module.YoutubeDL(options) as downloader:
|
|
258
|
+
return downloader.download(urls)
|
|
259
|
+
except yt_dlp_module.utils.DownloadError as error:
|
|
260
|
+
raise DownloadError(str(error)) from error
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def execute(*args: str) -> int:
|
|
264
|
+
"""Programmatic entrypoint compatible with the original package."""
|
|
265
|
+
return main(cmdl_parser.parse_args(list(args)))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def cli() -> None:
|
|
269
|
+
"""Console-script entrypoint."""
|
|
270
|
+
logging.basicConfig(level=logging.DEBUG if "--verbose" in sys.argv else logging.INFO)
|
|
271
|
+
try:
|
|
272
|
+
raise SystemExit(main())
|
|
273
|
+
except KeyboardInterrupt:
|
|
274
|
+
print("\nInterrupted.", file=sys.stderr)
|
|
275
|
+
raise SystemExit(130)
|
|
276
|
+
except DownloadError as error:
|
|
277
|
+
print(f"tugyt: {error}", file=sys.stderr)
|
|
278
|
+
raise SystemExit(1)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == "__main__":
|
|
282
|
+
cli()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tugyt
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: A focused YouTube downloader powered by yt-dlp.
|
|
5
|
+
Author: ftnick
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: yt-dlp==2026.8.19
|
|
11
|
+
Requires-Dist: rich==15.0.0
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<img src="icon.ico" width="160">
|
|
16
|
+
</p>
|
|
17
|
+
<p align="center">
|
|
18
|
+
<sub>Fetch (icon) by <a href="https://thenounproject.com/browse/icons/term/fetch/" target="_blank" title="highlight Icons">Gregor Cresnar</a> from <a href="https://thenounproject.com/browse/icons/term/fetch/" target="_blank" title="highlight Icons">Noun Project</a> (CC BY 3.0)</sub>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
<h1 align="center">tugyt</h1>
|
|
22
|
+
|
|
23
|
+
<p align="center">
|
|
24
|
+
is a lightweight command-line YouTube downloader powered by <a href="https://github.com/yt-dlp/yt-dlp" target="_blank">yt-dlp</a>, with support for videos, playlists, audio extraction, subtitles, metadata, and download archives.
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<a href="https://github.com/ftnick/tugyt/actions/workflows/python-package.yml"><img src="https://github.com/ftnick/tugyt/actions/workflows/python-package.yml/badge.svg" alt="CI"></a>
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
<p align="center">
|
|
32
|
+
<a href="https://github.com/ftnick/tugyt/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ftnick/tugyt" alt="License"></a>
|
|
33
|
+
<a href="https://github.com/ftnick/tugyt/commits/main"><img src="https://img.shields.io/github/last-commit/ftnick/tugyt" alt="Last commit"></a>
|
|
34
|
+
</p>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
setup.py
|
|
5
|
+
tests/test_tugyt.py
|
|
6
|
+
tugyt/__init__.py
|
|
7
|
+
tugyt/__main__.py
|
|
8
|
+
tugyt/tugyt.py
|
|
9
|
+
tugyt.egg-info/PKG-INFO
|
|
10
|
+
tugyt.egg-info/SOURCES.txt
|
|
11
|
+
tugyt.egg-info/dependency_links.txt
|
|
12
|
+
tugyt.egg-info/entry_points.txt
|
|
13
|
+
tugyt.egg-info/requires.txt
|
|
14
|
+
tugyt.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tugyt
|