]> jfr.im git - yt-dlp.git/blame - yt_dlp/update.py
[extractor/toggo] Improve `_VALID_URL` (#4663)
[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:
233 try:
57e0f077 234 os.rename(self.filename, old_filename)
a6125983 235 except OSError:
236 return self._report_error('Unable to move current version')
237
238 try:
57e0f077 239 os.rename(new_filename, self.filename)
a6125983 240 except OSError:
241 self._report_error('Unable to overwrite current version')
242 return os.rename(old_filename, self.filename)
b5899f4f 243
a6125983 244 if detect_variant() in ('win32_exe', 'py2exe'):
8372be74 245 atexit.register(Popen, f'ping 127.0.0.1 -n 5 -w 1000 & del /F "{old_filename}"',
246 shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
a6125983 247 elif old_filename:
248 try:
249 os.remove(old_filename)
250 except OSError:
251 self._report_error('Unable to remove the old version')
252
253 try:
254 os.chmod(self.filename, 0o777)
255 except OSError:
256 return self._report_error(
257 f'Unable to set permissions. Run: sudo chmod a+rx {compat_shlex_quote(self.filename)}')
3bf79c75 258
8372be74 259 self.ydl.to_screen(f'Updated yt-dlp to version {self.new_version}')
260 return True
261
262 @functools.cached_property
263 def cmd(self):
264 """The command-line to run the executable, if known"""
265 # There is no sys.orig_argv in py < 3.10. Also, it can be [] when frozen
266 if getattr(sys, 'orig_argv', None):
267 return sys.orig_argv
268 elif hasattr(sys, 'frozen'):
269 return sys.argv
270
271 def restart(self):
272 """Restart the executable"""
273 assert self.cmd, 'Must be frozen or Py >= 3.10'
274 self.ydl.write_debug(f'Restarting: {shell_quote(self.cmd)}')
275 _, _, returncode = Popen.run(self.cmd)
276 return returncode
57e0f077 277
44f705d0 278
57e0f077 279def run_update(ydl):
280 """Update the program file with the latest version from the repository
962ffcf8 281 @returns Whether there was a successful update (No update = False)
57e0f077 282 """
283 return Updater(ydl).update()
3bf79c75 284
5f6a1245 285
ee8dd27a 286# Deprecated
e6faf2be 287def update_self(to_screen, verbose, opener):
b5899f4f 288 import traceback
57e0f077 289
b5899f4f 290 from .utils import write_string
e6faf2be 291
ee8dd27a 292 write_string(
293 'DeprecationWarning: "yt_dlp.update.update_self" is deprecated and may be removed in a future version. '
b69fd25c 294 'Use "yt_dlp.update.run_update(ydl)" instead\n')
e6faf2be 295
b5899f4f 296 printfn = to_screen
297
e6faf2be 298 class FakeYDL():
e6faf2be 299 to_screen = printfn
300
57e0f077 301 def report_warning(self, msg, *args, **kwargs):
b5899f4f 302 return printfn(f'WARNING: {msg}', *args, **kwargs)
e6faf2be 303
57e0f077 304 def report_error(self, msg, tb=None):
b5899f4f 305 printfn(f'ERROR: {msg}')
e6faf2be 306 if not verbose:
307 return
308 if tb is None:
b5899f4f 309 # Copied from YoutubeDL.trouble
e6faf2be 310 if sys.exc_info()[0]:
311 tb = ''
312 if hasattr(sys.exc_info()[1], 'exc_info') and sys.exc_info()[1].exc_info[0]:
313 tb += ''.join(traceback.format_exception(*sys.exc_info()[1].exc_info))
b5899f4f 314 tb += traceback.format_exc()
e6faf2be 315 else:
316 tb_data = traceback.format_list(traceback.extract_stack())
317 tb = ''.join(tb_data)
318 if tb:
319 printfn(tb)
320
57e0f077 321 def write_debug(self, msg, *args, **kwargs):
322 printfn(f'[debug] {msg}', *args, **kwargs)
323
b5899f4f 324 def urlopen(self, url):
325 return opener.open(url)
326
e6faf2be 327 return run_update(FakeYDL())