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