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