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