notepipe-source-ytdlp 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.
@@ -0,0 +1,18 @@
1
+ /target
2
+ out/
3
+ .pi/
4
+ models/
5
+ *.wav
6
+ !tests/fixtures/speech.wav
7
+ *.mp4
8
+ .DS_Store
9
+ .venv/
10
+ __pycache__/
11
+ out-bv/
12
+ out-bench/
13
+ out-gpu/
14
+ out-smoke/
15
+ adapters/speech-swift/native/.build/
16
+ /adapters/speech-swift/target/
17
+ /adapters/speech-swift/wheel-data/
18
+ /dist/
@@ -0,0 +1,34 @@
1
+ Metadata-Version: 2.5
2
+ Name: notepipe-source-ytdlp
3
+ Version: 0.1.0
4
+ Summary: yt-dlp source adapter for notepipe
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: yt-dlp
8
+ Description-Content-Type: text/markdown
9
+
10
+ # notepipe-source-ytdlp
11
+
12
+ A [notepipe](https://github.com/3ae3ae/notepipe) source adapter that downloads media from any site
13
+ [yt-dlp](https://github.com/yt-dlp/yt-dlp) supports, so notepipe can take URLs.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ uv tool install "notepipe[ytdlp]"
19
+ ```
20
+
21
+ notepipe finds the adapter by its name; no configuration is needed. ffmpeg is required to merge formats and convert
22
+ subtitles.
23
+
24
+ ## Behavior
25
+
26
+ - Your yt-dlp configuration files apply. Extra yt-dlp options go after `--`:
27
+ ```toml
28
+ [download]
29
+ adapter = ["notepipe-source-ytdlp", "--", "--cookies-from-browser", "firefox", "-f", "bv*[height<=720]+ba/b"]
30
+ ```
31
+ - Subtitles: manual subtitles in every language and automatic captions only in the media's original language, never
32
+ machine-translated ones; all are delivered as SRT or VTT.
33
+ - Playlist URLs are rejected; pass the URL of a single item.
34
+ - For YouTube, notes link timestamps to the matching position in the video.
@@ -0,0 +1,25 @@
1
+ # notepipe-source-ytdlp
2
+
3
+ A [notepipe](https://github.com/3ae3ae/notepipe) source adapter that downloads media from any site
4
+ [yt-dlp](https://github.com/yt-dlp/yt-dlp) supports, so notepipe can take URLs.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ uv tool install "notepipe[ytdlp]"
10
+ ```
11
+
12
+ notepipe finds the adapter by its name; no configuration is needed. ffmpeg is required to merge formats and convert
13
+ subtitles.
14
+
15
+ ## Behavior
16
+
17
+ - Your yt-dlp configuration files apply. Extra yt-dlp options go after `--`:
18
+ ```toml
19
+ [download]
20
+ adapter = ["notepipe-source-ytdlp", "--", "--cookies-from-browser", "firefox", "-f", "bv*[height<=720]+ba/b"]
21
+ ```
22
+ - Subtitles: manual subtitles in every language and automatic captions only in the media's original language, never
23
+ machine-translated ones; all are delivered as SRT or VTT.
24
+ - Playlist URLs are rejected; pass the URL of a single item.
25
+ - For YouTube, notes link timestamps to the matching position in the video.
@@ -0,0 +1,15 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "notepipe-source-ytdlp"
7
+ version = "0.1.0"
8
+ description = "yt-dlp source adapter for notepipe"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = ["yt-dlp"]
13
+
14
+ [project.scripts]
15
+ notepipe-source-ytdlp = "notepipe_source_ytdlp:main"
@@ -0,0 +1,178 @@
1
+ """Download one online media item for the notepipe source contract."""
2
+ import argparse
3
+ from contextlib import redirect_stdout
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import sys
8
+ from urllib.parse import parse_qs, urlparse
9
+ from xml.etree.ElementTree import ParseError
10
+
11
+ import yt_dlp
12
+
13
+
14
+ CAPTION_EXTENSIONS = {*yt_dlp.utils.MEDIA_EXTENSIONS.subtitles, "ssa", "ttml", "dfxp", "tt", "srv1", "srv2", "srv3", "json3"}
15
+
16
+
17
+ def original_captions(info):
18
+ captions = info.get("automatic_captions") or {}
19
+ language = info.get("language")
20
+ if not language:
21
+ originals = [key[:-5] for key in captions if key.endswith("-orig")]
22
+ if len(originals) == 1:
23
+ language = originals[0]
24
+ if not language:
25
+ return {}
26
+ formats = captions.get(f"{language}-orig") or captions.get(language) or []
27
+ # A translation can share the media's language code; exclude translated URLs too.
28
+ formats = [entry for entry in formats if "tlang" not in parse_qs(urlparse(entry.get("url", "")).query)]
29
+ return {language: formats} if formats else {}
30
+
31
+
32
+ class YoutubeDL(yt_dlp.YoutubeDL):
33
+ def process_ie_result(self, ie_result, download=True, extra_info=None):
34
+ media_language = ie_result.get("language")
35
+ if ie_result.get("_type") in {"playlist", "multi_video", "compat_list"} or "entries" in ie_result:
36
+ raise ValueError("pass the URL of a single item")
37
+ if ie_result.get("_type", "video") == "video":
38
+ self.subtitle_tracks = {}
39
+ for field, automatic, tracks in [
40
+ ("subtitles", False, ie_result.get("subtitles") or {}),
41
+ ("automatic_captions", True, original_captions(ie_result)),
42
+ ]:
43
+ ie_result[field] = {}
44
+ for language, formats in tracks.items():
45
+ formats = [entry for entry in formats if entry.get("ext") in CAPTION_EXTENSIONS]
46
+ if language == "live_chat" or not formats:
47
+ continue
48
+ # Distinct keys preserve both manual and automatic tracks in one language.
49
+ key = f"{language}-auto" if automatic else language
50
+ ie_result[field][key] = formats
51
+ # Post-processors can replace subtitle dicts, so keep identity separately.
52
+ self.subtitle_tracks[key] = {"language": language, "automatic": automatic}
53
+ result = super().process_ie_result(ie_result, download=download, extra_info=extra_info)
54
+ if result and media_language and not result.get("language"):
55
+ result["language"] = media_language
56
+ return result
57
+
58
+
59
+ def relative_file(filename, out):
60
+ path = Path(filename).resolve()
61
+ relative = path.relative_to(out)
62
+ if not path.is_file():
63
+ raise ValueError(f"yt-dlp did not produce {relative}")
64
+ return relative.as_posix()
65
+
66
+
67
+ def download(url, out, extra_args, output):
68
+ def emit(value):
69
+ print(json.dumps(value, ensure_ascii=False, allow_nan=False), file=output, flush=True)
70
+
71
+ def progress(event):
72
+ total = event.get("total_bytes") or event.get("total_bytes_estimate")
73
+ if event.get("status") == "finished":
74
+ emit({"progress": 1.0})
75
+ elif event.get("status") == "downloading" and total:
76
+ emit({"progress": min(1.0, max(0.0, event.get("downloaded_bytes", 0) / total))})
77
+
78
+ if urlparse(url).scheme not in {"http", "https"}:
79
+ raise ValueError("expected an http(s) URL")
80
+ out = Path(out).resolve()
81
+ out.mkdir(parents=True, exist_ok=True)
82
+ if any(out.iterdir()):
83
+ raise ValueError("--out must be an empty directory")
84
+
85
+ # yt-dlp can print even with quiet enabled (e.g. --print); reserve stdout for JSON.
86
+ with redirect_stdout(sys.stderr):
87
+ options = yt_dlp.parse_options(extra_args).ydl_opts
88
+ if not options.get("format"):
89
+ options["format"] = "bestvideo+bestaudio/best/bestaudio"
90
+ options.update(
91
+ outtmpl={"default": "media.%(ext)s"},
92
+ paths={"home": str(out), "temp": str(out)},
93
+ noplaylist=True,
94
+ extract_flat=False,
95
+ progress_hooks=[progress],
96
+ logtostderr=True,
97
+ noprogress=True,
98
+ simulate=False,
99
+ skip_download=False,
100
+ listformats=False,
101
+ listsubtitles=False,
102
+ list_thumbnails=False,
103
+ writesubtitles=True,
104
+ writeautomaticsub=True,
105
+ allsubtitles=True,
106
+ subtitleslangs=["all"],
107
+ subtitlesformat="srt/vtt/best",
108
+ )
109
+ with YoutubeDL(options) as downloader:
110
+ info = downloader.extract_info(url, download=True)
111
+ if not info:
112
+ raise ValueError("yt-dlp did not return a media item")
113
+ downloads = info.get("requested_downloads") or [info]
114
+ if len(downloads) != 1:
115
+ raise ValueError("select a single merged media format")
116
+ downloaded = {**info, **downloads[0]}
117
+ tracks = downloaded.get("requested_subtitles") or {}
118
+ for key, track in list(tracks.items()):
119
+ if track.get("ext") in {"srt", "vtt"} or not track.get("filepath"):
120
+ continue
121
+ subtitle_info = {
122
+ "requested_subtitles": {key: track},
123
+ "__files_to_move": {track["filepath"]: track["filepath"]},
124
+ }
125
+ try:
126
+ converter = yt_dlp.postprocessor.FFmpegSubtitlesConvertorPP(downloader, format="srt")
127
+ downloader.run_pp(converter, subtitle_info)
128
+ except (yt_dlp.utils.PostProcessingError, OSError, ValueError, ParseError) as error:
129
+ downloader.report_warning(f"Skipping subtitle {key}: {error}")
130
+ del tracks[key]
131
+ else:
132
+ tracks[key] = subtitle_info["requested_subtitles"][key]
133
+ filename = downloaded.get("filepath")
134
+ if not filename:
135
+ raise ValueError("yt-dlp did not produce a media file")
136
+ result = {
137
+ "media": relative_file(filename, out),
138
+ "url": info.get("webpage_url") or url,
139
+ "subtitles": [
140
+ {
141
+ "path": relative_file(track["filepath"], out),
142
+ **downloader.subtitle_tracks[key],
143
+ }
144
+ for key, track in (downloaded.get("requested_subtitles") or {}).items()
145
+ if track.get("ext") in {"srt", "vtt"} and track.get("filepath") and Path(track["filepath"]).is_file()
146
+ ],
147
+ }
148
+ for source, target in [("title", "title"), ("uploader", "author"), ("duration", "duration"), ("language", "language")]:
149
+ if info.get(source) is not None:
150
+ result[target] = info[source]
151
+ if info.get("extractor_key") == "Youtube":
152
+ result["seek_url"] = f"https://www.youtube.com/watch?v={info['id']}&t={{t}}s"
153
+ emit(result)
154
+
155
+
156
+ def main(argv=None):
157
+ sys.stdout.flush()
158
+ with os.fdopen(os.dup(1), "w", encoding="utf-8") as output:
159
+ # Child processes inherit fd 1; only the duplicated descriptor carries JSON.
160
+ os.dup2(2, 1)
161
+ parser = argparse.ArgumentParser(description="Download one media item with yt-dlp")
162
+ parser.add_argument("url")
163
+ parser.add_argument("--out", required=True)
164
+ parser.epilog = "Pass yt-dlp options after -- (also supported before URL --out DIR in [download].adapter)."
165
+ argv = list(sys.argv[1:] if argv is None else argv)
166
+ if argv[:1] == ["--"]:
167
+ # The core appends URL --out DIR to the configured command.
168
+ adapter_args, extra_args = argv[-3:], argv[1:-3]
169
+ else:
170
+ separator = argv.index("--") if "--" in argv else len(argv)
171
+ adapter_args, extra_args = argv[:separator], argv[separator + 1:]
172
+ args = parser.parse_args(adapter_args)
173
+ try:
174
+ download(args.url, args.out, extra_args, output)
175
+ except (yt_dlp.utils.DownloadError, OSError, ValueError) as error:
176
+ parser.exit(1, f"{error}\n")
177
+ except KeyboardInterrupt:
178
+ parser.exit(130, "Download interrupted\n")
File without changes
@@ -0,0 +1,331 @@
1
+ from contextlib import redirect_stderr, redirect_stdout
2
+ from copy import deepcopy
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from unittest.mock import patch
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
13
+ import notepipe_source_ytdlp as adapter
14
+
15
+
16
+ URL = "https://example.org/watch/clip"
17
+
18
+
19
+ def subtitles(*extensions, translated=False):
20
+ return [
21
+ {"ext": ext, "url": f"https://example.org/sub.{ext}" + ("?tlang=en" if translated else "")}
22
+ for ext in extensions
23
+ ]
24
+
25
+
26
+ class AdapterTests(unittest.TestCase):
27
+ def setUp(self):
28
+ self.temp = tempfile.TemporaryDirectory()
29
+ self.addCleanup(self.temp.cleanup)
30
+ self.out = Path(self.temp.name).resolve() / "download"
31
+ self.info = {
32
+ "id": "clip",
33
+ "title": "A sample — 예시",
34
+ "uploader": "Sample author",
35
+ "duration": 12.5,
36
+ "language": "ko",
37
+ "webpage_url": "https://example.org/canonical/clip",
38
+ "extractor": "test",
39
+ "extractor_key": "Test",
40
+ "formats": [
41
+ {"format_id": "audio", "url": "https://example.org/audio.m4a", "ext": "m4a", "vcodec": "none", "acodec": "aac"},
42
+ {"format_id": "combined", "url": "https://example.org/combined.mp4", "ext": "mp4", "vcodec": "h264", "acodec": "aac", "height": 360},
43
+ {"format_id": "video", "url": "https://example.org/video.mp4", "ext": "mp4", "vcodec": "h264", "acodec": "none", "height": 720},
44
+ ],
45
+ "subtitles": {"en": subtitles("vtt", "srt"), "ko": subtitles("vtt")},
46
+ "automatic_captions": {
47
+ "ko-orig": subtitles("vtt"),
48
+ "ko": subtitles("vtt"),
49
+ "en": subtitles("vtt", translated=True),
50
+ "ja": subtitles("vtt", translated=True),
51
+ },
52
+ }
53
+ self.options = None
54
+ self.selected = []
55
+
56
+ def extract(downloader, url, download=True):
57
+ self.assertEqual(url, URL)
58
+ self.assertTrue(download)
59
+ self.options = downloader.params
60
+ print("yt-dlp diagnostic")
61
+ return downloader.process_ie_result(deepcopy(self.info), download=download)
62
+
63
+ def process(downloader, info):
64
+ filename = Path(downloader.prepare_filename(info))
65
+ filename.write_bytes(b"simulated media")
66
+ info["filepath"] = str(filename)
67
+ files_to_move = {}
68
+ for language, track in (info.get("requested_subtitles") or {}).items():
69
+ path = filename.with_suffix(f".{language}.{track['ext']}")
70
+ path.write_text("simulated subtitles")
71
+ track["filepath"] = str(path)
72
+ files_to_move[str(path)] = str(path)
73
+ new_info, files_to_move = downloader.pre_process(info, "before_dl", files_to_move)
74
+ info.update(new_info)
75
+ for event in [
76
+ {"status": "downloading", "downloaded_bytes": 25, "total_bytes": 100},
77
+ {"status": "downloading", "downloaded_bytes": 50, "total_bytes_estimate": 100},
78
+ {"status": "downloading", "downloaded_bytes": 150, "total_bytes_estimate": 100},
79
+ {"status": "downloading", "downloaded_bytes": 1},
80
+ {"status": "finished"},
81
+ ]:
82
+ for hook in downloader.params["progress_hooks"]:
83
+ hook(event)
84
+ info.update(downloader.post_process(str(filename), info, files_to_move))
85
+ self.selected.append(deepcopy(info))
86
+
87
+ self.addCleanup(patch.stopall)
88
+ patch.object(adapter.yt_dlp.YoutubeDL, "extract_info", extract).start()
89
+ patch.object(adapter.yt_dlp.YoutubeDL, "process_info", process).start()
90
+ patch.object(adapter.yt_dlp.YoutubeDL, "urlopen", side_effect=AssertionError("network access in offline test")).start()
91
+ cookiejar = adapter.yt_dlp.cookies.YoutubeDLCookieJar()
92
+ patch.object(adapter.yt_dlp.YoutubeDL, "cookiejar", property(lambda _: cookiejar)).start()
93
+ # Exercise real option parsing while isolating tests from personal/system config.
94
+ real_parse = adapter.yt_dlp.parse_options
95
+ self.parse = patch.object(adapter.yt_dlp, "parse_options", side_effect=lambda args: real_parse(["--ignore-config", *args])).start()
96
+
97
+ def call_adapter(self, args):
98
+ sys.stdout.flush()
99
+ sys.stderr.flush()
100
+ saved_stdout, saved_stderr = os.dup(1), os.dup(2)
101
+ with tempfile.TemporaryFile(mode="w+", encoding="utf-8") as stdout, tempfile.TemporaryFile(mode="w+", encoding="utf-8") as stderr:
102
+ try:
103
+ os.dup2(stdout.fileno(), 1)
104
+ os.dup2(stderr.fileno(), 2)
105
+ with redirect_stdout(stdout), redirect_stderr(stderr):
106
+ adapter.main(args)
107
+ finally:
108
+ os.dup2(saved_stdout, 1)
109
+ os.dup2(saved_stderr, 2)
110
+ os.close(saved_stdout)
111
+ os.close(saved_stderr)
112
+ stdout.seek(0)
113
+ stderr.seek(0)
114
+ self.protocol = stdout.read()
115
+ self.diagnostics = stderr.read()
116
+
117
+ def run_adapter(self, *extra, prefix=False):
118
+ args = [URL, "--out", str(self.out)]
119
+ if extra:
120
+ args = ["--", *extra, *args] if prefix else [*args, "--", *extra]
121
+ self.call_adapter(args)
122
+ return [json.loads(line) for line in self.protocol.splitlines()]
123
+
124
+ def test_result_progress_subtitles_and_default_merged_format(self):
125
+ lines = self.run_adapter()
126
+ self.assertEqual(lines[:-1], [{"progress": n} for n in [0.25, 0.5, 1.0, 1.0]])
127
+ result = lines[-1]
128
+ self.assertEqual(result, {
129
+ "media": "media.mp4",
130
+ "title": "A sample — 예시",
131
+ "author": "Sample author",
132
+ "duration": 12.5,
133
+ "language": "ko",
134
+ "url": "https://example.org/canonical/clip",
135
+ "subtitles": [
136
+ {"path": "media.en.srt", "language": "en", "automatic": False},
137
+ {"path": "media.ko.vtt", "language": "ko", "automatic": False},
138
+ {"path": "media.ko-auto.vtt", "language": "ko", "automatic": True},
139
+ ],
140
+ })
141
+ self.assertEqual([f["format_id"] for f in self.selected[0]["requested_formats"]], ["video", "audio"])
142
+ for name in [result["media"], *(track["path"] for track in result["subtitles"])]:
143
+ self.assertTrue((self.out / name).is_file())
144
+ self.assertIn("yt-dlp diagnostic", self.diagnostics)
145
+
146
+ def test_youtube_seek_url(self):
147
+ self.info.update(id="BaW_jenozKc", extractor_key="Youtube")
148
+ result = self.run_adapter()[-1]
149
+ self.assertEqual(result["seek_url"], "https://www.youtube.com/watch?v=BaW_jenozKc&t={t}s")
150
+
151
+ def test_subtitle_identity_survives_conversion(self):
152
+ converter = adapter.yt_dlp.postprocessor.FFmpegSubtitlesConvertorPP
153
+
154
+ def convert(processor, source, target, options):
155
+ Path(target).write_text("converted subtitles")
156
+
157
+ with patch.object(converter, "run_ffmpeg", convert):
158
+ result = self.run_adapter("--convert-subs", "srt")[-1]
159
+ self.assertEqual(result["subtitles"], [
160
+ {"path": "media.en.srt", "language": "en", "automatic": False},
161
+ {"path": "media.ko.srt", "language": "ko", "automatic": False},
162
+ {"path": "media.ko-auto.srt", "language": "ko", "automatic": True},
163
+ ])
164
+ for track in self.selected[0]["requested_subtitles"].values():
165
+ self.assertNotIn("_notepipe_language", track)
166
+ self.assertNotIn("_notepipe_automatic", track)
167
+ self.assertEqual(Path(track["filepath"]).suffix, ".srt")
168
+
169
+ def test_fallback_subtitles_are_converted_to_srt(self):
170
+ self.info["subtitles"] = {"en": subtitles("json3"), "ko": subtitles("vtt", "ass")}
171
+ self.info["automatic_captions"] = {"ko-orig": subtitles("json3")}
172
+
173
+ def convert(processor, source, target, options):
174
+ self.assertEqual(Path(source).suffix, ".json3")
175
+ self.assertEqual(options, ["-f", "srt"])
176
+ Path(target).write_text("converted subtitles")
177
+
178
+ converter = adapter.yt_dlp.postprocessor.FFmpegSubtitlesConvertorPP
179
+ with patch.object(converter, "run_ffmpeg", convert):
180
+ result = self.run_adapter()[-1]
181
+ self.assertEqual(result["subtitles"], [
182
+ {"path": "media.en.srt", "language": "en", "automatic": False},
183
+ {"path": "media.ko.vtt", "language": "ko", "automatic": False},
184
+ {"path": "media.ko-auto.srt", "language": "ko", "automatic": True},
185
+ ])
186
+ for track in result["subtitles"]:
187
+ self.assertTrue((self.out / track["path"]).is_file())
188
+
189
+ def test_nonconvertible_subtitle_is_dropped(self):
190
+ converter = adapter.yt_dlp.postprocessor.FFmpegSubtitlesConvertorPP
191
+ for ext in ["srv3", "ttml"]:
192
+ with self.subTest(ext=ext):
193
+ self.out = Path(self.temp.name).resolve() / ext
194
+ self.info["subtitles"]["en"] = subtitles(ext)
195
+ with patch.object(converter, "run_ffmpeg", side_effect=adapter.yt_dlp.utils.PostProcessingError("cannot convert")):
196
+ result = self.run_adapter()[-1]
197
+ self.assertEqual(result["subtitles"], [
198
+ {"path": "media.ko.vtt", "language": "ko", "automatic": False},
199
+ {"path": "media.ko-auto.vtt", "language": "ko", "automatic": True},
200
+ ])
201
+ self.assertIn("Skipping subtitle en:", self.diagnostics)
202
+
203
+ def test_child_stdout_is_sent_to_diagnostics(self):
204
+ extract = adapter.YoutubeDL.extract_info
205
+
206
+ def extract_with_child(downloader, *args, **kwargs):
207
+ subprocess.run([sys.executable, "-c", "print('child stdout diagnostic')"], check=True)
208
+ return extract(downloader, *args, **kwargs)
209
+
210
+ with patch.object(adapter.YoutubeDL, "extract_info", extract_with_child):
211
+ result = self.run_adapter()[-1]
212
+ self.assertEqual(result["media"], "media.mp4")
213
+ self.assertIn("child stdout diagnostic", self.diagnostics)
214
+
215
+ def test_non_caption_tracks_are_excluded_before_download(self):
216
+ self.info["subtitles"].update({
217
+ "live_chat": [
218
+ {**track, "protocol": "youtube_live_chat_replay"}
219
+ for track in subtitles("json", "vtt")
220
+ ],
221
+ "comments": subtitles("json"),
222
+ "thumbnails": subtitles("mhtml"),
223
+ })
224
+ self.info["subtitles"]["en"].extend(subtitles("json"))
225
+ result = self.run_adapter()[-1]
226
+ self.assertEqual(set(self.selected[0]["requested_subtitles"]), {"en", "ko", "ko-auto"})
227
+ self.assertEqual(set(self.selected[0]["subtitles"]), {"en", "ko"})
228
+ self.assertEqual([track["ext"] for track in self.selected[0]["subtitles"]["en"]], ["vtt", "srt"])
229
+ self.assertEqual(len(result["subtitles"]), 3)
230
+ self.assertFalse(list(self.out.glob("*.json")))
231
+ self.assertFalse(list(self.out.glob("*.mhtml")))
232
+
233
+ def test_media_language_is_preserved_without_automatic_captions(self):
234
+ self.info["subtitles"] = {"en": subtitles("vtt")}
235
+ self.info["automatic_captions"] = {}
236
+ self.assertEqual(self.run_adapter()[-1]["language"], "ko")
237
+
238
+ def test_audio_only_fallback_and_optional_metadata(self):
239
+ self.info["formats"] = self.info["formats"][:1]
240
+ for field in ["title", "uploader", "duration", "language", "webpage_url", "subtitles", "automatic_captions"]:
241
+ self.info.pop(field)
242
+ result = self.run_adapter()[-1]
243
+ self.assertEqual(result["media"], "media.m4a")
244
+ self.assertEqual(result["url"], URL)
245
+ self.assertEqual(result["subtitles"], [])
246
+ for field in ["author", "duration", "language"]:
247
+ self.assertNotIn(field, result)
248
+
249
+ def test_passthrough_options_in_both_command_positions(self):
250
+ for prefix in [False, True]:
251
+ with self.subTest(prefix=prefix):
252
+ self.out = Path(self.temp.name).resolve() / str(prefix)
253
+ self.run_adapter("--cookies-from-browser", "chrome", "-f", "bestaudio", prefix=prefix)
254
+ self.parse.assert_called_with(["--cookies-from-browser", "chrome", "-f", "bestaudio"])
255
+ self.assertEqual(self.options["cookiesfrombrowser"][0], "chrome")
256
+ self.assertEqual(self.options["format"], "bestaudio")
257
+ self.assertEqual(self.selected[-1]["format_id"], "audio")
258
+
259
+ def test_config_is_honored_except_contract_overrides(self):
260
+ config = Path(self.temp.name) / "yt-dlp.conf"
261
+ config.write_text(
262
+ "-f bestaudio\n--socket-timeout 7\n--no-playlist\n"
263
+ "-o elsewhere/%(title)s.%(ext)s\n-P elsewhere\n"
264
+ "--skip-download\n--simulate\n--no-write-subs\n--sub-langs en\n"
265
+ )
266
+ self.run_adapter("--config-locations", str(config))
267
+ self.assertEqual(self.options["format"], "bestaudio")
268
+ self.assertEqual(self.options["socket_timeout"], 7)
269
+ self.assertTrue(self.options["noplaylist"])
270
+ self.assertEqual(self.options["paths"], {"home": str(self.out), "temp": str(self.out)})
271
+ self.assertEqual(self.options["outtmpl"]["default"], "media.%(ext)s")
272
+ self.assertFalse(self.options["simulate"])
273
+ self.assertFalse(self.options["skip_download"])
274
+ self.assertTrue(self.options["writesubtitles"])
275
+ self.assertTrue(self.options["writeautomaticsub"])
276
+ self.assertEqual(self.options["subtitleslangs"], ["all"])
277
+
278
+ def test_playlist_is_rejected_before_download(self):
279
+ for kind in ["playlist", "multi_video", "compat_list"]:
280
+ with self.subTest(kind=kind):
281
+ self.info = {"_type": kind, "entries": []}
282
+ with self.assertRaises(SystemExit) as raised:
283
+ self.call_adapter([URL, "--out", str(self.out)])
284
+ self.assertEqual(raised.exception.code, 1)
285
+ self.assertIn("pass the URL of a single item", self.diagnostics)
286
+ self.assertEqual(self.protocol, "")
287
+ self.assertEqual(self.selected, [])
288
+
289
+ def test_original_caption_selection(self):
290
+ for info, expected in [
291
+ ({"automatic_captions": {"ja-orig": subtitles("vtt"), "en": subtitles("vtt", translated=True)}}, {"ja"}),
292
+ ({"language": "fr", "automatic_captions": {"fr": subtitles("vtt"), "en": subtitles("vtt", translated=True)}}, {"fr"}),
293
+ ({"language": "en", "automatic_captions": {"en": subtitles("vtt", translated=True)}}, set()),
294
+ ({"automatic_captions": {"en": subtitles("vtt"), "fr": subtitles("vtt")}}, set()),
295
+ ({"automatic_captions": {"en-orig": subtitles("vtt"), "fr-orig": subtitles("vtt")}}, set()),
296
+ ({"language": "ja", "automatic_captions": {"en-orig": subtitles("vtt")}}, set()),
297
+ ]:
298
+ with self.subTest(info=info):
299
+ self.assertEqual(set(adapter.original_captions(info)), expected)
300
+
301
+ def test_nonempty_output_directory_is_rejected(self):
302
+ self.out.mkdir()
303
+ existing = self.out / "existing.txt"
304
+ existing.write_text("keep")
305
+ with self.assertRaises(SystemExit) as raised:
306
+ self.call_adapter([URL, "--out", str(self.out)])
307
+ self.assertEqual(raised.exception.code, 1)
308
+ self.assertEqual(existing.read_text(), "keep")
309
+ self.assertIsNone(self.options)
310
+
311
+ def test_paths_must_remain_inside_output_directory(self):
312
+ outside = Path(self.temp.name) / "outside.mp4"
313
+ outside.write_bytes(b"media")
314
+ with self.assertRaises(ValueError):
315
+ adapter.relative_file(outside, self.out)
316
+ self.out.mkdir()
317
+ (self.out / "link.mp4").symlink_to(outside)
318
+ with self.assertRaises(ValueError):
319
+ adapter.relative_file(self.out / "link.mp4", self.out)
320
+
321
+ def test_download_failure_exits_nonzero_with_diagnostics(self):
322
+ with patch.object(adapter.YoutubeDL, "extract_info", side_effect=adapter.yt_dlp.utils.DownloadError("download failed")):
323
+ with self.assertRaises(SystemExit) as raised:
324
+ self.call_adapter([URL, "--out", str(self.out)])
325
+ self.assertEqual(raised.exception.code, 1)
326
+ self.assertIn("download failed", self.diagnostics)
327
+ self.assertEqual(self.protocol, "")
328
+
329
+
330
+ if __name__ == "__main__":
331
+ unittest.main()
@@ -0,0 +1,23 @@
1
+ version = 1
2
+ revision = 5
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "notepipe-source-ytdlp"
7
+ version = "0.1.0"
8
+ source = { editable = "." }
9
+ dependencies = [
10
+ { name = "yt-dlp" },
11
+ ]
12
+
13
+ [package.metadata]
14
+ requires-dist = [{ name = "yt-dlp" }]
15
+
16
+ [[package]]
17
+ name = "yt-dlp"
18
+ version = "2026.8.19"
19
+ source = { registry = "https://pypi.org/simple" }
20
+ sdist = { url = "https://files.pythonhosted.org/packages/1e/e0/832fa4ca334b766a06933a196066edc3dba37cdb6f14cd98d59bcc69a4b4/yt_dlp-2026.8.19.tar.gz", hash = "sha256:9e213e48cea35c66b378e4447903f118f6392a5fa380a2b6d7070ec86f4e0af1", size = 3052025, upload-time = "2026-08-19T23:48:59.291Z" }
21
+ wheels = [
22
+ { url = "https://files.pythonhosted.org/packages/69/b2/8cd1613f56eed7ceb64fbd4df3f1c01246bfb098e6f398228bafda22b80b/yt_dlp-2026.8.19-py3-none-any.whl", hash = "sha256:1d57897e94c6665a0a6f9bc54b34e584284e32c034ffab3a7df25d8f7b24eedf", size = 3185533, upload-time = "2026-08-19T23:48:56.925Z" },
23
+ ]