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