]> jfr.im git - yt-dlp.git/blame - yt_dlp/update.py
[test] Fix `test_load_certifi`
[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
57e0f077 115def _sha256_file(path):
116 h = hashlib.sha256()
117 mv = memoryview(bytearray(128 * 1024))
118 with open(os.path.realpath(path), 'rb', buffering=0) as f:
119 for n in iter(lambda: f.readinto(mv), 0):
120 h.update(mv[:n])
121 return h.hexdigest()
122
123
124class Updater:
77df20f1
SS
125 _exact = True
126
127 def __init__(self, ydl, target=None):
57e0f077 128 self.ydl = ydl
129
77df20f1 130 self.target_channel, sep, self.target_tag = (target or CHANNEL).rpartition('@')
665472a7
SS
131 # stable => stable@latest
132 if not sep and ('/' in self.target_tag or self.target_tag in UPDATE_SOURCES):
133 self.target_channel = self.target_tag
134 self.target_tag = None
77df20f1 135 elif not self.target_channel:
665472a7 136 self.target_channel = CHANNEL.partition('@')[0]
77df20f1
SS
137
138 if not self.target_tag:
665472a7
SS
139 self.target_tag = 'latest'
140 self._exact = False
77df20f1
SS
141 elif self.target_tag != 'latest':
142 self.target_tag = f'tags/{self.target_tag}'
143
665472a7
SS
144 if '/' in self.target_channel:
145 self._target_repo = self.target_channel
146 if self.target_channel not in (CHANNEL, *UPDATE_SOURCES.values()):
147 self.ydl.report_warning(
148 f'You are switching to an {self.ydl._format_err("unofficial", "red")} executable '
149 f'from {self.ydl._format_err(self._target_repo, self.ydl.Styles.EMPHASIS)}. '
150 f'Run {self.ydl._format_err("at your own risk", "light red")}')
02948a17 151 self._block_restart('Automatically restarting into custom builds is disabled for security reasons')
665472a7
SS
152 else:
153 self._target_repo = UPDATE_SOURCES.get(self.target_channel)
154 if not self._target_repo:
155 self._report_error(
156 f'Invalid update channel {self.target_channel!r} requested. '
157 f'Valid channels are {", ".join(UPDATE_SOURCES)}', True)
77df20f1
SS
158
159 def _version_compare(self, a, b, channel=CHANNEL):
665472a7 160 if self._exact and channel != self.target_channel:
77df20f1
SS
161 return False
162
163 if _VERSION_RE.fullmatch(f'{a}.{b}'):
164 a, b = version_tuple(a), version_tuple(b)
165 return a == b if self._exact else a >= b
166 return a == b
167
57e0f077 168 @functools.cached_property
b1f94422 169 def _tag(self):
77df20f1
SS
170 if self._version_compare(self.current_version, self.latest_version):
171 return self.target_tag
a63b35a6 172
77df20f1 173 identifier = f'{detect_variant()} {self.target_channel} {system_identifier()}'
b1f94422 174 for line in self._download('_update_spec', 'latest').decode().splitlines():
175 if not line.startswith('lock '):
176 continue
177 _, tag, pattern = line.split(' ', 2)
178 if re.match(pattern, identifier):
77df20f1
SS
179 if not self._exact:
180 return f'tags/{tag}'
181 elif self.target_tag == 'latest' or not self._version_compare(
182 tag, self.target_tag[5:], channel=self.target_channel):
183 self._report_error(
184 f'yt-dlp cannot be updated above {tag} since you are on an older Python version', True)
185 return f'tags/{self.current_version}'
186 return self.target_tag
b1f94422 187
188 @cached_method
189 def _get_version_info(self, tag):
77df20f1
SS
190 url = f'{API_BASE_URL}/{self._target_repo}/releases/{tag}'
191 self.ydl.write_debug(f'Fetching release info: {url}')
3d2623a8 192 return json.loads(self.ydl.urlopen(Request(url, headers={
77df20f1
SS
193 'Accept': 'application/vnd.github+json',
194 'User-Agent': 'yt-dlp',
195 'X-GitHub-Api-Version': '2022-11-28',
196 })).read().decode())
57e0f077 197
198 @property
199 def current_version(self):
200 """Current version"""
201 return __version__
202
77df20f1
SS
203 @staticmethod
204 def _label(channel, tag):
205 """Label for a given channel and tag"""
206 return f'{channel}@{remove_start(tag, "tags/")}'
207
208 def _get_actual_tag(self, tag):
209 if tag.startswith('tags/'):
210 return tag[5:]
211 return self._get_version_info(tag)['tag_name']
212
57e0f077 213 @property
214 def new_version(self):
24093d52 215 """Version of the latest release we can update to"""
77df20f1 216 return self._get_actual_tag(self._tag)
57e0f077 217
24093d52 218 @property
219 def latest_version(self):
77df20f1
SS
220 """Version of the target release"""
221 return self._get_actual_tag(self.target_tag)
24093d52 222
57e0f077 223 @property
224 def has_update(self):
225 """Whether there is an update available"""
77df20f1 226 return not self._version_compare(self.current_version, self.new_version)
57e0f077 227
228 @functools.cached_property
229 def filename(self):
230 """Filename of the executable"""
231 return compat_realpath(_get_variant_and_executable_path()[1])
232
b1f94422 233 def _download(self, name, tag):
77df20f1
SS
234 slug = 'latest/download' if tag == 'latest' else f'download/{tag[5:]}'
235 url = f'https://github.com/{self._target_repo}/releases/{slug}/{name}'
57e0f077 236 self.ydl.write_debug(f'Downloading {name} from {url}')
237 return self.ydl.urlopen(url).read()
238
239 @functools.cached_property
240 def release_name(self):
241 """The release filename"""
17fc3dc4 242 return f'yt-dlp{_FILE_SUFFIXES[detect_variant()]}'
57e0f077 243
244 @functools.cached_property
245 def release_hash(self):
246 """Hash of the latest release"""
b1f94422 247 hash_data = dict(ln.split()[::-1] for ln in self._download('SHA2-256SUMS', self._tag).decode().splitlines())
57e0f077 248 return hash_data[self.release_name]
249
250 def _report_error(self, msg, expected=False):
251 self.ydl.report_error(msg, tb=False if expected else None)
ff48fc04 252 self.ydl._download_retcode = 100
57e0f077 253
254 def _report_permission_error(self, file):
255 self._report_error(f'Unable to write to {file}; Try running as administrator', True)
256
257 def _report_network_error(self, action, delim=';'):
77df20f1
SS
258 self._report_error(
259 f'Unable to {action}{delim} visit '
260 f'https://github.com/{self._target_repo}/releases/{self.target_tag.replace("tags/", "tag/")}', True)
57e0f077 261
262 def check_update(self):
263 """Report whether there is an update available"""
77df20f1
SS
264 if not self._target_repo:
265 return False
57e0f077 266 try:
77df20f1
SS
267 self.ydl.to_screen((
268 f'Available version: {self._label(self.target_channel, self.latest_version)}, ' if self.target_tag == 'latest' else ''
269 ) + f'Current version: {self._label(CHANNEL, self.current_version)}')
d2e84d5e
SS
270 except network_exceptions as e:
271 return self._report_network_error(f'obtain version info ({e})', delim='; Please try again later or')
e6faf2be 272
57e0f077 273 if not is_non_updateable():
77df20f1
SS
274 self.ydl.to_screen(f'Current Build Hash: {_sha256_file(self.filename)}')
275
276 if self.has_update:
277 return True
278
279 if self.target_tag == self._tag:
280 self.ydl.to_screen(f'yt-dlp is up to date ({self._label(CHANNEL, self.current_version)})')
281 elif not self._exact:
282 self.ydl.report_warning('yt-dlp cannot be updated any further since you are on an older Python version')
283 return False
c19bc311 284
57e0f077 285 def update(self):
286 """Update yt-dlp executable to the latest version"""
287 if not self.check_update():
288 return
289 err = is_non_updateable()
290 if err:
291 return self._report_error(err, True)
77df20f1
SS
292 self.ydl.to_screen(f'Updating to {self._label(self.target_channel, self.new_version)} ...')
293 if (_VERSION_RE.fullmatch(self.target_tag[5:])
294 and version_tuple(self.target_tag[5:]) < (2023, 3, 2)):
295 self.ydl.report_warning('You are downgrading to a version without --update-to')
02948a17 296 self._block_restart('Cannot automatically restart to a version without --update-to')
57e0f077 297
298 directory = os.path.dirname(self.filename)
299 if not os.access(self.filename, os.W_OK):
300 return self._report_permission_error(self.filename)
301 elif not os.access(directory, os.W_OK):
302 return self._report_permission_error(directory)
303
304 new_filename, old_filename = f'{self.filename}.new', f'{self.filename}.old'
305 if detect_variant() == 'zip': # Can be replaced in-place
306 new_filename, old_filename = self.filename, None
fa57af1e 307
57e0f077 308 try:
309 if os.path.exists(old_filename or ''):
310 os.remove(old_filename)
311 except OSError:
312 return self._report_error('Unable to remove the old version')
28234287 313
57e0f077 314 try:
b1f94422 315 newcontent = self._download(self.release_name, self._tag)
d2e84d5e 316 except network_exceptions as e:
3d2623a8 317 if isinstance(e, HTTPError) and e.status == 404:
77df20f1
SS
318 return self._report_error(
319 f'The requested tag {self._label(self.target_channel, self.target_tag)} does not exist', True)
320 return self._report_network_error(f'fetch updates: {e}')
28234287 321
57e0f077 322 try:
323 expected_hash = self.release_hash
324 except Exception:
325 self.ydl.report_warning('no hash information found for the release')
326 else:
327 if hashlib.sha256(newcontent).hexdigest() != expected_hash:
328 return self._report_network_error('verify the new executable')
d5ed35b6 329
57e0f077 330 try:
331 with open(new_filename, 'wb') as outf:
332 outf.write(newcontent)
333 except OSError:
334 return self._report_permission_error(new_filename)
c487cf00 335
a6125983 336 if old_filename:
6440c45f 337 mask = os.stat(self.filename).st_mode
a6125983 338 try:
57e0f077 339 os.rename(self.filename, old_filename)
a6125983 340 except OSError:
341 return self._report_error('Unable to move current version')
342
343 try:
57e0f077 344 os.rename(new_filename, self.filename)
a6125983 345 except OSError:
346 self._report_error('Unable to overwrite current version')
347 return os.rename(old_filename, self.filename)
b5899f4f 348
5be214ab
SS
349 variant = detect_variant()
350 if variant.startswith('win') or variant == 'py2exe':
8372be74 351 atexit.register(Popen, f'ping 127.0.0.1 -n 5 -w 1000 & del /F "{old_filename}"',
352 shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
a6125983 353 elif old_filename:
354 try:
355 os.remove(old_filename)
356 except OSError:
357 self._report_error('Unable to remove the old version')
358
359 try:
6440c45f 360 os.chmod(self.filename, mask)
a6125983 361 except OSError:
362 return self._report_error(
363 f'Unable to set permissions. Run: sudo chmod a+rx {compat_shlex_quote(self.filename)}')
3bf79c75 364
77df20f1 365 self.ydl.to_screen(f'Updated yt-dlp to {self._label(self.target_channel, self.new_version)}')
8372be74 366 return True
367
368 @functools.cached_property
369 def cmd(self):
370 """The command-line to run the executable, if known"""
371 # There is no sys.orig_argv in py < 3.10. Also, it can be [] when frozen
372 if getattr(sys, 'orig_argv', None):
373 return sys.orig_argv
7aaf4cd2 374 elif getattr(sys, 'frozen', False):
8372be74 375 return sys.argv
376
377 def restart(self):
378 """Restart the executable"""
379 assert self.cmd, 'Must be frozen or Py >= 3.10'
380 self.ydl.write_debug(f'Restarting: {shell_quote(self.cmd)}')
381 _, _, returncode = Popen.run(self.cmd)
382 return returncode
57e0f077 383
02948a17 384 def _block_restart(self, msg):
385 def wrapper():
386 self._report_error(f'{msg}. Restart yt-dlp to use the updated version', expected=True)
387 return self.ydl._download_retcode
388 self.restart = wrapper
665472a7 389
44f705d0 390
57e0f077 391def run_update(ydl):
392 """Update the program file with the latest version from the repository
962ffcf8 393 @returns Whether there was a successful update (No update = False)
57e0f077 394 """
395 return Updater(ydl).update()
3bf79c75 396
5f6a1245 397
ee8dd27a 398# Deprecated
e6faf2be 399def update_self(to_screen, verbose, opener):
b5899f4f 400 import traceback
57e0f077 401
da4db748 402 deprecation_warning(f'"{__name__}.update_self" is deprecated and may be removed '
403 f'in a future version. Use "{__name__}.run_update(ydl)" instead')
e6faf2be 404
b5899f4f 405 printfn = to_screen
406
e6faf2be 407 class FakeYDL():
e6faf2be 408 to_screen = printfn
409
57e0f077 410 def report_warning(self, msg, *args, **kwargs):
b5899f4f 411 return printfn(f'WARNING: {msg}', *args, **kwargs)
e6faf2be 412
57e0f077 413 def report_error(self, msg, tb=None):
b5899f4f 414 printfn(f'ERROR: {msg}')
e6faf2be 415 if not verbose:
416 return
417 if tb is None:
b5899f4f 418 # Copied from YoutubeDL.trouble
e6faf2be 419 if sys.exc_info()[0]:
420 tb = ''
421 if hasattr(sys.exc_info()[1], 'exc_info') and sys.exc_info()[1].exc_info[0]:
422 tb += ''.join(traceback.format_exception(*sys.exc_info()[1].exc_info))
b5899f4f 423 tb += traceback.format_exc()
e6faf2be 424 else:
425 tb_data = traceback.format_list(traceback.extract_stack())
426 tb = ''.join(tb_data)
427 if tb:
428 printfn(tb)
429
57e0f077 430 def write_debug(self, msg, *args, **kwargs):
431 printfn(f'[debug] {msg}', *args, **kwargs)
432
b5899f4f 433 def urlopen(self, url):
434 return opener.open(url)
435
e6faf2be 436 return run_update(FakeYDL())
77df20f1
SS
437
438
439__all__ = ['Updater']