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.
- notepipe_source_ytdlp-0.1.0/.gitignore +18 -0
- notepipe_source_ytdlp-0.1.0/PKG-INFO +34 -0
- notepipe_source_ytdlp-0.1.0/README.md +25 -0
- notepipe_source_ytdlp-0.1.0/pyproject.toml +15 -0
- notepipe_source_ytdlp-0.1.0/src/notepipe_source_ytdlp/__init__.py +178 -0
- notepipe_source_ytdlp-0.1.0/tests/__init__.py +0 -0
- notepipe_source_ytdlp-0.1.0/tests/test_adapter.py +331 -0
- notepipe_source_ytdlp-0.1.0/uv.lock +23 -0
|
@@ -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
|
+
]
|