]> jfr.im git - yt-dlp.git/blame - yt_dlp/update.py
[update] Implement `--update-to` repo
[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")}')
152 self.restart = self._blocked_restart
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')
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:
77df20f1
SS
317 if isinstance(e, urllib.error.HTTPError) and e.code == 404:
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
665472a7
SS
384 def _blocked_restart(self):
385 self._report_error(
386 'Automatically restarting into custom builds is disabled for security reasons. '
387 'Restart yt-dlp to use the updated version', expected=True)
388 return self.ydl._download_retcode
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']