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