update.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. import atexit
  2. import contextlib
  3. import hashlib
  4. import json
  5. import os
  6. import platform
  7. import re
  8. import subprocess
  9. import sys
  10. import urllib.error
  11. from zipimport import zipimporter
  12. from .compat import functools # isort: split
  13. from .compat import compat_realpath, compat_shlex_quote
  14. from .utils import (
  15. Popen,
  16. cached_method,
  17. deprecation_warning,
  18. remove_end,
  19. remove_start,
  20. sanitized_Request,
  21. shell_quote,
  22. system_identifier,
  23. version_tuple,
  24. )
  25. from .version import CHANNEL, UPDATE_HINT, VARIANT, __version__
  26. UPDATE_SOURCES = {
  27. 'stable': 'yt-dlp/yt-dlp',
  28. 'nightly': 'yt-dlp/yt-dlp-nightly-builds',
  29. }
  30. REPOSITORY = UPDATE_SOURCES['stable']
  31. _VERSION_RE = re.compile(r'(\d+\.)*\d+')
  32. API_BASE_URL = 'https://api.github.com/repos'
  33. # Backwards compatibility variables for the current channel
  34. API_URL = f'{API_BASE_URL}/{REPOSITORY}/releases'
  35. @functools.cache
  36. def _get_variant_and_executable_path():
  37. """@returns (variant, executable_path)"""
  38. if getattr(sys, 'frozen', False):
  39. path = sys.executable
  40. if not hasattr(sys, '_MEIPASS'):
  41. return 'py2exe', path
  42. elif sys._MEIPASS == os.path.dirname(path):
  43. return f'{sys.platform}_dir', path
  44. elif sys.platform == 'darwin':
  45. machine = '_legacy' if version_tuple(platform.mac_ver()[0]) < (10, 15) else ''
  46. else:
  47. machine = f'_{platform.machine().lower()}'
  48. # Ref: https://en.wikipedia.org/wiki/Uname#Examples
  49. if machine[1:] in ('x86', 'x86_64', 'amd64', 'i386', 'i686'):
  50. machine = '_x86' if platform.architecture()[0][:2] == '32' else ''
  51. return f'{remove_end(sys.platform, "32")}{machine}_exe', path
  52. path = os.path.dirname(__file__)
  53. if isinstance(__loader__, zipimporter):
  54. return 'zip', os.path.join(path, '..')
  55. elif (os.path.basename(sys.argv[0]) in ('__main__.py', '-m')
  56. and os.path.exists(os.path.join(path, '../.git/HEAD'))):
  57. return 'source', path
  58. return 'unknown', path
  59. def detect_variant():
  60. return VARIANT or _get_variant_and_executable_path()[0]
  61. @functools.cache
  62. def current_git_head():
  63. if detect_variant() != 'source':
  64. return
  65. with contextlib.suppress(Exception):
  66. stdout, _, _ = Popen.run(
  67. ['git', 'rev-parse', '--short', 'HEAD'],
  68. text=True, cwd=os.path.dirname(os.path.abspath(__file__)),
  69. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  70. if re.fullmatch('[0-9a-f]+', stdout.strip()):
  71. return stdout.strip()
  72. _FILE_SUFFIXES = {
  73. 'zip': '',
  74. 'py2exe': '_min.exe',
  75. 'win_exe': '.exe',
  76. 'win_x86_exe': '_x86.exe',
  77. 'darwin_exe': '_macos',
  78. 'darwin_legacy_exe': '_macos_legacy',
  79. 'linux_exe': '_linux',
  80. 'linux_aarch64_exe': '_linux_aarch64',
  81. 'linux_armv7l_exe': '_linux_armv7l',
  82. }
  83. _NON_UPDATEABLE_REASONS = {
  84. **{variant: None for variant in _FILE_SUFFIXES}, # Updatable
  85. **{variant: f'Auto-update is not supported for unpackaged {name} executable; Re-download the latest release'
  86. for variant, name in {'win32_dir': 'Windows', 'darwin_dir': 'MacOS', 'linux_dir': 'Linux'}.items()},
  87. 'source': 'You cannot update when running from source code; Use git to pull the latest changes',
  88. 'unknown': 'You installed yt-dlp with a package manager or setup.py; Use that to update',
  89. 'other': 'You are using an unofficial build of yt-dlp; Build the executable again',
  90. }
  91. def is_non_updateable():
  92. if UPDATE_HINT:
  93. return UPDATE_HINT
  94. return _NON_UPDATEABLE_REASONS.get(
  95. detect_variant(), _NON_UPDATEABLE_REASONS['unknown' if VARIANT else 'other'])
  96. def _sha256_file(path):
  97. h = hashlib.sha256()
  98. mv = memoryview(bytearray(128 * 1024))
  99. with open(os.path.realpath(path), 'rb', buffering=0) as f:
  100. for n in iter(lambda: f.readinto(mv), 0):
  101. h.update(mv[:n])
  102. return h.hexdigest()
  103. class Updater:
  104. _exact = True
  105. def __init__(self, ydl, target=None):
  106. self.ydl = ydl
  107. self.target_channel, sep, self.target_tag = (target or CHANNEL).rpartition('@')
  108. if not sep and self.target_tag in UPDATE_SOURCES: # stable => stable@latest
  109. self.target_channel, self.target_tag = self.target_tag, None
  110. elif not self.target_channel:
  111. self.target_channel = CHANNEL
  112. if not self.target_tag:
  113. self.target_tag, self._exact = 'latest', False
  114. elif self.target_tag != 'latest':
  115. self.target_tag = f'tags/{self.target_tag}'
  116. @property
  117. def _target_repo(self):
  118. try:
  119. return UPDATE_SOURCES[self.target_channel]
  120. except KeyError:
  121. return self._report_error(
  122. f'Invalid update channel {self.target_channel!r} requested. '
  123. f'Valid channels are {", ".join(UPDATE_SOURCES)}', True)
  124. def _version_compare(self, a, b, channel=CHANNEL):
  125. if channel != self.target_channel:
  126. return False
  127. if _VERSION_RE.fullmatch(f'{a}.{b}'):
  128. a, b = version_tuple(a), version_tuple(b)
  129. return a == b if self._exact else a >= b
  130. return a == b
  131. @functools.cached_property
  132. def _tag(self):
  133. if self._version_compare(self.current_version, self.latest_version):
  134. return self.target_tag
  135. identifier = f'{detect_variant()} {self.target_channel} {system_identifier()}'
  136. for line in self._download('_update_spec', 'latest').decode().splitlines():
  137. if not line.startswith('lock '):
  138. continue
  139. _, tag, pattern = line.split(' ', 2)
  140. if re.match(pattern, identifier):
  141. if not self._exact:
  142. return f'tags/{tag}'
  143. elif self.target_tag == 'latest' or not self._version_compare(
  144. tag, self.target_tag[5:], channel=self.target_channel):
  145. self._report_error(
  146. f'yt-dlp cannot be updated above {tag} since you are on an older Python version', True)
  147. return f'tags/{self.current_version}'
  148. return self.target_tag
  149. @cached_method
  150. def _get_version_info(self, tag):
  151. url = f'{API_BASE_URL}/{self._target_repo}/releases/{tag}'
  152. self.ydl.write_debug(f'Fetching release info: {url}')
  153. return json.loads(self.ydl.urlopen(sanitized_Request(url, headers={
  154. 'Accept': 'application/vnd.github+json',
  155. 'User-Agent': 'yt-dlp',
  156. 'X-GitHub-Api-Version': '2022-11-28',
  157. })).read().decode())
  158. @property
  159. def current_version(self):
  160. """Current version"""
  161. return __version__
  162. @staticmethod
  163. def _label(channel, tag):
  164. """Label for a given channel and tag"""
  165. return f'{channel}@{remove_start(tag, "tags/")}'
  166. def _get_actual_tag(self, tag):
  167. if tag.startswith('tags/'):
  168. return tag[5:]
  169. return self._get_version_info(tag)['tag_name']
  170. @property
  171. def new_version(self):
  172. """Version of the latest release we can update to"""
  173. return self._get_actual_tag(self._tag)
  174. @property
  175. def latest_version(self):
  176. """Version of the target release"""
  177. return self._get_actual_tag(self.target_tag)
  178. @property
  179. def has_update(self):
  180. """Whether there is an update available"""
  181. return not self._version_compare(self.current_version, self.new_version)
  182. @functools.cached_property
  183. def filename(self):
  184. """Filename of the executable"""
  185. return compat_realpath(_get_variant_and_executable_path()[1])
  186. def _download(self, name, tag):
  187. slug = 'latest/download' if tag == 'latest' else f'download/{tag[5:]}'
  188. url = f'https://github.com/{self._target_repo}/releases/{slug}/{name}'
  189. self.ydl.write_debug(f'Downloading {name} from {url}')
  190. return self.ydl.urlopen(url).read()
  191. @functools.cached_property
  192. def release_name(self):
  193. """The release filename"""
  194. return f'yt-dlp{_FILE_SUFFIXES[detect_variant()]}'
  195. @functools.cached_property
  196. def release_hash(self):
  197. """Hash of the latest release"""
  198. hash_data = dict(ln.split()[::-1] for ln in self._download('SHA2-256SUMS', self._tag).decode().splitlines())
  199. return hash_data[self.release_name]
  200. def _report_error(self, msg, expected=False):
  201. self.ydl.report_error(msg, tb=False if expected else None)
  202. self.ydl._download_retcode = 100
  203. def _report_permission_error(self, file):
  204. self._report_error(f'Unable to write to {file}; Try running as administrator', True)
  205. def _report_network_error(self, action, delim=';'):
  206. self._report_error(
  207. f'Unable to {action}{delim} visit '
  208. f'https://github.com/{self._target_repo}/releases/{self.target_tag.replace("tags/", "tag/")}', True)
  209. def check_update(self):
  210. """Report whether there is an update available"""
  211. if not self._target_repo:
  212. return False
  213. try:
  214. self.ydl.to_screen((
  215. f'Available version: {self._label(self.target_channel, self.latest_version)}, ' if self.target_tag == 'latest' else ''
  216. ) + f'Current version: {self._label(CHANNEL, self.current_version)}')
  217. except Exception:
  218. return self._report_network_error('obtain version info', delim='; Please try again later or')
  219. if not is_non_updateable():
  220. self.ydl.to_screen(f'Current Build Hash: {_sha256_file(self.filename)}')
  221. if self.has_update:
  222. return True
  223. if self.target_tag == self._tag:
  224. self.ydl.to_screen(f'yt-dlp is up to date ({self._label(CHANNEL, self.current_version)})')
  225. elif not self._exact:
  226. self.ydl.report_warning('yt-dlp cannot be updated any further since you are on an older Python version')
  227. return False
  228. def update(self):
  229. """Update yt-dlp executable to the latest version"""
  230. if not self.check_update():
  231. return
  232. err = is_non_updateable()
  233. if err:
  234. return self._report_error(err, True)
  235. self.ydl.to_screen(f'Updating to {self._label(self.target_channel, self.new_version)} ...')
  236. if (_VERSION_RE.fullmatch(self.target_tag[5:])
  237. and version_tuple(self.target_tag[5:]) < (2023, 3, 2)):
  238. self.ydl.report_warning('You are downgrading to a version without --update-to')
  239. directory = os.path.dirname(self.filename)
  240. if not os.access(self.filename, os.W_OK):
  241. return self._report_permission_error(self.filename)
  242. elif not os.access(directory, os.W_OK):
  243. return self._report_permission_error(directory)
  244. new_filename, old_filename = f'{self.filename}.new', f'{self.filename}.old'
  245. if detect_variant() == 'zip': # Can be replaced in-place
  246. new_filename, old_filename = self.filename, None
  247. try:
  248. if os.path.exists(old_filename or ''):
  249. os.remove(old_filename)
  250. except OSError:
  251. return self._report_error('Unable to remove the old version')
  252. try:
  253. newcontent = self._download(self.release_name, self._tag)
  254. except Exception as e:
  255. if isinstance(e, urllib.error.HTTPError) and e.code == 404:
  256. return self._report_error(
  257. f'The requested tag {self._label(self.target_channel, self.target_tag)} does not exist', True)
  258. return self._report_network_error(f'fetch updates: {e}')
  259. try:
  260. expected_hash = self.release_hash
  261. except Exception:
  262. self.ydl.report_warning('no hash information found for the release')
  263. else:
  264. if hashlib.sha256(newcontent).hexdigest() != expected_hash:
  265. return self._report_network_error('verify the new executable')
  266. try:
  267. with open(new_filename, 'wb') as outf:
  268. outf.write(newcontent)
  269. except OSError:
  270. return self._report_permission_error(new_filename)
  271. if old_filename:
  272. mask = os.stat(self.filename).st_mode
  273. try:
  274. os.rename(self.filename, old_filename)
  275. except OSError:
  276. return self._report_error('Unable to move current version')
  277. try:
  278. os.rename(new_filename, self.filename)
  279. except OSError:
  280. self._report_error('Unable to overwrite current version')
  281. return os.rename(old_filename, self.filename)
  282. variant = detect_variant()
  283. if variant.startswith('win') or variant == 'py2exe':
  284. atexit.register(Popen, f'ping 127.0.0.1 -n 5 -w 1000 & del /F "{old_filename}"',
  285. shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  286. elif old_filename:
  287. try:
  288. os.remove(old_filename)
  289. except OSError:
  290. self._report_error('Unable to remove the old version')
  291. try:
  292. os.chmod(self.filename, mask)
  293. except OSError:
  294. return self._report_error(
  295. f'Unable to set permissions. Run: sudo chmod a+rx {compat_shlex_quote(self.filename)}')
  296. self.ydl.to_screen(f'Updated yt-dlp to {self._label(self.target_channel, self.new_version)}')
  297. return True
  298. @functools.cached_property
  299. def cmd(self):
  300. """The command-line to run the executable, if known"""
  301. # There is no sys.orig_argv in py < 3.10. Also, it can be [] when frozen
  302. if getattr(sys, 'orig_argv', None):
  303. return sys.orig_argv
  304. elif getattr(sys, 'frozen', False):
  305. return sys.argv
  306. def restart(self):
  307. """Restart the executable"""
  308. assert self.cmd, 'Must be frozen or Py >= 3.10'
  309. self.ydl.write_debug(f'Restarting: {shell_quote(self.cmd)}')
  310. _, _, returncode = Popen.run(self.cmd)
  311. return returncode
  312. def run_update(ydl):
  313. """Update the program file with the latest version from the repository
  314. @returns Whether there was a successful update (No update = False)
  315. """
  316. return Updater(ydl).update()
  317. # Deprecated
  318. def update_self(to_screen, verbose, opener):
  319. import traceback
  320. deprecation_warning(f'"{__name__}.update_self" is deprecated and may be removed '
  321. f'in a future version. Use "{__name__}.run_update(ydl)" instead')
  322. printfn = to_screen
  323. class FakeYDL():
  324. to_screen = printfn
  325. def report_warning(self, msg, *args, **kwargs):
  326. return printfn(f'WARNING: {msg}', *args, **kwargs)
  327. def report_error(self, msg, tb=None):
  328. printfn(f'ERROR: {msg}')
  329. if not verbose:
  330. return
  331. if tb is None:
  332. # Copied from YoutubeDL.trouble
  333. if sys.exc_info()[0]:
  334. tb = ''
  335. if hasattr(sys.exc_info()[1], 'exc_info') and sys.exc_info()[1].exc_info[0]:
  336. tb += ''.join(traceback.format_exception(*sys.exc_info()[1].exc_info))
  337. tb += traceback.format_exc()
  338. else:
  339. tb_data = traceback.format_list(traceback.extract_stack())
  340. tb = ''.join(tb_data)
  341. if tb:
  342. printfn(tb)
  343. def write_debug(self, msg, *args, **kwargs):
  344. printfn(f'[debug] {msg}', *args, **kwargs)
  345. def urlopen(self, url):
  346. return opener.open(url)
  347. return run_update(FakeYDL())
  348. __all__ = ['Updater']