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