ember-dl 0.9.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.
- ember/__init__.py +66 -0
- ember/__main__.py +497 -0
- ember/_browser_cookies.py +611 -0
- ember/cache.py +79 -0
- ember/cookies.py +179 -0
- ember/download.py +549 -0
- ember/errors.py +29 -0
- ember/hls.py +149 -0
- ember/http.py +121 -0
- ember/models.py +148 -0
- ember/py.typed +0 -0
- ember/router.py +233 -0
- ember/services/__init__.py +0 -0
- ember/services/bluesky.py +79 -0
- ember/services/facebook.py +74 -0
- ember/services/instagram.py +434 -0
- ember/services/newgrounds.py +76 -0
- ember/services/ok.py +78 -0
- ember/services/pinterest.py +110 -0
- ember/services/pornhub.py +72 -0
- ember/services/reddit.py +136 -0
- ember/services/redgifs.py +95 -0
- ember/services/rutube.py +90 -0
- ember/services/soundcloud.py +169 -0
- ember/services/tiktok.py +129 -0
- ember/services/tumblr.py +147 -0
- ember/services/twitch.py +110 -0
- ember/services/twitter.py +319 -0
- ember/services/vimeo.py +130 -0
- ember/services/vk.py +264 -0
- ember/services/xvideos.py +88 -0
- ember_dl-0.9.0.dist-info/METADATA +291 -0
- ember_dl-0.9.0.dist-info/RECORD +37 -0
- ember_dl-0.9.0.dist-info/WHEEL +5 -0
- ember_dl-0.9.0.dist-info/entry_points.txt +2 -0
- ember_dl-0.9.0.dist-info/licenses/LICENSE +21 -0
- ember_dl-0.9.0.dist-info/top_level.txt +1 -0
ember/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Ember — extract direct media links from social platforms.
|
|
2
|
+
|
|
3
|
+
An embeddable, cobalt-like (imputnet/cobalt) Python library.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
import ember
|
|
7
|
+
result = ember.extract("https://www.tiktok.com/@user/video/123...")
|
|
8
|
+
for m in result.media:
|
|
9
|
+
print(m.kind, m.url)
|
|
10
|
+
|
|
11
|
+
Logging: the package logs to the "ember" logger and is silent by default
|
|
12
|
+
(NullHandler). To see logs from your app:
|
|
13
|
+
import logging; logging.basicConfig(); logging.getLogger("ember").setLevel(logging.INFO)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging as _logging
|
|
17
|
+
|
|
18
|
+
# best practice: a library must not force output — silent by default
|
|
19
|
+
_logging.getLogger("ember").addHandler(_logging.NullHandler())
|
|
20
|
+
|
|
21
|
+
from .errors import (
|
|
22
|
+
EmberError,
|
|
23
|
+
ExtractionError,
|
|
24
|
+
NetworkError,
|
|
25
|
+
UnsupportedUrlError,
|
|
26
|
+
)
|
|
27
|
+
from .cookies import cookies_from_browser, cookies_from_file
|
|
28
|
+
from .download import (DownloadProgress, RateLimiter, available_qualities,
|
|
29
|
+
download, download_media, ffmpeg_available, probe_size)
|
|
30
|
+
from .models import Media, MediaVariant, Playlist, Result, Subtitle
|
|
31
|
+
from .router import (can_extract, extract, extract_highlights, extract_playlist,
|
|
32
|
+
extract_timeline, supported_services, supports_highlights,
|
|
33
|
+
supports_playlist, supports_timeline)
|
|
34
|
+
|
|
35
|
+
__version__ = "0.9.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"extract",
|
|
39
|
+
"extract_playlist",
|
|
40
|
+
"extract_timeline",
|
|
41
|
+
"extract_highlights",
|
|
42
|
+
"can_extract",
|
|
43
|
+
"supports_playlist",
|
|
44
|
+
"supports_timeline",
|
|
45
|
+
"supports_highlights",
|
|
46
|
+
"supported_services",
|
|
47
|
+
"cookies_from_browser",
|
|
48
|
+
"cookies_from_file",
|
|
49
|
+
"download",
|
|
50
|
+
"download_media",
|
|
51
|
+
"available_qualities",
|
|
52
|
+
"ffmpeg_available",
|
|
53
|
+
"probe_size",
|
|
54
|
+
"DownloadProgress",
|
|
55
|
+
"RateLimiter",
|
|
56
|
+
"MediaVariant",
|
|
57
|
+
"Playlist",
|
|
58
|
+
"Result",
|
|
59
|
+
"Media",
|
|
60
|
+
"Subtitle",
|
|
61
|
+
"EmberError",
|
|
62
|
+
"UnsupportedUrlError",
|
|
63
|
+
"NetworkError",
|
|
64
|
+
"ExtractionError",
|
|
65
|
+
"__version__",
|
|
66
|
+
]
|
ember/__main__.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""Ember CLI.
|
|
2
|
+
|
|
3
|
+
Show links: ember "URL"
|
|
4
|
+
Download: ember -d "URL" (site-derived name, current folder)
|
|
5
|
+
Custom name: ember -d -o myclip "URL"
|
|
6
|
+
Custom folder: ember -d -p downloads "URL"
|
|
7
|
+
Installed entry point is `ember`; `python -m ember` works too.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from . import (DownloadProgress, EmberError, __version__, available_qualities,
|
|
17
|
+
can_extract, cookies_from_file, download, extract,
|
|
18
|
+
extract_highlights, extract_playlist, extract_timeline,
|
|
19
|
+
probe_size, supported_services, supports_playlist,
|
|
20
|
+
supports_timeline)
|
|
21
|
+
from .http import make_context
|
|
22
|
+
|
|
23
|
+
# browsers understood by --cookies-from-browser (same set as yt-dlp)
|
|
24
|
+
BROWSERS = ["brave", "chrome", "chromium", "edge", "firefox",
|
|
25
|
+
"opera", "safari", "vivaldi", "whale"]
|
|
26
|
+
|
|
27
|
+
# options that take a value (can accidentally swallow the URL) + example values
|
|
28
|
+
_VALUE_OPTS = {
|
|
29
|
+
"-o": "myfile", "--output": "myfile", "-p": "downloads", "--path": "downloads",
|
|
30
|
+
"-a": "links.txt", "--batch-file": "links.txt",
|
|
31
|
+
"--max-height": "720", "--concurrency": "6", "--timeout": "20",
|
|
32
|
+
"--proxy": "http://host:port", "--cookies": '"a=1; b=2"',
|
|
33
|
+
"--cookies-file": "cookies.txt", "--cookies-from-browser": "firefox",
|
|
34
|
+
"--browser-profile": "Default",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_cookies_arg(raw: str) -> dict:
|
|
39
|
+
cookies = {}
|
|
40
|
+
for pair in raw.split(";"):
|
|
41
|
+
pair = pair.strip()
|
|
42
|
+
if "=" in pair:
|
|
43
|
+
name, _, value = pair.partition("=")
|
|
44
|
+
cookies[name.strip()] = value.strip()
|
|
45
|
+
return cookies
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_parse_cookies_file = cookies_from_file # библиотечный парсер Netscape
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_RATE_UNITS = {"": 1, "k": 1000, "m": 1000 ** 2, "g": 1000 ** 3,
|
|
52
|
+
"ki": 1024, "mi": 1024 ** 2, "gi": 1024 ** 3}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_rate(raw: str) -> float:
|
|
56
|
+
"""'500K' / '2M' / '1.5MiB' / '800' -> bytes per second."""
|
|
57
|
+
m = re.fullmatch(r"\s*([\d.]+)\s*([KMGkmg]i?)?B?/?[sS]?\s*", raw)
|
|
58
|
+
if not m:
|
|
59
|
+
raise ValueError(f"could not parse rate {raw!r}")
|
|
60
|
+
value = float(m.group(1))
|
|
61
|
+
unit = (m.group(2) or "").lower()
|
|
62
|
+
return value * _RATE_UNITS[unit]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _bar(frac: float, width: int = 30) -> str:
|
|
66
|
+
filled = int(frac * width)
|
|
67
|
+
if filled >= width:
|
|
68
|
+
return "=" * width
|
|
69
|
+
return "=" * filled + ">" + "-" * (width - filled - 1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _eta(seconds: float) -> str:
|
|
73
|
+
s = int(seconds)
|
|
74
|
+
return f"{s // 60:02d}m{s % 60:02d}s"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _enable_vt() -> None:
|
|
78
|
+
"""Windows: turn on VT processing so \\r and \\x1b[K redraw in place."""
|
|
79
|
+
if sys.platform != "win32":
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
import ctypes
|
|
83
|
+
k = ctypes.windll.kernel32
|
|
84
|
+
h = k.GetStdHandle(-11) # STD_OUTPUT_HANDLE
|
|
85
|
+
mode = ctypes.c_uint32()
|
|
86
|
+
if k.GetConsoleMode(h, ctypes.byref(mode)):
|
|
87
|
+
k.SetConsoleMode(h, mode.value | 0x0004) # VIRTUAL_TERMINAL_PROCESSING
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cols() -> int:
|
|
93
|
+
return shutil.get_terminal_size((80, 20)).columns
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _line(text: str) -> None:
|
|
97
|
+
"""Redraw one line in place. Skipped when stdout is not a terminal —
|
|
98
|
+
there \\r would pile up as new lines."""
|
|
99
|
+
try:
|
|
100
|
+
if not sys.stdout.isatty():
|
|
101
|
+
return
|
|
102
|
+
except Exception:
|
|
103
|
+
return
|
|
104
|
+
print("\r\x1b[K" + text[:_cols() - 1], end="", flush=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _make_progress_printer():
|
|
108
|
+
"""One-line progress: MiB / MiB [bar] pct speed ETA (CLI only)."""
|
|
109
|
+
state = {"last": 0.0}
|
|
110
|
+
MiB = 1048576
|
|
111
|
+
|
|
112
|
+
def cb(p: DownloadProgress):
|
|
113
|
+
now = time.time()
|
|
114
|
+
if p.stage != "download":
|
|
115
|
+
_line(f" {p.stage}…")
|
|
116
|
+
return
|
|
117
|
+
if now - state["last"] < 0.1:
|
|
118
|
+
return
|
|
119
|
+
state["last"] = now
|
|
120
|
+
dl, spd = p.downloaded / MiB, p.speed / MiB
|
|
121
|
+
if p.total:
|
|
122
|
+
frac = p.fraction or 0
|
|
123
|
+
# bar shrinks so the whole line fits the console
|
|
124
|
+
bar = _bar(frac, max(10, _cols() - 56))
|
|
125
|
+
_line(f" {dl:.2f} MiB / {p.total / MiB:.2f} MiB [{bar}]"
|
|
126
|
+
f" {frac * 100:6.2f}% {spd:.2f} MiB/s {_eta(p.eta or 0)}")
|
|
127
|
+
elif p.segments_total:
|
|
128
|
+
_line(f" segment {p.segments_done}/{p.segments_total}"
|
|
129
|
+
f" {dl:.2f} MiB {spd:.2f} MiB/s")
|
|
130
|
+
else:
|
|
131
|
+
_line(f" {dl:.2f} MiB {spd:.2f} MiB/s")
|
|
132
|
+
|
|
133
|
+
return cb
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _print_result(result, size=False, ctx=None) -> None:
|
|
137
|
+
print(f"service: {result.service}")
|
|
138
|
+
print(f"type: {result.kind}")
|
|
139
|
+
print(f"author: {result.author or '-'}")
|
|
140
|
+
print(f"title: {result.title or '-'}")
|
|
141
|
+
if result.duration:
|
|
142
|
+
print(f"duration: {int(result.duration // 60)}:{int(result.duration % 60):02d}")
|
|
143
|
+
if result.timestamp:
|
|
144
|
+
from datetime import datetime, timezone
|
|
145
|
+
print(f"date: {datetime.fromtimestamp(result.timestamp, timezone.utc):%Y-%m-%d}")
|
|
146
|
+
if result.view_count is not None:
|
|
147
|
+
print(f"views: {result.view_count}")
|
|
148
|
+
if result.like_count is not None:
|
|
149
|
+
print(f"likes: {result.like_count}")
|
|
150
|
+
print(f"filename: {result.filename_hint}")
|
|
151
|
+
if result.thumbnail:
|
|
152
|
+
print(f"thumb: {result.thumbnail}")
|
|
153
|
+
for i, m in enumerate(result.media, 1):
|
|
154
|
+
q = f" [{m.quality}]" if m.quality else ""
|
|
155
|
+
print(f" {i}. {m.kind}{q} .{m.ext}")
|
|
156
|
+
print(f" {m.url}")
|
|
157
|
+
qs = available_qualities(m) if m.variants else []
|
|
158
|
+
if qs:
|
|
159
|
+
print(f" qualities: {qs}")
|
|
160
|
+
if size:
|
|
161
|
+
b = probe_size(m, ctx)
|
|
162
|
+
if b:
|
|
163
|
+
print(f" size: {b / 1048576:.1f} MB")
|
|
164
|
+
if result.subtitles:
|
|
165
|
+
print(f"subs: {', '.join(s.lang for s in result.subtitles)}")
|
|
166
|
+
if result.requires_merge:
|
|
167
|
+
print("! video and audio are separate — downloading needs ffmpeg")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class _Parser(argparse.ArgumentParser):
|
|
171
|
+
"""Turns confusing argparse errors into short, clear CLI messages
|
|
172
|
+
(especially when a value-taking option accidentally swallows the URL)."""
|
|
173
|
+
|
|
174
|
+
def _fail(self, text: str):
|
|
175
|
+
# короткое сообщение без простыни usage
|
|
176
|
+
sys.stderr.write(f"ember: error: {text}\n")
|
|
177
|
+
self.exit(2)
|
|
178
|
+
|
|
179
|
+
def error(self, message):
|
|
180
|
+
argv = sys.argv[1:]
|
|
181
|
+
url = next((a for a in argv if a.startswith(("http://", "https://"))), None)
|
|
182
|
+
|
|
183
|
+
# 1) ссылка «съедена» как значение опции (напр. --cookies-from-browser URL)
|
|
184
|
+
if url:
|
|
185
|
+
i = argv.index(url)
|
|
186
|
+
prev = argv[i - 1] if i > 0 else None
|
|
187
|
+
if prev in _VALUE_OPTS:
|
|
188
|
+
sample = _VALUE_OPTS[prev]
|
|
189
|
+
self._fail(
|
|
190
|
+
f'"{prev}" needs a value, but your link was taken as its value.\n'
|
|
191
|
+
f' put the value after "{prev}", e.g.: '
|
|
192
|
+
f'ember {prev} {sample} "{url}"\n'
|
|
193
|
+
f' or just show links: ember "{url}"')
|
|
194
|
+
|
|
195
|
+
# 2) опция требует значение, но его нет (напр. в конце строки)
|
|
196
|
+
m = re.search(r"argument ([^:]+): expected one argument", message)
|
|
197
|
+
if m:
|
|
198
|
+
opt = m.group(1).split("/")[-1].strip()
|
|
199
|
+
sample = _VALUE_OPTS.get(opt, "<value>")
|
|
200
|
+
self._fail(f'"{opt}" needs a value, e.g.: ember {opt} {sample} "URL"')
|
|
201
|
+
|
|
202
|
+
# 3) ссылка есть, но не встала как позиционный аргумент
|
|
203
|
+
if "required: url" in message and url:
|
|
204
|
+
self._fail(f'put the link as the last argument, e.g.: ember "{url}"')
|
|
205
|
+
|
|
206
|
+
super().error(message)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
210
|
+
p = _Parser(prog="ember",
|
|
211
|
+
description="Extract and download media (a cobalt-like library)")
|
|
212
|
+
p.add_argument("--version", action="version", version=f"ember {__version__}")
|
|
213
|
+
p.add_argument("--list-services", action="store_true",
|
|
214
|
+
help="print supported services and exit")
|
|
215
|
+
p.add_argument("url", nargs="?", help="link to a post / track / video")
|
|
216
|
+
p.add_argument("-a", "--batch-file", metavar="FILE",
|
|
217
|
+
help="read links from a file (one per line, '#' comments; '-' = stdin)")
|
|
218
|
+
p.add_argument("--json", action="store_true", help="print metadata as JSON")
|
|
219
|
+
p.add_argument("-F", "--list-formats", action="store_true",
|
|
220
|
+
help="list available qualities and exit (no download)")
|
|
221
|
+
p.add_argument("-v", "--verbose", action="count", default=0,
|
|
222
|
+
help="log to stderr; -vv for debug")
|
|
223
|
+
p.add_argument("--timeout", type=float, default=15.0, metavar="SEC",
|
|
224
|
+
help="per-request timeout, seconds (default 15)")
|
|
225
|
+
# download
|
|
226
|
+
p.add_argument("-d", "--download", action="store_true",
|
|
227
|
+
help="download the media (otherwise only links are shown)")
|
|
228
|
+
p.add_argument("-o", "--output", metavar="NAME",
|
|
229
|
+
help="output file name without extension, or a template with "
|
|
230
|
+
"%%(title)s/%%(author)s/%%(service)s/%%(id)s; implies --download")
|
|
231
|
+
p.add_argument("-p", "--path", metavar="DIR",
|
|
232
|
+
help="target folder (default: current folder); implies --download")
|
|
233
|
+
p.add_argument("--max-height", type=int, metavar="N",
|
|
234
|
+
help="cap quality by height, e.g. 720")
|
|
235
|
+
p.add_argument("--audio-only", action="store_true",
|
|
236
|
+
help="keep audio only (needs ffmpeg); implies --download")
|
|
237
|
+
p.add_argument("--subs", action="store_true",
|
|
238
|
+
help="also download subtitle tracks; implies --download")
|
|
239
|
+
p.add_argument("--thumbnail", action="store_true",
|
|
240
|
+
help="also save the cover image; implies --download")
|
|
241
|
+
p.add_argument("--write-info", action="store_true", dest="write_info",
|
|
242
|
+
help="save a .info.json sidecar with all metadata; implies --download")
|
|
243
|
+
p.add_argument("--size", action="store_true",
|
|
244
|
+
help="print file size before downloading (extra request)")
|
|
245
|
+
p.add_argument("--concurrency", type=int, default=1, metavar="N",
|
|
246
|
+
help="parallel HLS segments, and parallel gallery items (default 1)")
|
|
247
|
+
p.add_argument("--skip-existing", action="store_true", dest="skip_existing",
|
|
248
|
+
help="do not re-download files that already exist")
|
|
249
|
+
p.add_argument("--rate-limit", metavar="RATE", dest="rate_limit",
|
|
250
|
+
help="cap download speed, e.g. 500K, 2M, 1.5MiB (default: unlimited)")
|
|
251
|
+
p.add_argument("--embed-metadata", "--metadata", action="store_true",
|
|
252
|
+
dest="embed_metadata",
|
|
253
|
+
help="write title/author into the file (needs ffmpeg); implies --download")
|
|
254
|
+
p.add_argument("--playlist", action="store_true",
|
|
255
|
+
help="treat as a playlist/set (SoundCloud sets)")
|
|
256
|
+
p.add_argument("--timeline", action="store_true",
|
|
257
|
+
help="treat the URL as a profile/channel and list latest posts")
|
|
258
|
+
p.add_argument("--highlights", action="store_true",
|
|
259
|
+
help="treat the URL as a profile and list its story highlights "
|
|
260
|
+
"(Instagram; needs cookies)")
|
|
261
|
+
p.add_argument("--limit", type=int, default=30, metavar="N",
|
|
262
|
+
help="max items for --timeline (default 30)")
|
|
263
|
+
p.add_argument("--proxy", metavar="URL",
|
|
264
|
+
help="proxy for all requests, e.g. http://host:port "
|
|
265
|
+
"(helps with IP-blocked sites)")
|
|
266
|
+
# cookies
|
|
267
|
+
p.add_argument("--cookies", metavar='"name=value; ..."',
|
|
268
|
+
help="cookies as a string (NSFW tweets, private Instagram)")
|
|
269
|
+
p.add_argument("--cookies-file", metavar="cookies.txt",
|
|
270
|
+
help="cookies.txt in Netscape format (like yt-dlp)")
|
|
271
|
+
p.add_argument("--cookies-from-browser", metavar="BROWSER", choices=BROWSERS,
|
|
272
|
+
help="read cookies from a browser; one of: " + ", ".join(BROWSERS))
|
|
273
|
+
p.add_argument("--browser-profile", metavar="PROFILE",
|
|
274
|
+
help="browser profile for --cookies-from-browser")
|
|
275
|
+
return p
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _report_error(msg: str, prefix: str = "error") -> None:
|
|
279
|
+
"""Print an error and, when relevant, a hint with CLI flags (not Python API)."""
|
|
280
|
+
print(f"{prefix}: {msg}", file=sys.stderr)
|
|
281
|
+
low = msg.lower()
|
|
282
|
+
if "cookie" in low and "could not read cookies" not in low and "app-bound" not in low:
|
|
283
|
+
print('hint: pass cookies with --cookies "name=value; ..." | '
|
|
284
|
+
"--cookies-file cookies.txt | --cookies-from-browser firefox",
|
|
285
|
+
file=sys.stderr)
|
|
286
|
+
if "proxy" in low or "network policy" in low:
|
|
287
|
+
print("hint: try another IP with --proxy http://host:port", file=sys.stderr)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _setup_logging(verbose: int) -> None:
|
|
291
|
+
if verbose <= 0:
|
|
292
|
+
return
|
|
293
|
+
import logging
|
|
294
|
+
h = logging.StreamHandler(sys.stderr)
|
|
295
|
+
h.setFormatter(logging.Formatter("[%(name)s] %(message)s"))
|
|
296
|
+
lg = logging.getLogger("ember")
|
|
297
|
+
lg.addHandler(h)
|
|
298
|
+
lg.setLevel(logging.DEBUG if verbose >= 2 else logging.INFO)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _read_batch(path: str) -> list:
|
|
302
|
+
src = sys.stdin if path == "-" else open(path, encoding="utf-8", errors="replace")
|
|
303
|
+
try:
|
|
304
|
+
urls = []
|
|
305
|
+
for line in src:
|
|
306
|
+
line = line.strip()
|
|
307
|
+
if line and not line.startswith("#"):
|
|
308
|
+
urls.append(line)
|
|
309
|
+
return urls
|
|
310
|
+
finally:
|
|
311
|
+
if src is not sys.stdin:
|
|
312
|
+
src.close()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _render_name(template: str, result) -> str:
|
|
316
|
+
if "%(" not in template:
|
|
317
|
+
return template
|
|
318
|
+
fields = {"title": result.title or "", "author": result.author or "",
|
|
319
|
+
"uploader": result.author or "", "service": result.service,
|
|
320
|
+
"id": result.filename_hint or ""}
|
|
321
|
+
try:
|
|
322
|
+
return template % fields
|
|
323
|
+
except (KeyError, ValueError):
|
|
324
|
+
return template
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _list_formats(result) -> None:
|
|
328
|
+
print(f"# {result.service}: {result.title or result.filename_hint}")
|
|
329
|
+
for i, m in enumerate(result.media, 1):
|
|
330
|
+
qs = available_qualities(m)
|
|
331
|
+
label = ", ".join(f"{h}p" for h in qs) if qs else (m.quality or "single")
|
|
332
|
+
print(f" {i}. {m.kind} .{m.ext}: {label}")
|
|
333
|
+
for s in result.subtitles:
|
|
334
|
+
print(f" sub: {s.lang} ({s.ext})")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def main() -> int:
|
|
338
|
+
# Windows-консоль часто cp1251/cp866 -> не-ASCII падает/рисуется квадратами.
|
|
339
|
+
# Данные в str корректны; правим только вывод.
|
|
340
|
+
for _s in (sys.stdout, sys.stderr):
|
|
341
|
+
try:
|
|
342
|
+
_s.reconfigure(encoding="utf-8", errors="replace")
|
|
343
|
+
except (AttributeError, ValueError):
|
|
344
|
+
pass
|
|
345
|
+
_enable_vt()
|
|
346
|
+
args = _build_parser().parse_args()
|
|
347
|
+
_setup_logging(args.verbose)
|
|
348
|
+
|
|
349
|
+
if args.list_services:
|
|
350
|
+
print(", ".join(supported_services()))
|
|
351
|
+
return 0
|
|
352
|
+
|
|
353
|
+
urls = []
|
|
354
|
+
if args.url:
|
|
355
|
+
urls.append(args.url)
|
|
356
|
+
if args.batch_file:
|
|
357
|
+
try:
|
|
358
|
+
urls += _read_batch(args.batch_file)
|
|
359
|
+
except OSError as e:
|
|
360
|
+
print(f"could not read batch file: {e}", file=sys.stderr)
|
|
361
|
+
return 1
|
|
362
|
+
if not urls:
|
|
363
|
+
print("error: provide a URL or --batch-file FILE", file=sys.stderr)
|
|
364
|
+
return 2
|
|
365
|
+
|
|
366
|
+
cookies = {}
|
|
367
|
+
if args.cookies_file:
|
|
368
|
+
try:
|
|
369
|
+
cookies.update(_parse_cookies_file(args.cookies_file))
|
|
370
|
+
except OSError as e:
|
|
371
|
+
print(f"could not read cookies file: {e}", file=sys.stderr)
|
|
372
|
+
return 1
|
|
373
|
+
if args.cookies:
|
|
374
|
+
cookies.update(_parse_cookies_arg(args.cookies))
|
|
375
|
+
|
|
376
|
+
rate_limit = None
|
|
377
|
+
if args.rate_limit:
|
|
378
|
+
try:
|
|
379
|
+
rate_limit = _parse_rate(args.rate_limit)
|
|
380
|
+
except ValueError as e:
|
|
381
|
+
print(f"error: {e} (use e.g. 500K, 2M, 1.5MiB)", file=sys.stderr)
|
|
382
|
+
return 2
|
|
383
|
+
|
|
384
|
+
proxies = {"http": args.proxy, "https": args.proxy} if args.proxy else None
|
|
385
|
+
common = dict(timeout=args.timeout, proxies=proxies, cookies=cookies or None,
|
|
386
|
+
cookies_from_browser=args.cookies_from_browser,
|
|
387
|
+
browser_profile=args.browser_profile)
|
|
388
|
+
|
|
389
|
+
do_download = (args.download or args.output or args.path or args.audio_only
|
|
390
|
+
or args.embed_metadata or args.subs or args.thumbnail
|
|
391
|
+
or args.write_info or args.skip_existing or args.rate_limit)
|
|
392
|
+
dl_ctx = make_context(timeout=args.timeout, proxies=proxies) if (do_download or args.size) else None
|
|
393
|
+
cb = _make_progress_printer()
|
|
394
|
+
rc = 0
|
|
395
|
+
|
|
396
|
+
for url in urls:
|
|
397
|
+
# профиль/канал -> лента; авто, если ссылка не пост
|
|
398
|
+
is_timeline = (not args.highlights
|
|
399
|
+
and (args.timeline
|
|
400
|
+
or (supports_timeline(url) and not can_extract(url))))
|
|
401
|
+
try:
|
|
402
|
+
if args.highlights:
|
|
403
|
+
pl = extract_highlights(url, limit=args.limit, **common)
|
|
404
|
+
results = pl.entries
|
|
405
|
+
print(f"highlights: {pl.author or '-'} ({len(results)} collections)")
|
|
406
|
+
elif is_timeline:
|
|
407
|
+
pl = extract_timeline(url, limit=args.limit, **common)
|
|
408
|
+
results = pl.entries
|
|
409
|
+
if do_download:
|
|
410
|
+
results = list(reversed(results)) # старые -> новые
|
|
411
|
+
print(f"timeline: {pl.author or '-'} ({len(results)} items)")
|
|
412
|
+
elif args.playlist or supports_playlist(url):
|
|
413
|
+
playlist = extract_playlist(url, **common)
|
|
414
|
+
results = playlist.entries
|
|
415
|
+
print(f"playlist: {playlist.title or '-'} ({len(results)} items)")
|
|
416
|
+
else:
|
|
417
|
+
results = [extract(url, **common)]
|
|
418
|
+
except EmberError as e:
|
|
419
|
+
_report_error(str(e))
|
|
420
|
+
rc = 1
|
|
421
|
+
continue
|
|
422
|
+
except Exception as e: # ничего не должно долетать сюда сырым
|
|
423
|
+
print(f"error: {type(e).__name__}: {e}", file=sys.stderr)
|
|
424
|
+
rc = 1
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
# лента/highlights без -d: компактный список, не вываливаем полные блоки
|
|
428
|
+
if ((is_timeline or args.highlights) and not do_download
|
|
429
|
+
and not args.json and not args.list_formats):
|
|
430
|
+
for i, r in enumerate(results, 1):
|
|
431
|
+
extra = (f" [{len(r.media)} items]" if args.highlights
|
|
432
|
+
else f" {r.source_url}")
|
|
433
|
+
print(f" {i:2d}. {(r.title or r.filename_hint or '')[:60]}{extra}")
|
|
434
|
+
continue
|
|
435
|
+
|
|
436
|
+
if args.json:
|
|
437
|
+
import json
|
|
438
|
+
payload = [r.to_dict() for r in results]
|
|
439
|
+
print(json.dumps(payload if len(payload) > 1 else payload[0],
|
|
440
|
+
ensure_ascii=False, indent=2))
|
|
441
|
+
continue
|
|
442
|
+
if args.list_formats:
|
|
443
|
+
for r in results:
|
|
444
|
+
_list_formats(r)
|
|
445
|
+
continue
|
|
446
|
+
if not do_download:
|
|
447
|
+
for r in results:
|
|
448
|
+
_print_result(r, size=args.size, ctx=dl_ctx)
|
|
449
|
+
if len(results) > 1 or len(urls) > 1:
|
|
450
|
+
print("-" * 40)
|
|
451
|
+
continue
|
|
452
|
+
|
|
453
|
+
out_dir = args.path or "."
|
|
454
|
+
single = len(results) == 1 and len(urls) == 1
|
|
455
|
+
if args.output and "%(" not in args.output and not single:
|
|
456
|
+
print(f'warning: -o "{args.output}" is a literal name but there are '
|
|
457
|
+
"multiple files — ignored, using site-derived names instead. "
|
|
458
|
+
"Use a template, e.g. -o \"%(title)s\"", file=sys.stderr)
|
|
459
|
+
for r in results:
|
|
460
|
+
print(f"downloading: {r.title or r.filename_hint}")
|
|
461
|
+
if args.output and "%(" in args.output:
|
|
462
|
+
name = _render_name(args.output, r) # шаблон — на каждый результат
|
|
463
|
+
elif args.output and single:
|
|
464
|
+
name = args.output # литеральное имя — только один файл
|
|
465
|
+
else:
|
|
466
|
+
name = None # иначе имя с сайта
|
|
467
|
+
try:
|
|
468
|
+
paths = download(r, out_dir, filename=name, ctx=dl_ctx,
|
|
469
|
+
max_height=args.max_height,
|
|
470
|
+
concurrency=args.concurrency, on_progress=cb,
|
|
471
|
+
audio_only=args.audio_only,
|
|
472
|
+
embed_metadata=args.embed_metadata,
|
|
473
|
+
subtitles=args.subs, thumbnail=args.thumbnail,
|
|
474
|
+
write_info=args.write_info,
|
|
475
|
+
skip_existing=args.skip_existing,
|
|
476
|
+
rate_limit=rate_limit)
|
|
477
|
+
except EmberError as e:
|
|
478
|
+
print()
|
|
479
|
+
_report_error(str(e), prefix=" error")
|
|
480
|
+
rc = 1
|
|
481
|
+
continue
|
|
482
|
+
_line("") # стираем строку прогресса
|
|
483
|
+
for p in paths:
|
|
484
|
+
print(f"\r saved: {p}")
|
|
485
|
+
return rc
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _run() -> int:
|
|
489
|
+
try:
|
|
490
|
+
return main()
|
|
491
|
+
except KeyboardInterrupt:
|
|
492
|
+
print("\ncancelled", file=sys.stderr)
|
|
493
|
+
return 130 # общепринятый код для SIGINT
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
if __name__ == "__main__":
|
|
497
|
+
sys.exit(_run())
|