1 from __future__
import annotations
13 from dataclasses
import dataclass
14 from zipimport
import zipimporter
16 from .networking
import Request
17 from .networking
.exceptions
import HTTPError
, network_exceptions
28 from .version
import (
38 'stable': 'yt-dlp/yt-dlp',
39 'nightly': 'yt-dlp/yt-dlp-nightly-builds',
40 'master': 'yt-dlp/yt-dlp-master-builds',
42 REPOSITORY
= UPDATE_SOURCES
['stable']
43 _INVERSE_UPDATE_SOURCES
= {value
: key
for key
, value
in UPDATE_SOURCES
.items()}
45 _VERSION_RE
= re
.compile(r
'(\d+\.)*\d+')
46 _HASH_PATTERN
= r
'[\da-f]{40}'
47 _COMMIT_RE
= re
.compile(rf
'Generated from: https://(?:[^/?#]+/){{3}}commit/(?P<hash>{_HASH_PATTERN})')
49 API_BASE_URL
= 'https://api.github.com/repos'
51 # Backwards compatibility variables for the current channel
52 API_URL
= f
'{API_BASE_URL}/{REPOSITORY}/releases'
56 def _get_variant_and_executable_path():
57 """@returns (variant, executable_path)"""
58 if getattr(sys
, 'frozen', False):
60 if not hasattr(sys
, '_MEIPASS'):
62 elif sys
._MEIPASS
== os
.path
.dirname(path
):
63 return f
'{sys.platform}_dir', path
64 elif sys
.platform
== 'darwin':
65 machine
= '_legacy' if version_tuple(platform
.mac_ver()[0]) < (10, 15) else ''
67 machine
= f
'_{platform.machine().lower()}'
68 is_64bits
= sys
.maxsize
> 2**32
69 # Ref: https://en.wikipedia.org/wiki/Uname#Examples
70 if machine
[1:] in ('x86', 'x86_64', 'amd64', 'i386', 'i686'):
71 machine
= '_x86' if not is_64bits
else ''
72 # platform.machine() on 32-bit raspbian OS may return 'aarch64', so check "64-bitness"
73 # See: https://github.com/yt-dlp/yt-dlp/issues/11813
74 elif machine
[1:] == 'aarch64' and not is_64bits
:
76 # sys.executable returns a /tmp/ path for staticx builds (linux_static)
77 # Ref: https://staticx.readthedocs.io/en/latest/usage.html#run-time-information
78 if static_exe_path
:= os
.getenv('STATICX_PROG_PATH'):
79 path
= static_exe_path
80 return f
'{remove_end(sys.platform, "32")}{machine}_exe', path
82 path
= os
.path
.dirname(__file__
)
83 if isinstance(__loader__
, zipimporter
):
84 return 'zip', os
.path
.join(path
, '..')
85 elif (os
.path
.basename(sys
.argv
[0]) in ('__main__.py', '-m')
86 and os
.path
.exists(os
.path
.join(path
, '../.git/HEAD'))):
88 return 'unknown', path
92 return VARIANT
or _get_variant_and_executable_path()[0]
96 def current_git_head():
97 if detect_variant() != 'source':
99 with contextlib
.suppress(Exception):
100 stdout
, _
, _
= Popen
.run(
101 ['git', 'rev-parse', '--short', 'HEAD'],
102 text
=True, cwd
=os
.path
.dirname(os
.path
.abspath(__file__
)),
103 stdout
=subprocess
.PIPE
, stderr
=subprocess
.PIPE
)
104 if re
.fullmatch('[0-9a-f]+', stdout
.strip()):
105 return stdout
.strip()
111 'win_x86_exe': '_x86.exe',
112 'darwin_exe': '_macos',
113 'darwin_legacy_exe': '_macos_legacy',
114 'linux_exe': '_linux',
115 'linux_aarch64_exe': '_linux_aarch64',
116 'linux_armv7l_exe': '_linux_armv7l',
119 _NON_UPDATEABLE_REASONS
= {
120 **{variant
: None for variant
in _FILE_SUFFIXES
}, # Updatable
121 **{variant
: f
'Auto-update is not supported for unpackaged {name} executable; Re-download the latest release'
122 for variant
, name
in {'win32_dir': 'Windows', 'darwin_dir': 'MacOS', 'linux_dir': 'Linux'}.items()},
123 'py2exe': 'py2exe is no longer supported by yt-dlp; This executable cannot be updated',
124 'source': 'You cannot update when running from source code; Use git to pull the latest changes',
125 'unknown': 'You installed yt-dlp from a manual build or with a package manager; Use that to update',
126 'other': 'You are using an unofficial build of yt-dlp; Build the executable again',
130 def is_non_updateable():
133 return _NON_UPDATEABLE_REASONS
.get(
134 detect_variant(), _NON_UPDATEABLE_REASONS
['unknown' if VARIANT
else 'other'])
137 def _get_binary_name():
138 return format_field(_FILE_SUFFIXES
, detect_variant(), template
='yt-dlp%s', ignore
=None, default
=None)
141 def _get_system_deprecation():
142 MIN_SUPPORTED
, MIN_RECOMMENDED
= (3, 9), (3, 9)
144 if sys
.version_info
> MIN_RECOMMENDED
:
147 major
, minor
= sys
.version_info
[:2]
148 PYTHON_MSG
= f
'Please update to Python {".".join(map(str, MIN_RECOMMENDED))} or above'
150 if sys
.version_info
< MIN_SUPPORTED
:
151 return f
'Python version {major}.{minor} is no longer supported! {PYTHON_MSG}'
153 return f
'Support for Python version {major}.{minor} has been deprecated. {PYTHON_MSG}'
156 def _sha256_file(path
):
158 mv
= memoryview(bytearray(128 * 1024))
159 with
open(os
.path
.realpath(path
), 'rb', buffering
=0) as f
:
160 for n
in iter(lambda: f
.readinto(mv
), 0):
165 def _make_label(origin
, tag
, version
=None):
167 channel
= _INVERSE_UPDATE_SOURCES
.get(origin
, origin
)
170 label
= f
'{channel}@{tag}'
171 if version
and version
!= tag
:
172 label
+= f
' build {version}'
173 if channel
!= origin
:
174 label
+= f
' from {origin}'
181 Update target information
183 Can be created by `query_update()` or manually.
186 tag The release tag that will be updated to. If from query_update,
187 the value is after API resolution and update spec processing.
188 The only property that is required.
189 version The actual numeric version (if available) of the binary to be updated to,
190 after API resolution and update spec processing. (default: None)
191 requested_version Numeric version of the binary being requested (if available),
192 after API resolution only. (default: None)
193 commit Commit hash (if available) of the binary to be updated to,
194 after API resolution and update spec processing. (default: None)
195 This value will only match the RELEASE_GIT_HEAD of prerelease builds.
196 binary_name Filename of the binary to be updated to. (default: current binary name)
197 checksum Expected checksum (if available) of the binary to be
198 updated to. (default: None)
201 version
: str |
None = None
202 requested_version
: str |
None = None
203 commit
: str |
None = None
205 binary_name
: str |
None = _get_binary_name() # noqa: RUF009: Always returns the same value
206 checksum
: str |
None = None
210 # XXX: use class variables to simplify testing
213 _update_sources
= UPDATE_SOURCES
215 def __init__(self
, ydl
, target
: str |
None = None):
217 # For backwards compat, target needs to be treated as if it could be None
218 self
.requested_channel
, sep
, self
.requested_tag
= (target
or self
._channel
).rpartition('@')
219 # Check if requested_tag is actually the requested repo/channel
220 if not sep
and ('/' in self
.requested_tag
or self
.requested_tag
in self
._update
_sources
):
221 self
.requested_channel
= self
.requested_tag
222 self
.requested_tag
: str = None # type: ignore (we set it later)
223 elif not self
.requested_channel
:
224 # User did not specify a channel, so we are requesting the default channel
225 self
.requested_channel
= self
._channel
.partition('@')[0]
227 # --update should not be treated as an exact tag request even if CHANNEL has a @tag
228 self
._exact
= bool(target
) and target
!= self
._channel
229 if not self
.requested_tag
:
230 # User did not specify a tag, so we request 'latest' and track that no exact tag was passed
231 self
.requested_tag
= 'latest'
234 if '/' in self
.requested_channel
:
235 # requested_channel is actually a repository
236 self
.requested_repo
= self
.requested_channel
237 if not self
.requested_repo
.startswith('yt-dlp/') and self
.requested_repo
!= self
._origin
:
238 self
.ydl
.report_warning(
239 f
'You are switching to an {self.ydl._format_err("unofficial", "red")} executable '
240 f
'from {self.ydl._format_err(self.requested_repo, self.ydl.Styles.EMPHASIS)}. '
241 f
'Run {self.ydl._format_err("at your own risk", "light red")}')
242 self
._block
_restart
('Automatically restarting into custom builds is disabled for security reasons')
244 # Check if requested_channel resolves to a known repository or else raise
245 self
.requested_repo
= self
._update
_sources
.get(self
.requested_channel
)
246 if not self
.requested_repo
:
248 f
'Invalid update channel {self.requested_channel!r} requested. '
249 f
'Valid channels are {", ".join(self._update_sources)}', True)
251 self
._identifier
= f
'{detect_variant()} {system_identifier()}'
254 def current_version(self
):
255 """Current version"""
259 def current_commit(self
):
260 """Current commit hash"""
261 return RELEASE_GIT_HEAD
263 def _download_asset(self
, name
, tag
=None):
265 tag
= self
.requested_tag
267 path
= 'latest/download' if tag
== 'latest' else f
'download/{tag}'
268 url
= f
'https://github.com/{self.requested_repo}/releases/{path}/{name}'
269 self
.ydl
.write_debug(f
'Downloading {name} from {url}')
270 return self
.ydl
.urlopen(url
).read()
272 def _call_api(self
, tag
):
273 tag
= f
'tags/{tag}' if tag
!= 'latest' else tag
274 url
= f
'{API_BASE_URL}/{self.requested_repo}/releases/{tag}'
275 self
.ydl
.write_debug(f
'Fetching release info: {url}')
276 return json
.loads(self
.ydl
.urlopen(Request(url
, headers
={
277 'Accept': 'application/vnd.github+json',
278 'User-Agent': 'yt-dlp',
279 'X-GitHub-Api-Version': '2022-11-28',
282 def _get_version_info(self
, tag
: str) -> tuple[str |
None, str |
None]:
283 if _VERSION_RE
.fullmatch(tag
):
286 api_info
= self
._call
_api
(tag
)
289 requested_version
= api_info
['tag_name']
291 match
= re
.search(rf
'\s+(?P<version>{_VERSION_RE.pattern})$', api_info
.get('name', ''))
292 requested_version
= match
.group('version') if match
else None
294 if re
.fullmatch(_HASH_PATTERN
, api_info
.get('target_commitish', '')):
295 target_commitish
= api_info
['target_commitish']
297 match
= _COMMIT_RE
.match(api_info
.get('body', ''))
298 target_commitish
= match
.group('hash') if match
else None
300 if not (requested_version
or target_commitish
):
301 self
._report
_error
('One of either version or commit hash must be available on the release', expected
=True)
303 return requested_version
, target_commitish
305 def _download_update_spec(self
, source_tags
):
306 for tag
in source_tags
:
308 return self
._download
_asset
('_update_spec', tag
=tag
).decode()
309 except network_exceptions
as error
:
310 if isinstance(error
, HTTPError
) and error
.status
== 404:
312 self
._report
_network
_error
(f
'fetch update spec: {error}')
316 f
'The requested tag {self.requested_tag} does not exist for {self.requested_repo}', True)
319 def _process_update_spec(self
, lockfile
: str, resolved_tag
: str):
320 lines
= lockfile
.splitlines()
321 is_version2
= any(line
.startswith('lockV2 ') for line
in lines
)
325 if not line
.startswith(f
'lockV2 {self.requested_repo} '):
327 _
, _
, tag
, pattern
= line
.split(' ', 3)
329 if not line
.startswith('lock '):
331 _
, tag
, pattern
= line
.split(' ', 2)
333 if re
.match(pattern
, self
._identifier
):
334 if _VERSION_RE
.fullmatch(tag
):
337 elif self
._version
_compare
(tag
, resolved_tag
):
339 elif tag
!= resolved_tag
:
343 f
'yt-dlp cannot be updated to {resolved_tag} since you are on an older Python version '
344 'or your operating system is not compatible with the requested build', True)
349 def _version_compare(self
, a
: str, b
: str):
351 Compare two version strings
353 This function SHOULD NOT be called if self._exact == True
355 if _VERSION_RE
.fullmatch(f
'{a}.{b}'):
356 return version_tuple(a
) >= version_tuple(b
)
359 def query_update(self
, *, _output
=False) -> UpdateInfo |
None:
360 """Fetches info about the available update
361 @returns An `UpdateInfo` if there is an update available, else None
363 if not self
.requested_repo
:
364 self
._report
_error
('No target repository could be determined from input')
368 requested_version
, target_commitish
= self
._get
_version
_info
(self
.requested_tag
)
369 except network_exceptions
as e
:
370 self
._report
_network
_error
(f
'obtain version info ({e})', delim
='; Please try again later or')
373 if self
._exact
and self
._origin
!= self
.requested_repo
:
375 elif requested_version
:
377 has_update
= self
.current_version
!= requested_version
379 has_update
= not self
._version
_compare
(self
.current_version
, requested_version
)
380 elif target_commitish
:
381 has_update
= target_commitish
!= self
.current_commit
385 resolved_tag
= requested_version
if self
.requested_tag
== 'latest' else self
.requested_tag
386 current_label
= _make_label(self
._origin
, self
._channel
.partition('@')[2] or self
.current_version
, self
.current_version
)
387 requested_label
= _make_label(self
.requested_repo
, resolved_tag
, requested_version
)
388 latest_or_requested
= f
'{"Latest" if self.requested_tag == "latest" else "Requested"} version: {requested_label}'
391 self
.ydl
.to_screen(f
'{latest_or_requested}\nyt-dlp is up to date ({current_label})')
394 update_spec
= self
._download
_update
_spec
(('latest', None) if requested_version
else (None,))
397 # `result_` prefixed vars == post-_process_update_spec() values
398 result_tag
= self
._process
_update
_spec
(update_spec
, resolved_tag
)
399 if not result_tag
or result_tag
== self
.current_version
:
401 elif result_tag
== resolved_tag
:
402 result_version
= requested_version
403 elif _VERSION_RE
.fullmatch(result_tag
):
404 result_version
= result_tag
405 else: # actual version being updated to is unknown
406 result_version
= None
409 # Non-updateable variants can get update_info but need to skip checksum
410 if not is_non_updateable():
412 hashes
= self
._download
_asset
('SHA2-256SUMS', result_tag
)
413 except network_exceptions
as error
:
414 if not isinstance(error
, HTTPError
) or error
.status
!= 404:
415 self
._report
_network
_error
(f
'fetch checksums: {error}')
417 self
.ydl
.report_warning('No hash information found for the release, skipping verification')
419 for ln
in hashes
.decode().splitlines():
420 if ln
.endswith(_get_binary_name()):
421 checksum
= ln
.split()[0]
424 self
.ydl
.report_warning('The hash could not be found in the checksum file, skipping verification')
427 update_label
= _make_label(self
.requested_repo
, result_tag
, result_version
)
429 f
'Current version: {current_label}\n{latest_or_requested}'
430 + (f
'\nUpgradable to: {update_label}' if update_label
!= requested_label
else ''))
434 version
=result_version
,
435 requested_version
=requested_version
,
436 commit
=target_commitish
if result_tag
== resolved_tag
else None,
439 def update(self
, update_info
=NO_DEFAULT
):
440 """Update yt-dlp executable to the latest version
441 @param update_info `UpdateInfo | None` as returned by query_update()
443 if update_info
is NO_DEFAULT
:
444 update_info
= self
.query_update(_output
=True)
448 err
= is_non_updateable()
450 self
._report
_error
(err
, True)
453 self
.ydl
.to_screen(f
'Current Build Hash: {_sha256_file(self.filename)}')
455 update_label
= _make_label(self
.requested_repo
, update_info
.tag
, update_info
.version
)
456 self
.ydl
.to_screen(f
'Updating to {update_label} ...')
458 directory
= os
.path
.dirname(self
.filename
)
459 if not os
.access(self
.filename
, os
.W_OK
):
460 return self
._report
_permission
_error
(self
.filename
)
461 elif not os
.access(directory
, os
.W_OK
):
462 return self
._report
_permission
_error
(directory
)
464 new_filename
, old_filename
= f
'{self.filename}.new', f
'{self.filename}.old'
465 if detect_variant() == 'zip': # Can be replaced in-place
466 new_filename
, old_filename
= self
.filename
, None
469 if os
.path
.exists(old_filename
or ''):
470 os
.remove(old_filename
)
472 return self
._report
_error
('Unable to remove the old version')
475 newcontent
= self
._download
_asset
(update_info
.binary_name
, update_info
.tag
)
476 except network_exceptions
as e
:
477 if isinstance(e
, HTTPError
) and e
.status
== 404:
478 return self
._report
_error
(
479 f
'The requested tag {self.requested_repo}@{update_info.tag} does not exist', True)
480 return self
._report
_network
_error
(f
'fetch updates: {e}', tag
=update_info
.tag
)
482 if not update_info
.checksum
:
483 self
._block
_restart
('Automatically restarting into unverified builds is disabled for security reasons')
484 elif hashlib
.sha256(newcontent
).hexdigest() != update_info
.checksum
:
485 return self
._report
_network
_error
('verify the new executable', tag
=update_info
.tag
)
488 with
open(new_filename
, 'wb') as outf
:
489 outf
.write(newcontent
)
491 return self
._report
_permission
_error
(new_filename
)
494 mask
= os
.stat(self
.filename
).st_mode
496 os
.rename(self
.filename
, old_filename
)
498 return self
._report
_error
('Unable to move current version')
501 os
.rename(new_filename
, self
.filename
)
503 self
._report
_error
('Unable to overwrite current version')
504 return os
.rename(old_filename
, self
.filename
)
506 variant
= detect_variant()
507 if variant
.startswith('win'):
508 atexit
.register(Popen
, f
'ping 127.0.0.1 -n 5 -w 1000 & del /F "{old_filename}"',
509 shell
=True, stdout
=subprocess
.DEVNULL
, stderr
=subprocess
.DEVNULL
)
512 os
.remove(old_filename
)
514 self
._report
_error
('Unable to remove the old version')
517 os
.chmod(self
.filename
, mask
)
519 return self
._report
_error
(
520 f
'Unable to set permissions. Run: sudo chmod a+rx {shell_quote(self.filename)}')
522 self
.ydl
.to_screen(f
'Updated yt-dlp to {update_label}')
525 @functools.cached_property
527 """Filename of the executable"""
528 return os
.path
.realpath(_get_variant_and_executable_path()[1])
530 @functools.cached_property
532 """The command-line to run the executable, if known"""
534 # There is no sys.orig_argv in py < 3.10. Also, it can be [] when frozen
535 if getattr(sys
, 'orig_argv', None):
537 elif getattr(sys
, 'frozen', False):
539 # linux_static exe's argv[0] will be /tmp/staticx-NNNN/yt-dlp_linux if we don't fixup here
540 if argv
and os
.getenv('STATICX_PROG_PATH'):
541 argv
= [self
.filename
, *argv
[1:]]
545 """Restart the executable"""
546 assert self
.cmd
, 'Must be frozen or Py >= 3.10'
547 self
.ydl
.write_debug(f
'Restarting: {shell_quote(self.cmd)}')
548 _
, _
, returncode
= Popen
.run(self
.cmd
)
551 def _block_restart(self
, msg
):
553 self
._report
_error
(f
'{msg}. Restart yt-dlp to use the updated version', expected
=True)
554 return self
.ydl
._download
_retcode
555 self
.restart
= wrapper
557 def _report_error(self
, msg
, expected
=False):
558 self
.ydl
.report_error(msg
, tb
=False if expected
else None)
559 self
.ydl
._download
_retcode
= 100
561 def _report_permission_error(self
, file):
562 self
._report
_error
(f
'Unable to write to {file}; try running as administrator', True)
564 def _report_network_error(self
, action
, delim
=';', tag
=None):
566 tag
= self
.requested_tag
567 path
= tag
if tag
== 'latest' else f
'tag/{tag}'
569 f
'Unable to {action}{delim} visit '
570 f
'https://github.com/{self.requested_repo}/releases/{path}', True)
574 """Update the program file with the latest version from the repository
575 @returns Whether there was a successful update (No update = False)
578 '"yt_dlp.update.run_update(ydl)" is deprecated and may be removed in a future version. '
579 'Use "yt_dlp.update.Updater(ydl).update()" instead')
580 return Updater(ydl
).update()
583 __all__
= ['Updater']