update.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import atexit
  2. import hashlib
  3. import json
  4. import os
  5. import platform
  6. import subprocess
  7. import sys
  8. from zipimport import zipimporter
  9. from .compat import functools # isort: split
  10. from .compat import compat_realpath
  11. from .utils import Popen, shell_quote, traverse_obj, version_tuple
  12. from .version import __version__
  13. REPOSITORY = 'yt-dlp/yt-dlp'
  14. API_URL = f'https://api.github.com/repos/{REPOSITORY}/releases/latest'
  15. @functools.cache
  16. def _get_variant_and_executable_path():
  17. """@returns (variant, executable_path)"""
  18. if hasattr(sys, 'frozen'):
  19. path = sys.executable
  20. if not hasattr(sys, '_MEIPASS'):
  21. return 'py2exe', path
  22. if sys._MEIPASS == os.path.dirname(path):
  23. return f'{sys.platform}_dir', path
  24. return f'{sys.platform}_exe', path
  25. path = os.path.dirname(__file__)
  26. if isinstance(__loader__, zipimporter):
  27. return 'zip', os.path.join(path, '..')
  28. elif (os.path.basename(sys.argv[0]) in ('__main__.py', '-m')
  29. and os.path.exists(os.path.join(path, '../.git/HEAD'))):
  30. return 'source', path
  31. return 'unknown', path
  32. def detect_variant():
  33. return _get_variant_and_executable_path()[0]
  34. _FILE_SUFFIXES = {
  35. 'zip': '',
  36. 'py2exe': '_min.exe',
  37. 'win32_exe': '.exe',
  38. 'darwin_exe': '_macos',
  39. 'linux_exe': '_linux',
  40. }
  41. _NON_UPDATEABLE_REASONS = {
  42. **{variant: None for variant in _FILE_SUFFIXES}, # Updatable
  43. **{variant: f'Auto-update is not supported for unpackaged {name} executable; Re-download the latest release'
  44. for variant, name in {'win32_dir': 'Windows', 'darwin_dir': 'MacOS', 'linux_dir': 'Linux'}.items()},
  45. 'source': 'You cannot update when running from source code; Use git to pull the latest changes',
  46. 'unknown': 'It looks like you installed yt-dlp with a package manager, pip or setup.py; Use that to update',
  47. 'other': 'It looks like you are using an unofficial build of yt-dlp; Build the executable again',
  48. }
  49. def is_non_updateable():
  50. return _NON_UPDATEABLE_REASONS.get(detect_variant(), _NON_UPDATEABLE_REASONS['other'])
  51. def _sha256_file(path):
  52. h = hashlib.sha256()
  53. mv = memoryview(bytearray(128 * 1024))
  54. with open(os.path.realpath(path), 'rb', buffering=0) as f:
  55. for n in iter(lambda: f.readinto(mv), 0):
  56. h.update(mv[:n])
  57. return h.hexdigest()
  58. class Updater:
  59. def __init__(self, ydl):
  60. self.ydl = ydl
  61. @functools.cached_property
  62. def _new_version_info(self):
  63. self.ydl.write_debug(f'Fetching release info: {API_URL}')
  64. return json.loads(self.ydl.urlopen(API_URL).read().decode())
  65. @property
  66. def current_version(self):
  67. """Current version"""
  68. return __version__
  69. @property
  70. def new_version(self):
  71. """Version of the latest release"""
  72. return self._new_version_info['tag_name']
  73. @property
  74. def has_update(self):
  75. """Whether there is an update available"""
  76. return version_tuple(__version__) < version_tuple(self.new_version)
  77. @functools.cached_property
  78. def filename(self):
  79. """Filename of the executable"""
  80. return compat_realpath(_get_variant_and_executable_path()[1])
  81. def _download(self, name=None):
  82. name = name or self.release_name
  83. url = traverse_obj(self._new_version_info, (
  84. 'assets', lambda _, v: v['name'] == name, 'browser_download_url'), get_all=False)
  85. if not url:
  86. raise Exception('Unable to find download URL')
  87. self.ydl.write_debug(f'Downloading {name} from {url}')
  88. return self.ydl.urlopen(url).read()
  89. @functools.cached_property
  90. def release_name(self):
  91. """The release filename"""
  92. label = _FILE_SUFFIXES[detect_variant()]
  93. if label and platform.architecture()[0][:2] == '32':
  94. label = f'_x86{label}'
  95. return f'yt-dlp{label}'
  96. @functools.cached_property
  97. def release_hash(self):
  98. """Hash of the latest release"""
  99. hash_data = dict(ln.split()[::-1] for ln in self._download('SHA2-256SUMS').decode().splitlines())
  100. return hash_data[self.release_name]
  101. def _report_error(self, msg, expected=False):
  102. self.ydl.report_error(msg, tb=False if expected else None)
  103. def _report_permission_error(self, file):
  104. self._report_error(f'Unable to write to {file}; Try running as administrator', True)
  105. def _report_network_error(self, action, delim=';'):
  106. self._report_error(f'Unable to {action}{delim} Visit https://github.com/{REPOSITORY}/releases/latest', True)
  107. def check_update(self):
  108. """Report whether there is an update available"""
  109. try:
  110. self.ydl.to_screen(
  111. f'Latest version: {self.new_version}, Current version: {self.current_version}')
  112. except Exception:
  113. return self._report_network_error('obtain version info', delim='; Please try again later or')
  114. if not self.has_update:
  115. return self.ydl.to_screen(f'yt-dlp is up to date ({__version__})')
  116. if not is_non_updateable():
  117. self.ydl.to_screen(f'Current Build Hash {_sha256_file(self.filename)}')
  118. return True
  119. def update(self):
  120. """Update yt-dlp executable to the latest version"""
  121. if not self.check_update():
  122. return
  123. err = is_non_updateable()
  124. if err:
  125. return self._report_error(err, True)
  126. self.ydl.to_screen(f'Updating to version {self.new_version} ...')
  127. directory = os.path.dirname(self.filename)
  128. if not os.access(self.filename, os.W_OK):
  129. return self._report_permission_error(self.filename)
  130. elif not os.access(directory, os.W_OK):
  131. return self._report_permission_error(directory)
  132. new_filename, old_filename = f'{self.filename}.new', f'{self.filename}.old'
  133. if detect_variant() == 'zip': # Can be replaced in-place
  134. new_filename, old_filename = self.filename, None
  135. try:
  136. if os.path.exists(old_filename or ''):
  137. os.remove(old_filename)
  138. except OSError:
  139. return self._report_error('Unable to remove the old version')
  140. try:
  141. newcontent = self._download()
  142. except OSError:
  143. return self._report_network_error('download latest version')
  144. except Exception:
  145. return self._report_network_error('fetch updates')
  146. try:
  147. expected_hash = self.release_hash
  148. except Exception:
  149. self.ydl.report_warning('no hash information found for the release')
  150. else:
  151. if hashlib.sha256(newcontent).hexdigest() != expected_hash:
  152. return self._report_network_error('verify the new executable')
  153. try:
  154. with open(new_filename, 'wb') as outf:
  155. outf.write(newcontent)
  156. except OSError:
  157. return self._report_permission_error(new_filename)
  158. try:
  159. if old_filename:
  160. os.rename(self.filename, old_filename)
  161. except OSError:
  162. return self._report_error('Unable to move current version')
  163. try:
  164. if old_filename:
  165. os.rename(new_filename, self.filename)
  166. except OSError:
  167. self._report_error('Unable to overwrite current version')
  168. return os.rename(old_filename, self.filename)
  169. if detect_variant() not in ('win32_exe', 'py2exe'):
  170. if old_filename:
  171. os.remove(old_filename)
  172. else:
  173. atexit.register(Popen, f'ping 127.0.0.1 -n 5 -w 1000 & del /F "{old_filename}"',
  174. shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  175. self.ydl.to_screen(f'Updated yt-dlp to version {self.new_version}')
  176. return True
  177. @functools.cached_property
  178. def cmd(self):
  179. """The command-line to run the executable, if known"""
  180. # There is no sys.orig_argv in py < 3.10. Also, it can be [] when frozen
  181. if getattr(sys, 'orig_argv', None):
  182. return sys.orig_argv
  183. elif hasattr(sys, 'frozen'):
  184. return sys.argv
  185. def restart(self):
  186. """Restart the executable"""
  187. assert self.cmd, 'Must be frozen or Py >= 3.10'
  188. self.ydl.write_debug(f'Restarting: {shell_quote(self.cmd)}')
  189. _, _, returncode = Popen.run(self.cmd)
  190. return returncode
  191. def run_update(ydl):
  192. """Update the program file with the latest version from the repository
  193. @returns Whether there was a successfull update (No update = False)
  194. """
  195. return Updater(ydl).update()
  196. # Deprecated
  197. def update_self(to_screen, verbose, opener):
  198. import traceback
  199. from .utils import write_string
  200. write_string(
  201. 'DeprecationWarning: "yt_dlp.update.update_self" is deprecated and may be removed in a future version. '
  202. 'Use "yt_dlp.update.run_update(ydl)" instead\n')
  203. printfn = to_screen
  204. class FakeYDL():
  205. to_screen = printfn
  206. def report_warning(self, msg, *args, **kwargs):
  207. return printfn(f'WARNING: {msg}', *args, **kwargs)
  208. def report_error(self, msg, tb=None):
  209. printfn(f'ERROR: {msg}')
  210. if not verbose:
  211. return
  212. if tb is None:
  213. # Copied from YoutubeDL.trouble
  214. if sys.exc_info()[0]:
  215. tb = ''
  216. if hasattr(sys.exc_info()[1], 'exc_info') and sys.exc_info()[1].exc_info[0]:
  217. tb += ''.join(traceback.format_exception(*sys.exc_info()[1].exc_info))
  218. tb += traceback.format_exc()
  219. else:
  220. tb_data = traceback.format_list(traceback.extract_stack())
  221. tb = ''.join(tb_data)
  222. if tb:
  223. printfn(tb)
  224. def write_debug(self, msg, *args, **kwargs):
  225. printfn(f'[debug] {msg}', *args, **kwargs)
  226. def urlopen(self, url):
  227. return opener.open(url)
  228. return run_update(FakeYDL())