]> jfr.im git - yt-dlp.git/blame - yt_dlp/update.py
[ie/n-tv.de] Fix extractor (#8414)
[yt-dlp.git] / yt_dlp / update.py
CommitLineData
8372be74 1import atexit
b5e7a2e6 2import contextlib
c19bc311 3import hashlib
d5ed35b6 4import json
ce02ed60 5import os
e5813e53 6import platform
b1f94422 7import re
d2790370 8import subprocess
46353f67 9import sys
d5ed35b6
FV
10from zipimport import zipimporter
11
b5899f4f 12from .compat import functools # isort: split
a6125983 13from .compat import compat_realpath, compat_shlex_quote
3d2623a8 14from .networking import Request
15from .networking.exceptions import HTTPError, network_exceptions
b1f94422 16from .utils import (
17 Popen,
18 cached_method,
da4db748 19 deprecation_warning,
5be214ab 20 remove_end,
77df20f1 21 remove_start,
b1f94422 22 shell_quote,
23 system_identifier,
b1f94422 24 version_tuple,
25)
77df20f1 26from .version import CHANNEL, UPDATE_HINT, VARIANT, __version__
d5ed35b6 27
77df20f1
SS
28UPDATE_SOURCES = {
29 'stable': 'yt-dlp/yt-dlp',
30 'nightly': 'yt-dlp/yt-dlp-nightly-builds',
31}
392389b7 32REPOSITORY = UPDATE_SOURCES['stable']
77df20f1
SS
33
34_VERSION_RE = re.compile(r'(\d+\.)*\d+')
35
36API_BASE_URL = 'https://api.github.com/repos'
37
38# Backwards compatibility variables for the current channel
77df20f1 39API_URL = f'{API_BASE_URL}/{REPOSITORY}/releases'
b5899f4f 40
41
0b9c08b4 42@functools.cache
b5899f4f 43def _get_variant_and_executable_path():
c487cf00 44 """@returns (variant, executable_path)"""
7aaf4cd2 45 if getattr(sys, 'frozen', False):
c487cf00 46 path = sys.executable
b5899f4f 47 if not hasattr(sys, '_MEIPASS'):
48 return 'py2exe', path
7aaf4cd2 49 elif sys._MEIPASS == os.path.dirname(path):
b5899f4f 50 return f'{sys.platform}_dir', path
7aaf4cd2 51 elif sys.platform == 'darwin':
17fc3dc4
M
52 machine = '_legacy' if version_tuple(platform.mac_ver()[0]) < (10, 15) else ''
53 else:
54 machine = f'_{platform.machine().lower()}'
55 # Ref: https://en.wikipedia.org/wiki/Uname#Examples
56 if machine[1:] in ('x86', 'x86_64', 'amd64', 'i386', 'i686'):
57 machine = '_x86' if platform.architecture()[0][:2] == '32' else ''
5be214ab 58 return f'{remove_end(sys.platform, "32")}{machine}_exe', path
b5899f4f 59
60 path = os.path.dirname(__file__)
c487cf00 61 if isinstance(__loader__, zipimporter):
62 return 'zip', os.path.join(path, '..')
233ad894 63 elif (os.path.basename(sys.argv[0]) in ('__main__.py', '-m')
64 and os.path.exists(os.path.join(path, '../.git/HEAD'))):
c487cf00 65 return 'source', path
66 return 'unknown', path
67
68
69def detect_variant():
70b23409 70 return VARIANT or _get_variant_and_executable_path()[0]
4c88ff87 71
72
b5e7a2e6 73@functools.cache
74def current_git_head():
75 if detect_variant() != 'source':
76 return
77 with contextlib.suppress(Exception):
78 stdout, _, _ = Popen.run(
79 ['git', 'rev-parse', '--short', 'HEAD'],
80 text=True, cwd=os.path.dirname(os.path.abspath(__file__)),
81 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
82 if re.fullmatch('[0-9a-f]+', stdout.strip()):
83 return stdout.strip()
84
85
b5899f4f 86_FILE_SUFFIXES = {
87 'zip': '',
88 'py2exe': '_min.exe',
5be214ab
SS
89 'win_exe': '.exe',
90 'win_x86_exe': '_x86.exe',
b5899f4f 91 'darwin_exe': '_macos',
63da2d09 92 'darwin_legacy_exe': '_macos_legacy',
e4afcfde 93 'linux_exe': '_linux',
17fc3dc4
M
94 'linux_aarch64_exe': '_linux_aarch64',
95 'linux_armv7l_exe': '_linux_armv7l',
b5899f4f 96}
97
5d535b4a 98_NON_UPDATEABLE_REASONS = {
b5899f4f 99 **{variant: None for variant in _FILE_SUFFIXES}, # Updatable
100 **{variant: f'Auto-update is not supported for unpackaged {name} executable; Re-download the latest release'
e4afcfde 101 for variant, name in {'win32_dir': 'Windows', 'darwin_dir': 'MacOS', 'linux_dir': 'Linux'}.items()},
e6faf2be 102 'source': 'You cannot update when running from source code; Use git to pull the latest changes',
70b23409 103 'unknown': 'You installed yt-dlp with a package manager or setup.py; Use that to update',
104 'other': 'You are using an unofficial build of yt-dlp; Build the executable again',
5d535b4a 105}
106
107
108def is_non_updateable():
70b23409 109 if UPDATE_HINT:
110 return UPDATE_HINT
111 return _NON_UPDATEABLE_REASONS.get(
112 detect_variant(), _NON_UPDATEABLE_REASONS['unknown' if VARIANT else 'other'])
5d535b4a 113
114
61bdf15f
SS
115def _get_system_deprecation():
116 MIN_SUPPORTED, MIN_RECOMMENDED = (3, 7), (3, 8)
117
118 if sys.version_info > MIN_RECOMMENDED:
119 return None
120
121 major, minor = sys.version_info[:2]
122 if sys.version_info < MIN_SUPPORTED:
123 msg = f'Python version {major}.{minor} is no longer supported'
124 else:
125 msg = f'Support for Python version {major}.{minor} has been deprecated. '
126 # Temporary until `win_x86_exe` uses 3.8, which will deprecate Vista and Server 2008
127 if detect_variant() == 'win_x86_exe':
128 platform_name = platform.platform()
129 if any(platform_name.startswith(f'Windows-{name}') for name in ('Vista', '2008Server')):
130 msg = 'Support for Windows Vista/Server 2008 has been deprecated. '
131 else:
132 return None
133 msg += ('See https://github.com/yt-dlp/yt-dlp/issues/7803 for details.'
134 '\nYou may stop receiving updates on this version at any time')
135
136 major, minor = MIN_RECOMMENDED
137 return f'{msg}! Please update to Python {major}.{minor} or above'
138
139
57e0f077 140def _sha256_file(path):
141 h = hashlib.sha256()
142 mv = memoryview(bytearray(128 * 1024))
143 with open(os.path.realpath(path), 'rb', buffering=0) as f:
144 for n in iter(lambda: f.readinto(mv), 0):
145 h.update(mv[:n])
146 return h.hexdigest()
147
148
149class Updater:
77df20f1
SS
150 _exact = True
151
152 def __init__(self, ydl, target=None):
57e0f077 153 self.ydl = ydl
154
77df20f1 155 self.target_channel, sep, self.target_tag = (target or CHANNEL).rpartition('@')
665472a7
SS
156 # stable => stable@latest
157 if not sep and ('/' in self.target_tag or self.target_tag in UPDATE_SOURCES):
158 self.target_channel = self.target_tag
159 self.target_tag = None
77df20f1 160 elif not self.target_channel:
665472a7 161 self.target_channel = CHANNEL.partition('@')[0]
77df20f1
SS
162
163 if not self.target_tag:
665472a7
SS
164 self.target_tag = 'latest'
165 self._exact = False
77df20f1
SS
166 elif self.target_tag != 'latest':
167 self.target_tag = f'tags/{self.target_tag}'
168
665472a7
SS
169 if '/' in self.target_channel:
170 self._target_repo = self.target_channel
171 if self.target_channel not in (CHANNEL, *UPDATE_SOURCES.values()):
172 self.ydl.report_warning(
173 f'You are switching to an {self.ydl._format_err("unofficial", "red")} executable '
174 f'from {self.ydl._format_err(self._target_repo, self.ydl.Styles.EMPHASIS)}. '
175 f'Run {self.ydl._format_err("at your own risk", "light red")}')
02948a17 176 self._block_restart('Automatically restarting into custom builds is disabled for security reasons')
665472a7
SS
177 else:
178 self._target_repo = UPDATE_SOURCES.get(self.target_channel)
179 if not self._target_repo:
180 self._report_error(
181 f'Invalid update channel {self.target_channel!r} requested. '
182 f'Valid channels are {", ".join(UPDATE_SOURCES)}', True)
77df20f1
SS
183
184 def _version_compare(self, a, b, channel=CHANNEL):
665472a7 185 if self._exact and channel != self.target_channel:
77df20f1
SS
186 return False
187
188 if _VERSION_RE.fullmatch(f'{a}.{b}'):
189 a, b = version_tuple(a), version_tuple(b)
190 return a == b if self._exact else a >= b
191 return a == b
192
57e0f077 193 @functools.cached_property
b1f94422 194 def _tag(self):
77df20f1
SS
195 if self._version_compare(self.current_version, self.latest_version):
196 return self.target_tag
a63b35a6 197
77df20f1 198 identifier = f'{detect_variant()} {self.target_channel} {system_identifier()}'
b1f94422 199 for line in self._download('_update_spec', 'latest').decode().splitlines():
200 if not line.startswith('lock '):
201 continue
202 _, tag, pattern = line.split(' ', 2)
203 if re.match(pattern, identifier):
77df20f1
SS
204 if not self._exact:
205 return f'tags/{tag}'
206 elif self.target_tag == 'latest' or not self._version_compare(
207 tag, self.target_tag[5:], channel=self.target_channel):
208 self._report_error(
209 f'yt-dlp cannot be updated above {tag} since you are on an older Python version', True)
210 return f'tags/{self.current_version}'
211 return self.target_tag
b1f94422 212
213 @cached_method
214 def _get_version_info(self, tag):
77df20f1
SS
215 url = f'{API_BASE_URL}/{self._target_repo}/releases/{tag}'
216 self.ydl.write_debug(f'Fetching release info: {url}')
3d2623a8 217 return json.loads(self.ydl.urlopen(Request(url, headers={
77df20f1
SS
218 'Accept': 'application/vnd.github+json',
219 'User-Agent': 'yt-dlp',
220 'X-GitHub-Api-Version': '2022-11-28',
221 })).read().decode())
57e0f077 222
223 @property
224 def current_version(self):
225 """Current version"""
226 return __version__
227
77df20f1
SS
228 @staticmethod
229 def _label(channel, tag):
230 """Label for a given channel and tag"""
231 return f'{channel}@{remove_start(tag, "tags/")}'
232
233 def _get_actual_tag(self, tag):
234 if tag.startswith('tags/'):
235 return tag[5:]
236 return self._get_version_info(tag)['tag_name']
237
57e0f077 238 @property
239 def new_version(self):
24093d52 240 """Version of the latest release we can update to"""
77df20f1 241 return self._get_actual_tag(self._tag)
57e0f077 242
24093d52 243 @property
244 def latest_version(self):
77df20f1
SS
245 """Version of the target release"""
246 return self._get_actual_tag(self.target_tag)
24093d52 247
57e0f077 248 @property
249 def has_update(self):
250 """Whether there is an update available"""
77df20f1 251 return not self._version_compare(self.current_version, self.new_version)
57e0f077 252
253 @functools.cached_property
254 def filename(self):
255 """Filename of the executable"""
256 return compat_realpath(_get_variant_and_executable_path()[1])
257
b1f94422 258 def _download(self, name, tag):
77df20f1
SS
259 slug = 'latest/download' if tag == 'latest' else f'download/{tag[5:]}'
260 url = f'https://github.com/{self._target_repo}/releases/{slug}/{name}'
57e0f077 261 self.ydl.write_debug(f'Downloading {name} from {url}')
262 return self.ydl.urlopen(url).read()
263
264 @functools.cached_property
265 def release_name(self):
266 """The release filename"""
17fc3dc4 267 return f'yt-dlp{_FILE_SUFFIXES[detect_variant()]}'
57e0f077 268
269 @functools.cached_property
270 def release_hash(self):
271 """Hash of the latest release"""
b1f94422 272 hash_data = dict(ln.split()[::-1] for ln in self._download('SHA2-256SUMS', self._tag).decode().splitlines())
57e0f077 273 return hash_data[self.release_name]
274
275 def _report_error(self, msg, expected=False):
276 self.ydl.report_error(msg, tb=False if expected else None)
ff48fc04 277 self.ydl._download_retcode = 100
57e0f077 278
279 def _report_permission_error(self, file):
280 self._report_error(f'Unable to write to {file}; Try running as administrator', True)
281
282 def _report_network_error(self, action, delim=';'):
77df20f1
SS
283 self._report_error(
284 f'Unable to {action}{delim} visit '
285 f'https://github.com/{self._target_repo}/releases/{self.target_tag.replace("tags/", "tag/")}', True)
57e0f077 286
287 def check_update(self):
288 """Report whether there is an update available"""
77df20f1
SS
289 if not self._target_repo:
290 return False
57e0f077 291 try:
77df20f1
SS
292 self.ydl.to_screen((
293 f'Available version: {self._label(self.target_channel, self.latest_version)}, ' if self.target_tag == 'latest' else ''
294 ) + f'Current version: {self._label(CHANNEL, self.current_version)}')
d2e84d5e
SS
295 except network_exceptions as e:
296 return self._report_network_error(f'obtain version info ({e})', delim='; Please try again later or')
e6faf2be 297
57e0f077 298 if not is_non_updateable():
77df20f1
SS
299 self.ydl.to_screen(f'Current Build Hash: {_sha256_file(self.filename)}')
300
301 if self.has_update:
302 return True
303
304 if self.target_tag == self._tag:
305 self.ydl.to_screen(f'yt-dlp is up to date ({self._label(CHANNEL, self.current_version)})')
306 elif not self._exact:
307 self.ydl.report_warning('yt-dlp cannot be updated any further since you are on an older Python version')
308 return False
c19bc311 309
57e0f077 310 def update(self):
311 """Update yt-dlp executable to the latest version"""
312 if not self.check_update():
313 return
314 err = is_non_updateable()
315 if err:
316 return self._report_error(err, True)
77df20f1
SS
317 self.ydl.to_screen(f'Updating to {self._label(self.target_channel, self.new_version)} ...')
318 if (_VERSION_RE.fullmatch(self.target_tag[5:])
319 and version_tuple(self.target_tag[5:]) < (2023, 3, 2)):
320 self.ydl.report_warning('You are downgrading to a version without --update-to')
02948a17 321 self._block_restart('Cannot automatically restart to a version without --update-to')
57e0f077 322
323 directory = os.path.dirname(self.filename)
324 if not os.access(self.filename, os.W_OK):
325 return self._report_permission_error(self.filename)
326 elif not os.access(directory, os.W_OK):
327 return self._report_permission_error(directory)
328
329 new_filename, old_filename = f'{self.filename}.new', f'{self.filename}.old'
330 if detect_variant() == 'zip': # Can be replaced in-place
331 new_filename, old_filename = self.filename, None
fa57af1e 332
57e0f077 333 try:
334 if os.path.exists(old_filename or ''):
335 os.remove(old_filename)
336 except OSError:
337 return self._report_error('Unable to remove the old version')
28234287 338
57e0f077 339 try:
b1f94422 340 newcontent = self._download(self.release_name, self._tag)
d2e84d5e 341 except network_exceptions as e:
3d2623a8 342 if isinstance(e, HTTPError) and e.status == 404:
77df20f1
SS
343 return self._report_error(
344 f'The requested tag {self._label(self.target_channel, self.target_tag)} does not exist', True)
345 return self._report_network_error(f'fetch updates: {e}')
28234287 346
57e0f077 347 try:
348 expected_hash = self.release_hash
349 except Exception:
350 self.ydl.report_warning('no hash information found for the release')
351 else:
352 if hashlib.sha256(newcontent).hexdigest() != expected_hash:
353 return self._report_network_error('verify the new executable')
d5ed35b6 354
57e0f077 355 try:
356 with open(new_filename, 'wb') as outf:
357 outf.write(newcontent)
358 except OSError:
359 return self._report_permission_error(new_filename)
c487cf00 360
a6125983 361 if old_filename:
6440c45f 362 mask = os.stat(self.filename).st_mode
a6125983 363 try:
57e0f077 364 os.rename(self.filename, old_filename)
a6125983 365 except OSError:
366 return self._report_error('Unable to move current version')
367
368 try:
57e0f077 369 os.rename(new_filename, self.filename)
a6125983 370 except OSError:
371 self._report_error('Unable to overwrite current version')
372 return os.rename(old_filename, self.filename)
b5899f4f 373
5be214ab
SS
374 variant = detect_variant()
375 if variant.startswith('win') or variant == 'py2exe':
8372be74 376 atexit.register(Popen, f'ping 127.0.0.1 -n 5 -w 1000 & del /F "{old_filename}"',
377 shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
a6125983 378 elif old_filename:
379 try:
380 os.remove(old_filename)
381 except OSError:
382 self._report_error('Unable to remove the old version')
383
384 try:
6440c45f 385 os.chmod(self.filename, mask)
a6125983 386 except OSError:
387 return self._report_error(
388 f'Unable to set permissions. Run: sudo chmod a+rx {compat_shlex_quote(self.filename)}')
3bf79c75 389
77df20f1 390 self.ydl.to_screen(f'Updated yt-dlp to {self._label(self.target_channel, self.new_version)}')
8372be74 391 return True
392
393 @functools.cached_property
394 def cmd(self):
395 """The command-line to run the executable, if known"""
396 # There is no sys.orig_argv in py < 3.10. Also, it can be [] when frozen
397 if getattr(sys, 'orig_argv', None):
398 return sys.orig_argv
7aaf4cd2 399 elif getattr(sys, 'frozen', False):
8372be74 400 return sys.argv
401
402 def restart(self):
403 """Restart the executable"""
404 assert self.cmd, 'Must be frozen or Py >= 3.10'
405 self.ydl.write_debug(f'Restarting: {shell_quote(self.cmd)}')
406 _, _, returncode = Popen.run(self.cmd)
407 return returncode
57e0f077 408
02948a17 409 def _block_restart(self, msg):
410 def wrapper():
411 self._report_error(f'{msg}. Restart yt-dlp to use the updated version', expected=True)
412 return self.ydl._download_retcode
413 self.restart = wrapper
665472a7 414
44f705d0 415
57e0f077 416def run_update(ydl):
417 """Update the program file with the latest version from the repository
962ffcf8 418 @returns Whether there was a successful update (No update = False)
57e0f077 419 """
420 return Updater(ydl).update()
3bf79c75 421
5f6a1245 422
ee8dd27a 423# Deprecated
e6faf2be 424def update_self(to_screen, verbose, opener):
b5899f4f 425 import traceback
57e0f077 426
da4db748 427 deprecation_warning(f'"{__name__}.update_self" is deprecated and may be removed '
428 f'in a future version. Use "{__name__}.run_update(ydl)" instead')
e6faf2be 429
b5899f4f 430 printfn = to_screen
431
e6faf2be 432 class FakeYDL():
e6faf2be 433 to_screen = printfn
434
57e0f077 435 def report_warning(self, msg, *args, **kwargs):
b5899f4f 436 return printfn(f'WARNING: {msg}', *args, **kwargs)
e6faf2be 437
57e0f077 438 def report_error(self, msg, tb=None):
b5899f4f 439 printfn(f'ERROR: {msg}')
e6faf2be 440 if not verbose:
441 return
442 if tb is None:
b5899f4f 443 # Copied from YoutubeDL.trouble
e6faf2be 444 if sys.exc_info()[0]:
445 tb = ''
446 if hasattr(sys.exc_info()[1], 'exc_info') and sys.exc_info()[1].exc_info[0]:
447 tb += ''.join(traceback.format_exception(*sys.exc_info()[1].exc_info))
b5899f4f 448 tb += traceback.format_exc()
e6faf2be 449 else:
450 tb_data = traceback.format_list(traceback.extract_stack())
451 tb = ''.join(tb_data)
452 if tb:
453 printfn(tb)
454
57e0f077 455 def write_debug(self, msg, *args, **kwargs):
456 printfn(f'[debug] {msg}', *args, **kwargs)
457
b5899f4f 458 def urlopen(self, url):
459 return opener.open(url)
460
e6faf2be 461 return run_update(FakeYDL())
77df20f1
SS
462
463
464__all__ = ['Updater']