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