]> jfr.im git - yt-dlp.git/blame - yt_dlp/update.py
[extractor/biliIntl] Add fallback to `video_data` (#5971)
[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
b1f94422 14from .utils import (
15 Popen,
16 cached_method,
da4db748 17 deprecation_warning,
b1f94422 18 shell_quote,
19 system_identifier,
20 traverse_obj,
21 version_tuple,
22)
70b23409 23from .version import UPDATE_HINT, VARIANT, __version__
d5ed35b6 24
57e0f077 25REPOSITORY = 'yt-dlp/yt-dlp'
b1f94422 26API_URL = f'https://api.github.com/repos/{REPOSITORY}/releases'
b5899f4f 27
28
0b9c08b4 29@functools.cache
b5899f4f 30def _get_variant_and_executable_path():
c487cf00 31 """@returns (variant, executable_path)"""
7aaf4cd2 32 if getattr(sys, 'frozen', False):
c487cf00 33 path = sys.executable
b5899f4f 34 if not hasattr(sys, '_MEIPASS'):
35 return 'py2exe', path
7aaf4cd2 36 elif sys._MEIPASS == os.path.dirname(path):
b5899f4f 37 return f'{sys.platform}_dir', path
7aaf4cd2 38 elif sys.platform == 'darwin':
17fc3dc4
M
39 machine = '_legacy' if version_tuple(platform.mac_ver()[0]) < (10, 15) else ''
40 else:
41 machine = f'_{platform.machine().lower()}'
42 # Ref: https://en.wikipedia.org/wiki/Uname#Examples
43 if machine[1:] in ('x86', 'x86_64', 'amd64', 'i386', 'i686'):
44 machine = '_x86' if platform.architecture()[0][:2] == '32' else ''
2fb0f858 45 # NB: https://github.com/yt-dlp/yt-dlp/issues/5632
46 return f'{sys.platform}{machine}_exe', path
b5899f4f 47
48 path = os.path.dirname(__file__)
c487cf00 49 if isinstance(__loader__, zipimporter):
50 return 'zip', os.path.join(path, '..')
233ad894 51 elif (os.path.basename(sys.argv[0]) in ('__main__.py', '-m')
52 and os.path.exists(os.path.join(path, '../.git/HEAD'))):
c487cf00 53 return 'source', path
54 return 'unknown', path
55
56
57def detect_variant():
70b23409 58 return VARIANT or _get_variant_and_executable_path()[0]
4c88ff87 59
60
b5e7a2e6 61@functools.cache
62def current_git_head():
63 if detect_variant() != 'source':
64 return
65 with contextlib.suppress(Exception):
66 stdout, _, _ = Popen.run(
67 ['git', 'rev-parse', '--short', 'HEAD'],
68 text=True, cwd=os.path.dirname(os.path.abspath(__file__)),
69 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
70 if re.fullmatch('[0-9a-f]+', stdout.strip()):
71 return stdout.strip()
72
73
b5899f4f 74_FILE_SUFFIXES = {
75 'zip': '',
76 'py2exe': '_min.exe',
2fb0f858 77 'win32_exe': '.exe',
78 'win32_x86_exe': '_x86.exe',
b5899f4f 79 'darwin_exe': '_macos',
63da2d09 80 'darwin_legacy_exe': '_macos_legacy',
e4afcfde 81 'linux_exe': '_linux',
17fc3dc4
M
82 'linux_aarch64_exe': '_linux_aarch64',
83 'linux_armv7l_exe': '_linux_armv7l',
b5899f4f 84}
85
5d535b4a 86_NON_UPDATEABLE_REASONS = {
b5899f4f 87 **{variant: None for variant in _FILE_SUFFIXES}, # Updatable
88 **{variant: f'Auto-update is not supported for unpackaged {name} executable; Re-download the latest release'
e4afcfde 89 for variant, name in {'win32_dir': 'Windows', 'darwin_dir': 'MacOS', 'linux_dir': 'Linux'}.items()},
e6faf2be 90 'source': 'You cannot update when running from source code; Use git to pull the latest changes',
70b23409 91 'unknown': 'You installed yt-dlp with a package manager or setup.py; Use that to update',
92 'other': 'You are using an unofficial build of yt-dlp; Build the executable again',
5d535b4a 93}
94
95
96def is_non_updateable():
70b23409 97 if UPDATE_HINT:
98 return UPDATE_HINT
99 return _NON_UPDATEABLE_REASONS.get(
100 detect_variant(), _NON_UPDATEABLE_REASONS['unknown' if VARIANT else 'other'])
5d535b4a 101
102
57e0f077 103def _sha256_file(path):
104 h = hashlib.sha256()
105 mv = memoryview(bytearray(128 * 1024))
106 with open(os.path.realpath(path), 'rb', buffering=0) as f:
107 for n in iter(lambda: f.readinto(mv), 0):
108 h.update(mv[:n])
109 return h.hexdigest()
110
111
112class Updater:
113 def __init__(self, ydl):
114 self.ydl = ydl
115
116 @functools.cached_property
b1f94422 117 def _tag(self):
24093d52 118 if version_tuple(__version__) >= version_tuple(self.latest_version):
a63b35a6 119 return 'latest'
120
b1f94422 121 identifier = f'{detect_variant()} {system_identifier()}'
122 for line in self._download('_update_spec', 'latest').decode().splitlines():
123 if not line.startswith('lock '):
124 continue
125 _, tag, pattern = line.split(' ', 2)
126 if re.match(pattern, identifier):
127 return f'tags/{tag}'
128 return 'latest'
129
130 @cached_method
131 def _get_version_info(self, tag):
132 self.ydl.write_debug(f'Fetching release info: {API_URL}/{tag}')
133 return json.loads(self.ydl.urlopen(f'{API_URL}/{tag}').read().decode())
57e0f077 134
135 @property
136 def current_version(self):
137 """Current version"""
138 return __version__
139
140 @property
141 def new_version(self):
24093d52 142 """Version of the latest release we can update to"""
143 if self._tag.startswith('tags/'):
144 return self._tag[5:]
b1f94422 145 return self._get_version_info(self._tag)['tag_name']
57e0f077 146
24093d52 147 @property
148 def latest_version(self):
149 """Version of the latest release"""
150 return self._get_version_info('latest')['tag_name']
151
57e0f077 152 @property
153 def has_update(self):
154 """Whether there is an update available"""
155 return version_tuple(__version__) < version_tuple(self.new_version)
156
157 @functools.cached_property
158 def filename(self):
159 """Filename of the executable"""
160 return compat_realpath(_get_variant_and_executable_path()[1])
161
b1f94422 162 def _download(self, name, tag):
163 url = traverse_obj(self._get_version_info(tag), (
57e0f077 164 'assets', lambda _, v: v['name'] == name, 'browser_download_url'), get_all=False)
165 if not url:
166 raise Exception('Unable to find download URL')
167 self.ydl.write_debug(f'Downloading {name} from {url}')
168 return self.ydl.urlopen(url).read()
169
170 @functools.cached_property
171 def release_name(self):
172 """The release filename"""
17fc3dc4 173 return f'yt-dlp{_FILE_SUFFIXES[detect_variant()]}'
57e0f077 174
175 @functools.cached_property
176 def release_hash(self):
177 """Hash of the latest release"""
b1f94422 178 hash_data = dict(ln.split()[::-1] for ln in self._download('SHA2-256SUMS', self._tag).decode().splitlines())
57e0f077 179 return hash_data[self.release_name]
180
181 def _report_error(self, msg, expected=False):
182 self.ydl.report_error(msg, tb=False if expected else None)
ff48fc04 183 self.ydl._download_retcode = 100
57e0f077 184
185 def _report_permission_error(self, file):
186 self._report_error(f'Unable to write to {file}; Try running as administrator', True)
187
188 def _report_network_error(self, action, delim=';'):
189 self._report_error(f'Unable to {action}{delim} Visit https://github.com/{REPOSITORY}/releases/latest', True)
190
191 def check_update(self):
192 """Report whether there is an update available"""
193 try:
194 self.ydl.to_screen(
24093d52 195 f'Latest version: {self.latest_version}, Current version: {self.current_version}')
196 if not self.has_update:
197 if self._tag == 'latest':
198 return self.ydl.to_screen(f'yt-dlp is up to date ({__version__})')
199 return self.ydl.report_warning(
200 'yt-dlp cannot be updated any further since you are on an older Python version')
57e0f077 201 except Exception:
202 return self._report_network_error('obtain version info', delim='; Please try again later or')
e6faf2be 203
57e0f077 204 if not is_non_updateable():
205 self.ydl.to_screen(f'Current Build Hash {_sha256_file(self.filename)}')
206 return True
c19bc311 207
57e0f077 208 def update(self):
209 """Update yt-dlp executable to the latest version"""
210 if not self.check_update():
211 return
212 err = is_non_updateable()
213 if err:
214 return self._report_error(err, True)
215 self.ydl.to_screen(f'Updating to version {self.new_version} ...')
216
217 directory = os.path.dirname(self.filename)
218 if not os.access(self.filename, os.W_OK):
219 return self._report_permission_error(self.filename)
220 elif not os.access(directory, os.W_OK):
221 return self._report_permission_error(directory)
222
223 new_filename, old_filename = f'{self.filename}.new', f'{self.filename}.old'
224 if detect_variant() == 'zip': # Can be replaced in-place
225 new_filename, old_filename = self.filename, None
fa57af1e 226
57e0f077 227 try:
228 if os.path.exists(old_filename or ''):
229 os.remove(old_filename)
230 except OSError:
231 return self._report_error('Unable to remove the old version')
28234287 232
57e0f077 233 try:
b1f94422 234 newcontent = self._download(self.release_name, self._tag)
57e0f077 235 except OSError:
236 return self._report_network_error('download latest version')
237 except Exception:
238 return self._report_network_error('fetch updates')
28234287 239
57e0f077 240 try:
241 expected_hash = self.release_hash
242 except Exception:
243 self.ydl.report_warning('no hash information found for the release')
244 else:
245 if hashlib.sha256(newcontent).hexdigest() != expected_hash:
246 return self._report_network_error('verify the new executable')
d5ed35b6 247
57e0f077 248 try:
249 with open(new_filename, 'wb') as outf:
250 outf.write(newcontent)
251 except OSError:
252 return self._report_permission_error(new_filename)
c487cf00 253
a6125983 254 if old_filename:
6440c45f 255 mask = os.stat(self.filename).st_mode
a6125983 256 try:
57e0f077 257 os.rename(self.filename, old_filename)
a6125983 258 except OSError:
259 return self._report_error('Unable to move current version')
260
261 try:
57e0f077 262 os.rename(new_filename, self.filename)
a6125983 263 except OSError:
264 self._report_error('Unable to overwrite current version')
265 return os.rename(old_filename, self.filename)
b5899f4f 266
a6125983 267 if detect_variant() in ('win32_exe', 'py2exe'):
8372be74 268 atexit.register(Popen, f'ping 127.0.0.1 -n 5 -w 1000 & del /F "{old_filename}"',
269 shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
a6125983 270 elif old_filename:
271 try:
272 os.remove(old_filename)
273 except OSError:
274 self._report_error('Unable to remove the old version')
275
276 try:
6440c45f 277 os.chmod(self.filename, mask)
a6125983 278 except OSError:
279 return self._report_error(
280 f'Unable to set permissions. Run: sudo chmod a+rx {compat_shlex_quote(self.filename)}')
3bf79c75 281
8372be74 282 self.ydl.to_screen(f'Updated yt-dlp to version {self.new_version}')
283 return True
284
285 @functools.cached_property
286 def cmd(self):
287 """The command-line to run the executable, if known"""
288 # There is no sys.orig_argv in py < 3.10. Also, it can be [] when frozen
289 if getattr(sys, 'orig_argv', None):
290 return sys.orig_argv
7aaf4cd2 291 elif getattr(sys, 'frozen', False):
8372be74 292 return sys.argv
293
294 def restart(self):
295 """Restart the executable"""
296 assert self.cmd, 'Must be frozen or Py >= 3.10'
297 self.ydl.write_debug(f'Restarting: {shell_quote(self.cmd)}')
298 _, _, returncode = Popen.run(self.cmd)
299 return returncode
57e0f077 300
44f705d0 301
57e0f077 302def run_update(ydl):
303 """Update the program file with the latest version from the repository
962ffcf8 304 @returns Whether there was a successful update (No update = False)
57e0f077 305 """
306 return Updater(ydl).update()
3bf79c75 307
5f6a1245 308
ee8dd27a 309# Deprecated
e6faf2be 310def update_self(to_screen, verbose, opener):
b5899f4f 311 import traceback
57e0f077 312
da4db748 313 deprecation_warning(f'"{__name__}.update_self" is deprecated and may be removed '
314 f'in a future version. Use "{__name__}.run_update(ydl)" instead')
e6faf2be 315
b5899f4f 316 printfn = to_screen
317
e6faf2be 318 class FakeYDL():
e6faf2be 319 to_screen = printfn
320
57e0f077 321 def report_warning(self, msg, *args, **kwargs):
b5899f4f 322 return printfn(f'WARNING: {msg}', *args, **kwargs)
e6faf2be 323
57e0f077 324 def report_error(self, msg, tb=None):
b5899f4f 325 printfn(f'ERROR: {msg}')
e6faf2be 326 if not verbose:
327 return
328 if tb is None:
b5899f4f 329 # Copied from YoutubeDL.trouble
e6faf2be 330 if sys.exc_info()[0]:
331 tb = ''
332 if hasattr(sys.exc_info()[1], 'exc_info') and sys.exc_info()[1].exc_info[0]:
333 tb += ''.join(traceback.format_exception(*sys.exc_info()[1].exc_info))
b5899f4f 334 tb += traceback.format_exc()
e6faf2be 335 else:
336 tb_data = traceback.format_list(traceback.extract_stack())
337 tb = ''.join(tb_data)
338 if tb:
339 printfn(tb)
340
57e0f077 341 def write_debug(self, msg, *args, **kwargs):
342 printfn(f'[debug] {msg}', *args, **kwargs)
343
b5899f4f 344 def urlopen(self, url):
345 return opener.open(url)
346
e6faf2be 347 return run_update(FakeYDL())