]> jfr.im git - yt-dlp.git/blame - yt_dlp/update.py
[devscripts] Create `utils` and refactor
[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
12from .compat import compat_realpath
b1f94422 13from .utils import (
14 Popen,
15 cached_method,
16 shell_quote,
17 system_identifier,
18 traverse_obj,
19 version_tuple,
20)
d5ed35b6
FV
21from .version import __version__
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():
b5899f4f 50 return _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',
455a15e2 67 'unknown': 'It looks like you installed yt-dlp with a package manager, pip or setup.py; Use that to update',
b5899f4f 68 'other': 'It looks like you are using an unofficial build of yt-dlp; Build the executable again',
5d535b4a 69}
70
71
72def is_non_updateable():
b5899f4f 73 return _NON_UPDATEABLE_REASONS.get(detect_variant(), _NON_UPDATEABLE_REASONS['other'])
5d535b4a 74
75
57e0f077 76def _sha256_file(path):
77 h = hashlib.sha256()
78 mv = memoryview(bytearray(128 * 1024))
79 with open(os.path.realpath(path), 'rb', buffering=0) as f:
80 for n in iter(lambda: f.readinto(mv), 0):
81 h.update(mv[:n])
82 return h.hexdigest()
83
84
85class Updater:
86 def __init__(self, ydl):
87 self.ydl = ydl
88
89 @functools.cached_property
b1f94422 90 def _tag(self):
24093d52 91 if version_tuple(__version__) >= version_tuple(self.latest_version):
a63b35a6 92 return 'latest'
93
b1f94422 94 identifier = f'{detect_variant()} {system_identifier()}'
95 for line in self._download('_update_spec', 'latest').decode().splitlines():
96 if not line.startswith('lock '):
97 continue
98 _, tag, pattern = line.split(' ', 2)
99 if re.match(pattern, identifier):
100 return f'tags/{tag}'
101 return 'latest'
102
103 @cached_method
104 def _get_version_info(self, tag):
105 self.ydl.write_debug(f'Fetching release info: {API_URL}/{tag}')
106 return json.loads(self.ydl.urlopen(f'{API_URL}/{tag}').read().decode())
57e0f077 107
108 @property
109 def current_version(self):
110 """Current version"""
111 return __version__
112
113 @property
114 def new_version(self):
24093d52 115 """Version of the latest release we can update to"""
116 if self._tag.startswith('tags/'):
117 return self._tag[5:]
b1f94422 118 return self._get_version_info(self._tag)['tag_name']
57e0f077 119
24093d52 120 @property
121 def latest_version(self):
122 """Version of the latest release"""
123 return self._get_version_info('latest')['tag_name']
124
57e0f077 125 @property
126 def has_update(self):
127 """Whether there is an update available"""
128 return version_tuple(__version__) < version_tuple(self.new_version)
129
130 @functools.cached_property
131 def filename(self):
132 """Filename of the executable"""
133 return compat_realpath(_get_variant_and_executable_path()[1])
134
b1f94422 135 def _download(self, name, tag):
136 url = traverse_obj(self._get_version_info(tag), (
57e0f077 137 'assets', lambda _, v: v['name'] == name, 'browser_download_url'), get_all=False)
138 if not url:
139 raise Exception('Unable to find download URL')
140 self.ydl.write_debug(f'Downloading {name} from {url}')
141 return self.ydl.urlopen(url).read()
142
143 @functools.cached_property
144 def release_name(self):
145 """The release filename"""
146 label = _FILE_SUFFIXES[detect_variant()]
147 if label and platform.architecture()[0][:2] == '32':
148 label = f'_x86{label}'
149 return f'yt-dlp{label}'
150
151 @functools.cached_property
152 def release_hash(self):
153 """Hash of the latest release"""
b1f94422 154 hash_data = dict(ln.split()[::-1] for ln in self._download('SHA2-256SUMS', self._tag).decode().splitlines())
57e0f077 155 return hash_data[self.release_name]
156
157 def _report_error(self, msg, expected=False):
158 self.ydl.report_error(msg, tb=False if expected else None)
159
160 def _report_permission_error(self, file):
161 self._report_error(f'Unable to write to {file}; Try running as administrator', True)
162
163 def _report_network_error(self, action, delim=';'):
164 self._report_error(f'Unable to {action}{delim} Visit https://github.com/{REPOSITORY}/releases/latest', True)
165
166 def check_update(self):
167 """Report whether there is an update available"""
168 try:
169 self.ydl.to_screen(
24093d52 170 f'Latest version: {self.latest_version}, Current version: {self.current_version}')
171 if not self.has_update:
172 if self._tag == 'latest':
173 return self.ydl.to_screen(f'yt-dlp is up to date ({__version__})')
174 return self.ydl.report_warning(
175 'yt-dlp cannot be updated any further since you are on an older Python version')
57e0f077 176 except Exception:
177 return self._report_network_error('obtain version info', delim='; Please try again later or')
e6faf2be 178
57e0f077 179 if not is_non_updateable():
180 self.ydl.to_screen(f'Current Build Hash {_sha256_file(self.filename)}')
181 return True
c19bc311 182
57e0f077 183 def update(self):
184 """Update yt-dlp executable to the latest version"""
185 if not self.check_update():
186 return
187 err = is_non_updateable()
188 if err:
189 return self._report_error(err, True)
190 self.ydl.to_screen(f'Updating to version {self.new_version} ...')
191
192 directory = os.path.dirname(self.filename)
193 if not os.access(self.filename, os.W_OK):
194 return self._report_permission_error(self.filename)
195 elif not os.access(directory, os.W_OK):
196 return self._report_permission_error(directory)
197
198 new_filename, old_filename = f'{self.filename}.new', f'{self.filename}.old'
199 if detect_variant() == 'zip': # Can be replaced in-place
200 new_filename, old_filename = self.filename, None
fa57af1e 201
57e0f077 202 try:
203 if os.path.exists(old_filename or ''):
204 os.remove(old_filename)
205 except OSError:
206 return self._report_error('Unable to remove the old version')
28234287 207
57e0f077 208 try:
b1f94422 209 newcontent = self._download(self.release_name, self._tag)
57e0f077 210 except OSError:
211 return self._report_network_error('download latest version')
212 except Exception:
213 return self._report_network_error('fetch updates')
28234287 214
57e0f077 215 try:
216 expected_hash = self.release_hash
217 except Exception:
218 self.ydl.report_warning('no hash information found for the release')
219 else:
220 if hashlib.sha256(newcontent).hexdigest() != expected_hash:
221 return self._report_network_error('verify the new executable')
d5ed35b6 222
57e0f077 223 try:
224 with open(new_filename, 'wb') as outf:
225 outf.write(newcontent)
226 except OSError:
227 return self._report_permission_error(new_filename)
c487cf00 228
57e0f077 229 try:
230 if old_filename:
231 os.rename(self.filename, old_filename)
232 except OSError:
233 return self._report_error('Unable to move current version')
234 try:
235 if old_filename:
236 os.rename(new_filename, self.filename)
237 except OSError:
238 self._report_error('Unable to overwrite current version')
239 return os.rename(old_filename, self.filename)
b5899f4f 240
57e0f077 241 if detect_variant() not in ('win32_exe', 'py2exe'):
242 if old_filename:
243 os.remove(old_filename)
8372be74 244 else:
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)
3bf79c75 247
8372be74 248 self.ydl.to_screen(f'Updated yt-dlp to version {self.new_version}')
249 return True
250
251 @functools.cached_property
252 def cmd(self):
253 """The command-line to run the executable, if known"""
254 # There is no sys.orig_argv in py < 3.10. Also, it can be [] when frozen
255 if getattr(sys, 'orig_argv', None):
256 return sys.orig_argv
257 elif hasattr(sys, 'frozen'):
258 return sys.argv
259
260 def restart(self):
261 """Restart the executable"""
262 assert self.cmd, 'Must be frozen or Py >= 3.10'
263 self.ydl.write_debug(f'Restarting: {shell_quote(self.cmd)}')
264 _, _, returncode = Popen.run(self.cmd)
265 return returncode
57e0f077 266
44f705d0 267
57e0f077 268def run_update(ydl):
269 """Update the program file with the latest version from the repository
962ffcf8 270 @returns Whether there was a successful update (No update = False)
57e0f077 271 """
272 return Updater(ydl).update()
3bf79c75 273
5f6a1245 274
ee8dd27a 275# Deprecated
e6faf2be 276def update_self(to_screen, verbose, opener):
b5899f4f 277 import traceback
57e0f077 278
b5899f4f 279 from .utils import write_string
e6faf2be 280
ee8dd27a 281 write_string(
282 'DeprecationWarning: "yt_dlp.update.update_self" is deprecated and may be removed in a future version. '
b69fd25c 283 'Use "yt_dlp.update.run_update(ydl)" instead\n')
e6faf2be 284
b5899f4f 285 printfn = to_screen
286
e6faf2be 287 class FakeYDL():
e6faf2be 288 to_screen = printfn
289
57e0f077 290 def report_warning(self, msg, *args, **kwargs):
b5899f4f 291 return printfn(f'WARNING: {msg}', *args, **kwargs)
e6faf2be 292
57e0f077 293 def report_error(self, msg, tb=None):
b5899f4f 294 printfn(f'ERROR: {msg}')
e6faf2be 295 if not verbose:
296 return
297 if tb is None:
b5899f4f 298 # Copied from YoutubeDL.trouble
e6faf2be 299 if sys.exc_info()[0]:
300 tb = ''
301 if hasattr(sys.exc_info()[1], 'exc_info') and sys.exc_info()[1].exc_info[0]:
302 tb += ''.join(traceback.format_exception(*sys.exc_info()[1].exc_info))
b5899f4f 303 tb += traceback.format_exc()
e6faf2be 304 else:
305 tb_data = traceback.format_list(traceback.extract_stack())
306 tb = ''.join(tb_data)
307 if tb:
308 printfn(tb)
309
57e0f077 310 def write_debug(self, msg, *args, **kwargs):
311 printfn(f'[debug] {msg}', *args, **kwargs)
312
b5899f4f 313 def urlopen(self, url):
314 return opener.open(url)
315
e6faf2be 316 return run_update(FakeYDL())