diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cd7ead796..4bed5af6a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -164,7 +164,7 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: build-${{ github.job }} + name: build-bin-${{ github.job }} path: | yt-dlp yt-dlp.tar.gz @@ -227,7 +227,7 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: build-linux_${{ matrix.architecture }} + name: build-bin-linux_${{ matrix.architecture }} path: | # run-on-arch-action designates armv7l as armv7 repo/dist/yt-dlp_linux_${{ (matrix.architecture == 'armv7' && 'armv7l') || matrix.architecture }} compression-level: 0 @@ -271,7 +271,7 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: build-${{ github.job }} + name: build-bin-${{ github.job }} path: | dist/yt-dlp_macos dist/yt-dlp_macos.zip @@ -324,7 +324,7 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: build-${{ github.job }} + name: build-bin-${{ github.job }} path: | dist/yt-dlp_macos_legacy compression-level: 0 @@ -373,7 +373,7 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: build-${{ github.job }} + name: build-bin-${{ github.job }} path: | dist/yt-dlp.exe dist/yt-dlp_min.exe @@ -421,7 +421,7 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: build-${{ github.job }} + name: build-bin-${{ github.job }} path: | dist/yt-dlp_x86.exe compression-level: 0 @@ -441,7 +441,7 @@ jobs: - uses: actions/download-artifact@v4 with: path: artifact - pattern: build-* + pattern: build-bin-* merge-multiple: true - name: Make SHA2-SUMS files @@ -484,3 +484,4 @@ jobs: _update_spec SHA*SUMS* compression-level: 0 + overwrite: true diff --git a/Makefile b/Makefile index c33984f6f..2cfeb7841 100644 --- a/Makefile +++ b/Makefile @@ -37,14 +37,15 @@ BINDIR ?= $(PREFIX)/bin MANDIR ?= $(PREFIX)/man SHAREDIR ?= $(PREFIX)/share PYTHON ?= /usr/bin/env python3 - -# $(shell) and $(error) are no-ops in BSD Make and the != variable assignment operator is not supported by GNU Make <4.0 -VERSION_CHECK != echo supported -VERSION_CHECK ?= $(error GNU Make 4+ or BSD Make is required) -CHECK_VERSION := $(VERSION_CHECK) +GNUTAR ?= tar # set markdown input format to "markdown-smart" for pandoc version 2+ and to "markdown" for pandoc prior to version 2 -MARKDOWN != if [ "`pandoc -v | head -n1 | cut -d' ' -f2 | head -c1`" -ge "2" ]; then echo markdown-smart; else echo markdown; fi +PANDOC_VERSION_CMD = pandoc -v 2>/dev/null | head -n1 | cut -d' ' -f2 | head -c1 +PANDOC_VERSION != $(PANDOC_VERSION_CMD) +PANDOC_VERSION ?= $(shell $(PANDOC_VERSION_CMD)) +MARKDOWN_CMD = if [ "$(PANDOC_VERSION)" = "1" -o "$(PANDOC_VERSION)" = "0" ]; then echo markdown; else echo markdown-smart; fi +MARKDOWN != $(MARKDOWN_CMD) +MARKDOWN ?= $(shell $(MARKDOWN_CMD)) install: lazy-extractors yt-dlp yt-dlp.1 completions mkdir -p $(DESTDIR)$(BINDIR) @@ -75,8 +76,12 @@ test: offlinetest: codetest $(PYTHON) -m pytest -k "not download" -CODE_FOLDERS != find yt_dlp -type f -name '__init__.py' -exec dirname {} \+ | grep -v '/__' | sort -CODE_FILES != for f in $(CODE_FOLDERS) ; do echo "$$f" | sed 's,$$,/*.py,' ; done +CODE_FOLDERS_CMD = find yt_dlp -type f -name '__init__.py' | sed 's,/__init__.py,,' | grep -v '/__' | sort +CODE_FOLDERS != $(CODE_FOLDERS_CMD) +CODE_FOLDERS ?= $(shell $(CODE_FOLDERS_CMD)) +CODE_FILES_CMD = for f in $(CODE_FOLDERS) ; do echo "$$f" | sed 's,$$,/*.py,' ; done +CODE_FILES != $(CODE_FILES_CMD) +CODE_FILES ?= $(shell $(CODE_FILES_CMD)) yt-dlp: $(CODE_FILES) mkdir -p zip for d in $(CODE_FOLDERS) ; do \ @@ -129,12 +134,14 @@ completions/fish/yt-dlp.fish: $(CODE_FILES) devscripts/fish-completion.in mkdir -p completions/fish $(PYTHON) devscripts/fish-completion.py -_EXTRACTOR_FILES != find yt_dlp/extractor -name '*.py' -and -not -name 'lazy_extractors.py' +_EXTRACTOR_FILES_CMD = find yt_dlp/extractor -name '*.py' -and -not -name 'lazy_extractors.py' +_EXTRACTOR_FILES != $(_EXTRACTOR_FILES_CMD) +_EXTRACTOR_FILES ?= $(shell $(_EXTRACTOR_FILES_CMD)) yt_dlp/extractor/lazy_extractors.py: devscripts/make_lazy_extractors.py devscripts/lazy_load_template.py $(_EXTRACTOR_FILES) $(PYTHON) devscripts/make_lazy_extractors.py $@ yt-dlp.tar.gz: all - @tar -czf yt-dlp.tar.gz --transform "s|^|yt-dlp/|" --owner 0 --group 0 \ + @$(GNUTAR) -czf yt-dlp.tar.gz --transform "s|^|yt-dlp/|" --owner 0 --group 0 \ --exclude '*.DS_Store' \ --exclude '*.kate-swp' \ --exclude '*.pyc' \ @@ -143,7 +150,6 @@ yt-dlp.tar.gz: all --exclude '__pycache__' \ --exclude '.pytest_cache' \ --exclude '.git' \ - --exclude '__pyinstaller' \ -- \ README.md supportedsites.md Changelog.md LICENSE \ CONTRIBUTING.md Collaborators.md CONTRIBUTORS AUTHORS \ diff --git a/README.md b/README.md index 2fcb09917..7e31e6560 100644 --- a/README.md +++ b/README.md @@ -167,8 +167,8 @@ ### Differences in default behavior * `--compat-options youtube-dl`: Same as `--compat-options all,-multistreams,-playlist-match-filter,-manifest-filesize-approx` * `--compat-options youtube-dlc`: Same as `--compat-options all,-no-live-chat,-no-youtube-channel-redirect,-playlist-match-filter,-manifest-filesize-approx` * `--compat-options 2021`: Same as `--compat-options 2022,no-certifi,filename-sanitization,no-youtube-prefer-utc-upload-date` -* `--compat-options 2022`: Same as `--compat-options 2023,playlist-match-filter,no-external-downloader-progress` -* `--compat-options 2023`: Same as `--compat-options prefer-legacy-http-handler,manifest-filesize-approx`. Use this to enable all future compat options +* `--compat-options 2022`: Same as `--compat-options 2023,playlist-match-filter,no-external-downloader-progress,prefer-legacy-http-handler,manifest-filesize-approx` +* `--compat-options 2023`: Currently does nothing. Use this to enable all future compat options # INSTALLATION @@ -1311,7 +1311,8 @@ # OUTPUT TEMPLATE - `display_id` (string): An alternative identifier for the video - `uploader` (string): Full name of the video uploader - `license` (string): License name the video is licensed under - - `creator` (string): The creator of the video + - `creators` (list): The creators of the video + - `creator` (string): The creators of the video; comma-separated - `timestamp` (numeric): UNIX timestamp of the moment the video became available - `upload_date` (string): Video upload date in UTC (YYYYMMDD) - `release_timestamp` (numeric): UNIX timestamp of the moment the video was released @@ -1385,11 +1386,16 @@ # OUTPUT TEMPLATE - `track` (string): Title of the track - `track_number` (numeric): Number of the track within an album or a disc - `track_id` (string): Id of the track - - `artist` (string): Artist(s) of the track - - `genre` (string): Genre(s) of the track + - `artists` (list): Artist(s) of the track + - `artist` (string): Artist(s) of the track; comma-separated + - `genres` (list): Genre(s) of the track + - `genre` (string): Genre(s) of the track; comma-separated + - `composers` (list): Composer(s) of the piece + - `composer` (string): Composer(s) of the piece; comma-separated - `album` (string): Title of the album the track belongs to - `album_type` (string): Type of the album - - `album_artist` (string): List of all artists appeared on the album + - `album_artists` (list): All artists appeared on the album + - `album_artist` (string): All artists appeared on the album; comma-separated - `disc_number` (numeric): Number of the disc or other physical medium the track belongs to Available only when using `--download-sections` and for `chapter:` prefix when using `--split-chapters` for videos with internal chapters: @@ -1767,10 +1773,11 @@ # MODIFYING METADATA `description`, `synopsis` | `description` `purl`, `comment` | `webpage_url` `track` | `track_number` -`artist` | `artist`, `creator`, `uploader` or `uploader_id` -`genre` | `genre` +`artist` | `artist`, `artists`, `creator`, `creators`, `uploader` or `uploader_id` +`composer` | `composer` or `composers` +`genre` | `genre` or `genres` `album` | `album` -`album_artist` | `album_artist` +`album_artist` | `album_artist` or `album_artists` `disc` | `disc_number` `show` | `series` `season_number` | `season_number` diff --git a/pyproject.toml b/pyproject.toml index 5ef013279..0c9c5fc01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,7 +94,6 @@ include = [ "/setup.cfg", "/supportedsites.md", ] -exclude = ["/yt_dlp/__pyinstaller"] artifacts = [ "/yt_dlp/extractor/lazy_extractors.py", "/completions", @@ -105,7 +104,6 @@ artifacts = [ [tool.hatch.build.targets.wheel] packages = ["yt_dlp"] -exclude = ["/yt_dlp/__pyinstaller"] artifacts = ["/yt_dlp/extractor/lazy_extractors.py"] [tool.hatch.build.targets.wheel.shared-data] diff --git a/test/helper.py b/test/helper.py index 4aca47025..7760fd8d7 100644 --- a/test/helper.py +++ b/test/helper.py @@ -223,6 +223,10 @@ def sanitize(key, value): if test_info_dict.get('display_id') == test_info_dict.get('id'): test_info_dict.pop('display_id') + # Remove deprecated fields + for old in YoutubeDL._deprecated_multivalue_fields.keys(): + test_info_dict.pop(old, None) + # release_year may be generated from release_date if try_call(lambda: test_info_dict['release_year'] == int(test_info_dict['release_date'][:4])): test_info_dict.pop('release_year') diff --git a/test/test_YoutubeDL.py b/test/test_YoutubeDL.py index 0087cbc94..6be47af97 100644 --- a/test/test_YoutubeDL.py +++ b/test/test_YoutubeDL.py @@ -941,7 +941,7 @@ def test_match_filter(self): def get_videos(filter_=None): ydl = YDL({'match_filter': filter_, 'simulate': True}) for v in videos: - ydl.process_ie_result(v, download=True) + ydl.process_ie_result(v.copy(), download=True) return [v['id'] for v in ydl.downloaded_info_dicts] res = get_videos() diff --git a/test/test_networking.py b/test/test_networking.py index 8cadd86f5..10534242a 100644 --- a/test/test_networking.py +++ b/test/test_networking.py @@ -13,6 +13,7 @@ import http.cookiejar import http.server import io +import logging import pathlib import random import ssl @@ -752,6 +753,25 @@ def test_certificate_nocombined_pass(self, handler): }) +class TestRequestHandlerMisc: + """Misc generic tests for request handlers, not related to request or validation testing""" + @pytest.mark.parametrize('handler,logger_name', [ + ('Requests', 'urllib3'), + ('Websockets', 'websockets.client'), + ('Websockets', 'websockets.server') + ], indirect=['handler']) + def test_remove_logging_handler(self, handler, logger_name): + # Ensure any logging handlers, which may contain a YoutubeDL instance, + # are removed when we close the request handler + # See: https://github.com/yt-dlp/yt-dlp/issues/8922 + logging_handlers = logging.getLogger(logger_name).handlers + before_count = len(logging_handlers) + rh = handler() + assert len(logging_handlers) == before_count + 1 + rh.close() + assert len(logging_handlers) == before_count + + class TestUrllibRequestHandler(TestRequestHandlerBase): @pytest.mark.parametrize('handler', ['Urllib'], indirect=True) def test_file_urls(self, handler): @@ -827,6 +847,7 @@ def test_httplib_validation_errors(self, handler, req, match, version_check): assert not isinstance(exc_info.value, TransportError) +@pytest.mark.parametrize('handler', ['Requests'], indirect=True) class TestRequestsRequestHandler(TestRequestHandlerBase): @pytest.mark.parametrize('raised,expected', [ (lambda: requests.exceptions.ConnectTimeout(), TransportError), @@ -843,7 +864,6 @@ class TestRequestsRequestHandler(TestRequestHandlerBase): (lambda: requests.exceptions.RequestException(), RequestError) # (lambda: requests.exceptions.TooManyRedirects(), HTTPError) - Needs a response object ]) - @pytest.mark.parametrize('handler', ['Requests'], indirect=True) def test_request_error_mapping(self, handler, monkeypatch, raised, expected): with handler() as rh: def mock_get_instance(*args, **kwargs): @@ -877,7 +897,6 @@ def request(self, *args, **kwargs): '3 bytes read, 5 more expected' ), ]) - @pytest.mark.parametrize('handler', ['Requests'], indirect=True) def test_response_error_mapping(self, handler, monkeypatch, raised, expected, match): from requests.models import Response as RequestsResponse from urllib3.response import HTTPResponse as Urllib3Response @@ -896,6 +915,21 @@ def mock_read(*args, **kwargs): assert exc_info.type is expected + def test_close(self, handler, monkeypatch): + rh = handler() + session = rh._get_instance(cookiejar=rh.cookiejar) + called = False + original_close = session.close + + def mock_close(*args, **kwargs): + nonlocal called + called = True + return original_close(*args, **kwargs) + + monkeypatch.setattr(session, 'close', mock_close) + rh.close() + assert called + def run_validation(handler, error, req, **handler_kwargs): with handler(**handler_kwargs) as rh: @@ -1205,6 +1239,19 @@ def some_preference(rh, request): assert director.send(Request('http://')).read() == b'' assert director.send(Request('http://', headers={'prefer': '1'})).read() == b'supported' + def test_close(self, monkeypatch): + director = RequestDirector(logger=FakeLogger()) + director.add_handler(FakeRH(logger=FakeLogger())) + called = False + + def mock_close(*args, **kwargs): + nonlocal called + called = True + + monkeypatch.setattr(director.handlers[FakeRH.RH_KEY], 'close', mock_close) + director.close() + assert called + # XXX: do we want to move this to test_YoutubeDL.py? class TestYoutubeDLNetworking: diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py index e7d654d0f..ef66306b1 100644 --- a/yt_dlp/YoutubeDL.py +++ b/yt_dlp/YoutubeDL.py @@ -580,6 +580,13 @@ class YoutubeDL: 'http_headers', 'stretched_ratio', 'no_resume', 'has_drm', 'extra_param_to_segment_url', 'hls_aes', 'downloader_options', 'page_url', 'app', 'play_path', 'tc_url', 'flash_version', 'rtmp_live', 'rtmp_conn', 'rtmp_protocol', 'rtmp_real_time' } + _deprecated_multivalue_fields = { + 'album_artist': 'album_artists', + 'artist': 'artists', + 'composer': 'composers', + 'creator': 'creators', + 'genre': 'genres', + } _format_selection_exts = { 'audio': set(MEDIA_EXTENSIONS.common_audio), 'video': set(MEDIA_EXTENSIONS.common_video + ('3gp', )), @@ -683,7 +690,6 @@ def process_color_policy(stream): self.params['http_headers'] = HTTPHeaderDict(std_headers, self.params.get('http_headers')) self._load_cookies(self.params['http_headers'].get('Cookie')) # compat self.params['http_headers'].pop('Cookie', None) - self._request_director = self.build_request_director(_REQUEST_HANDLERS.values(), _RH_PREFERENCES) if auto_init and auto_init != 'no_verbose_header': self.print_debug_header() @@ -957,6 +963,7 @@ def __exit__(self, *args): def close(self): self.save_cookies() self._request_director.close() + del self._request_director def trouble(self, message=None, tb=None, is_error=True): """Determine action to take when a download problem appears. @@ -2640,6 +2647,14 @@ def _fill_common_fields(self, info_dict, final=True): if final and info_dict.get('%s_number' % field) is not None and not info_dict.get(field): info_dict[field] = '%s %d' % (field.capitalize(), info_dict['%s_number' % field]) + for old_key, new_key in self._deprecated_multivalue_fields.items(): + if new_key in info_dict and old_key in info_dict: + self.deprecation_warning(f'Do not return {old_key!r} when {new_key!r} is present') + elif old_value := info_dict.get(old_key): + info_dict[new_key] = old_value.split(', ') + elif new_value := info_dict.get(new_key): + info_dict[old_key] = ', '.join(v.replace(',', '\N{FULLWIDTH COMMA}') for v in new_value) + def _raise_pending_errors(self, info): err = info.pop('__pending_error', None) if err: @@ -3483,7 +3498,8 @@ def ffmpeg_fixup(cndn, msg, cls): or info_dict.get('is_live') and self.params.get('hls_use_mpegts') is None, 'Possible MPEG-TS in MP4 container or malformed AAC timestamps', FFmpegFixupM3u8PP) - ffmpeg_fixup(info_dict.get('is_live') and downloader == 'dashsegments', + ffmpeg_fixup(downloader == 'dashsegments' + and (info_dict.get('is_live') or info_dict.get('is_dash_periods')), 'Possible duplicate MOOV atoms', FFmpegFixupDuplicateMoovPP) ffmpeg_fixup(downloader == 'web_socket_fragment', 'Malformed timestamps detected', FFmpegFixupTimestampPP) @@ -4144,6 +4160,10 @@ def build_request_director(self, handlers, preferences=None): director.preferences.add(lambda rh, _: 500 if rh.RH_KEY == 'Urllib' else 0) return director + @functools.cached_property + def _request_director(self): + return self.build_request_director(_REQUEST_HANDLERS.values(), _RH_PREFERENCES) + def encode(self, s): if isinstance(s, bytes): return s # Already encoded diff --git a/yt_dlp/__init__.py b/yt_dlp/__init__.py index 57a487157..4380b888d 100644 --- a/yt_dlp/__init__.py +++ b/yt_dlp/__init__.py @@ -14,7 +14,7 @@ import re import traceback -from .compat import compat_shlex_quote +from .compat import compat_os_name, compat_shlex_quote from .cookies import SUPPORTED_BROWSERS, SUPPORTED_KEYRINGS from .downloader.external import get_external_downloader from .extractor import list_extractor_classes @@ -984,7 +984,28 @@ def _real_main(argv=None): if pre_process: return ydl._download_retcode - ydl.warn_if_short_id(sys.argv[1:] if argv is None else argv) + args = sys.argv[1:] if argv is None else argv + ydl.warn_if_short_id(args) + + # Show a useful error message and wait for keypress if not launched from shell on Windows + if not args and compat_os_name == 'nt' and getattr(sys, 'frozen', False): + import ctypes.wintypes + import msvcrt + + kernel32 = ctypes.WinDLL('Kernel32') + + buffer = (1 * ctypes.wintypes.DWORD)() + attached_processes = kernel32.GetConsoleProcessList(buffer, 1) + # If we only have a single process attached, then the executable was double clicked + # When using `pyinstaller` with `--onefile`, two processes get attached + is_onefile = hasattr(sys, '_MEIPASS') and os.path.basename(sys._MEIPASS).startswith('_MEI') + if attached_processes == 1 or is_onefile and attached_processes == 2: + print(parser._generate_error_message( + 'Do not double-click the executable, instead call it from a command line.\n' + 'Please read the README for further information on how to use yt-dlp: ' + 'https://github.com/yt-dlp/yt-dlp#readme')) + msvcrt.getch() + _exit(2) parser.error( 'You must provide at least one URL.\n' 'Type yt-dlp --help to see a list of all options.') diff --git a/yt_dlp/__pyinstaller/hook-yt_dlp.py b/yt_dlp/__pyinstaller/hook-yt_dlp.py index 20f037d32..bc843717c 100644 --- a/yt_dlp/__pyinstaller/hook-yt_dlp.py +++ b/yt_dlp/__pyinstaller/hook-yt_dlp.py @@ -31,4 +31,4 @@ def get_hidden_imports(): hiddenimports = list(get_hidden_imports()) print(f'Adding imports: {hiddenimports}') -excludedimports = ['youtube_dl', 'youtube_dlc', 'test', 'ytdlp_plugins', 'devscripts'] +excludedimports = ['youtube_dl', 'youtube_dlc', 'test', 'ytdlp_plugins', 'devscripts', 'bundle'] diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py index 5d1dd6038..d09502e5a 100644 --- a/yt_dlp/extractor/_extractors.py +++ b/yt_dlp/extractor/_extractors.py @@ -379,7 +379,6 @@ from .clyp import ClypIE from .cmt import CMTIE from .cnbc import ( - CNBCIE, CNBCVideoIE, ) from .cnn import ( @@ -618,6 +617,7 @@ from .filmweb import FilmwebIE from .firsttv import FirstTVIE from .fivetv import FiveTVIE +from .flextv import FlexTVIE from .flickr import FlickrIE from .floatplane import ( FloatplaneIE, @@ -2499,6 +2499,7 @@ Zee5SeriesIE, ) from .zeenews import ZeeNewsIE +from .zenporn import ZenPornIE from .zetland import ZetlandDKArticleIE from .zhihu import ZhihuIE from .zingmp3 import ( diff --git a/yt_dlp/extractor/altcensored.py b/yt_dlp/extractor/altcensored.py index 0e1627bfd..a8428ce2e 100644 --- a/yt_dlp/extractor/altcensored.py +++ b/yt_dlp/extractor/altcensored.py @@ -22,7 +22,7 @@ class AltCensoredIE(InfoExtractor): 'title': "QUELLES SONT LES CONSÉQUENCES DE L'HYPERSEXUALISATION DE LA SOCIÉTÉ ?", 'display_id': 'k0srjLSkga8.webm', 'release_date': '20180403', - 'creator': 'Virginie Vota', + 'creators': ['Virginie Vota'], 'release_year': 2018, 'upload_date': '20230318', 'uploader': 'admin@altcensored.com', @@ -32,7 +32,7 @@ class AltCensoredIE(InfoExtractor): 'duration': 926.09, 'thumbnail': 'https://archive.org/download/youtube-k0srjLSkga8/youtube-k0srjLSkga8.thumbs/k0srjLSkga8_000925.jpg', 'view_count': int, - 'categories': ['News & Politics'], + 'categories': ['News & Politics'], # FIXME } }] @@ -62,14 +62,21 @@ class AltCensoredChannelIE(InfoExtractor): 'title': 'Virginie Vota', 'id': 'UCFPTO55xxHqFqkzRZHu4kcw', }, - 'playlist_count': 91 + 'playlist_count': 85, }, { 'url': 'https://altcensored.com/channel/UC9CcJ96HKMWn0LZlcxlpFTw', 'info_dict': { 'title': 'yukikaze775', 'id': 'UC9CcJ96HKMWn0LZlcxlpFTw', }, - 'playlist_count': 4 + 'playlist_count': 4, + }, { + 'url': 'https://altcensored.com/channel/UCfYbb7nga6-icsFWWgS-kWw', + 'info_dict': { + 'title': 'Mister Metokur', + 'id': 'UCfYbb7nga6-icsFWWgS-kWw', + }, + 'playlist_count': 121, }] def _real_extract(self, url): @@ -78,7 +85,7 @@ def _real_extract(self, url): url, channel_id, 'Download channel webpage', 'Unable to get channel webpage') title = self._html_search_meta('altcen_title', webpage, 'title', fatal=False) page_count = int_or_none(self._html_search_regex( - r']+href="/channel/\w+/page/(\d+)">(?:\1)', + r']+href="/channel/[\w-]+/page/(\d+)">(?:\1)', webpage, 'page count', default='1')) def page_func(page_num): diff --git a/yt_dlp/extractor/archiveorg.py b/yt_dlp/extractor/archiveorg.py index 3bb6f2e31..c1bc1ba92 100644 --- a/yt_dlp/extractor/archiveorg.py +++ b/yt_dlp/extractor/archiveorg.py @@ -300,7 +300,7 @@ def _real_extract(self, url): is_logged_in = bool(self._get_cookies('https://archive.org').get('logged-in-sig')) if extension in KNOWN_EXTENSIONS and (not f.get('private') or is_logged_in): entry['formats'].append({ - 'url': 'https://archive.org/download/' + identifier + '/' + f['name'], + 'url': 'https://archive.org/download/' + identifier + '/' + urllib.parse.quote(f['name']), 'format': f.get('format'), 'width': int_or_none(f.get('width')), 'height': int_or_none(f.get('height')), diff --git a/yt_dlp/extractor/bilibili.py b/yt_dlp/extractor/bilibili.py index c138bde3a..f4e1c91a8 100644 --- a/yt_dlp/extractor/bilibili.py +++ b/yt_dlp/extractor/bilibili.py @@ -1996,7 +1996,7 @@ def _extract_video_metadata(self, url, video_id, season_id): 'title': get_element_by_class( 'bstar-meta__title', webpage) or self._html_search_meta('og:title', webpage), 'description': get_element_by_class( - 'bstar-meta__desc', webpage) or self._html_search_meta('og:description'), + 'bstar-meta__desc', webpage) or self._html_search_meta('og:description', webpage), }, self._search_json_ld(webpage, video_id, default={})) def _get_comments_reply(self, root_id, next_id=0, display_id=None): diff --git a/yt_dlp/extractor/chzzk.py b/yt_dlp/extractor/chzzk.py index 6894baea5..420fe0514 100644 --- a/yt_dlp/extractor/chzzk.py +++ b/yt_dlp/extractor/chzzk.py @@ -2,7 +2,7 @@ from .common import InfoExtractor from ..utils import ( - ExtractorError, + UserNotLive, float_or_none, int_or_none, parse_iso8601, @@ -40,7 +40,7 @@ def _real_extract(self, url): note='Downloading channel info', errnote='Unable to download channel info')['content'] if live_detail.get('status') == 'CLOSE': - raise ExtractorError('The channel is not currently live', expected=True) + raise UserNotLive(video_id=channel_id) live_playback = self._parse_json(live_detail['livePlaybackJson'], channel_id) diff --git a/yt_dlp/extractor/cloudflarestream.py b/yt_dlp/extractor/cloudflarestream.py index c4c7d66a5..a812c24af 100644 --- a/yt_dlp/extractor/cloudflarestream.py +++ b/yt_dlp/extractor/cloudflarestream.py @@ -4,27 +4,25 @@ class CloudflareStreamIE(InfoExtractor): + _SUBDOMAIN_RE = r'(?:(?:watch|iframe|customer-\w+)\.)?' _DOMAIN_RE = r'(?:cloudflarestream\.com|(?:videodelivery|bytehighway)\.net)' - _EMBED_RE = r'embed\.%s/embed/[^/]+\.js\?.*?\bvideo=' % _DOMAIN_RE + _EMBED_RE = rf'embed\.{_DOMAIN_RE}/embed/[^/]+\.js\?.*?\bvideo=' _ID_RE = r'[\da-f]{32}|[\w-]+\.[\w-]+\.[\w-]+' - _VALID_URL = r'''(?x) - https?:// - (?: - (?:watch\.)?%s/| - %s - ) - (?P%s) - ''' % (_DOMAIN_RE, _EMBED_RE, _ID_RE) - _EMBED_REGEX = [fr']+\bsrc=(["\'])(?P(?:https?:)?//{_EMBED_RE}(?:{_ID_RE}).*?)\1'] + _VALID_URL = rf'https?://(?:{_SUBDOMAIN_RE}{_DOMAIN_RE}/|{_EMBED_RE})(?P{_ID_RE})' + _EMBED_REGEX = [ + rf']+\bsrc=(["\'])(?P(?:https?:)?//{_EMBED_RE}(?:{_ID_RE}).*?)\1', + rf']+\bsrc=["\'](?Phttps?://{_SUBDOMAIN_RE}{_DOMAIN_RE}/[\da-f]{{32}})', + ] _TESTS = [{ 'url': 'https://embed.cloudflarestream.com/embed/we4g.fla9.latest.js?video=31c9291ab41fac05471db4e73aa11717', 'info_dict': { 'id': '31c9291ab41fac05471db4e73aa11717', 'ext': 'mp4', 'title': '31c9291ab41fac05471db4e73aa11717', + 'thumbnail': 'https://videodelivery.net/31c9291ab41fac05471db4e73aa11717/thumbnails/thumbnail.jpg', }, 'params': { - 'skip_download': True, + 'skip_download': 'm3u8', }, }, { 'url': 'https://watch.cloudflarestream.com/9df17203414fd1db3e3ed74abbe936c1', @@ -35,6 +33,21 @@ class CloudflareStreamIE(InfoExtractor): }, { 'url': 'https://embed.videodelivery.net/embed/r4xu.fla9.latest.js?video=81d80727f3022488598f68d323c1ad5e', 'only_matching': True, + }, { + 'url': 'https://customer-aw5py76sw8wyqzmh.cloudflarestream.com/2463f6d3e06fa29710a337f5f5389fd8/iframe', + 'only_matching': True, + }] + _WEBPAGE_TESTS = [{ + 'url': 'https://upride.cc/incident/shoulder-pass-at-light/', + 'info_dict': { + 'id': 'eaef9dea5159cf968be84241b5cedfe7', + 'ext': 'mp4', + 'title': 'eaef9dea5159cf968be84241b5cedfe7', + 'thumbnail': 'https://videodelivery.net/eaef9dea5159cf968be84241b5cedfe7/thumbnails/thumbnail.jpg', + }, + 'params': { + 'skip_download': 'm3u8', + }, }] def _real_extract(self, url): diff --git a/yt_dlp/extractor/cnbc.py b/yt_dlp/extractor/cnbc.py index 7d209b6d9..b8ce2b49a 100644 --- a/yt_dlp/extractor/cnbc.py +++ b/yt_dlp/extractor/cnbc.py @@ -1,68 +1,97 @@ from .common import InfoExtractor -from ..utils import smuggle_url - - -class CNBCIE(InfoExtractor): - _VALID_URL = r'https?://video\.cnbc\.com/gallery/\?video=(?P[0-9]+)' - _TEST = { - 'url': 'http://video.cnbc.com/gallery/?video=3000503714', - 'info_dict': { - 'id': '3000503714', - 'ext': 'mp4', - 'title': 'Fighting zombies is big business', - 'description': 'md5:0c100d8e1a7947bd2feec9a5550e519e', - 'timestamp': 1459332000, - 'upload_date': '20160330', - 'uploader': 'NBCU-CNBC', - }, - 'params': { - # m3u8 download - 'skip_download': True, - }, - 'skip': 'Dead link', - } - - def _real_extract(self, url): - video_id = self._match_id(url) - return { - '_type': 'url_transparent', - 'ie_key': 'ThePlatform', - 'url': smuggle_url( - 'http://link.theplatform.com/s/gZWlPC/media/guid/2408950221/%s?mbr=true&manifest=m3u' % video_id, - {'force_smil_url': True}), - 'id': video_id, - } +from ..utils import int_or_none, parse_iso8601, str_or_none, url_or_none +from ..utils.traversal import traverse_obj class CNBCVideoIE(InfoExtractor): - _VALID_URL = r'https?://(?:www\.)?cnbc\.com(?P/video/(?:[^/]+/)+(?P[^./?#&]+)\.html)' - _TEST = { - 'url': 'https://www.cnbc.com/video/2018/07/19/trump-i-dont-necessarily-agree-with-raising-rates.html', + _VALID_URL = r'https?://(?:www\.)?cnbc\.com/video/(?:[^/?#]+/)+(?P[^./?#&]+)\.html' + + _TESTS = [{ + 'url': 'https://www.cnbc.com/video/2023/12/07/mcdonalds-just-unveiled-cosmcsits-new-spinoff-brand.html', 'info_dict': { - 'id': '7000031301', 'ext': 'mp4', - 'title': "Trump: I don't necessarily agree with raising rates", - 'description': 'md5:878d8f0b4ebb5bb1dda3514b91b49de3', - 'timestamp': 1531958400, - 'upload_date': '20180719', - 'uploader': 'NBCU-CNBC', + 'id': '107344774', + 'display_id': 'mcdonalds-just-unveiled-cosmcsits-new-spinoff-brand', + 'modified_timestamp': 1702053483, + 'timestamp': 1701977810, + 'channel': 'News Videos', + 'upload_date': '20231207', + 'description': 'md5:882c001d85cb43d7579b514307b3e78b', + 'release_timestamp': 1701977375, + 'modified_date': '20231208', + 'release_date': '20231207', + 'duration': 65, + 'author': 'Sean Conlon', + 'title': 'Here\'s a first look at McDonald\'s new spinoff brand, CosMc\'s', + 'thumbnail': 'https://image.cnbcfm.com/api/v1/image/107344192-1701894812493-CosMcsskyHero_2336x1040_hero-desktop.jpg?v=1701894855', }, - 'params': { - 'skip_download': True, + 'expected_warnings': ['Unable to download f4m manifest'], + }, { + 'url': 'https://www.cnbc.com/video/2023/12/08/jim-cramer-shares-his-take-on-seattles-tech-scene.html', + 'info_dict': { + 'author': 'Jim Cramer', + 'channel': 'Mad Money with Jim Cramer', + 'description': 'md5:72925be21b952e95eba51178dddf4e3e', + 'duration': 299.0, + 'ext': 'mp4', + 'id': '107345451', + 'display_id': 'jim-cramer-shares-his-take-on-seattles-tech-scene', + 'thumbnail': 'https://image.cnbcfm.com/api/v1/image/107345481-1702079431MM-B-120823.jpg?v=1702079430', + 'timestamp': 1702080139, + 'title': 'Jim Cramer shares his take on Seattle\'s tech scene', + 'release_date': '20231208', + 'upload_date': '20231209', + 'modified_timestamp': 1702080139, + 'modified_date': '20231209', + 'release_timestamp': 1702073551, }, - 'skip': 'Dead link', - } + 'expected_warnings': ['Unable to download f4m manifest'], + }, { + 'url': 'https://www.cnbc.com/video/2023/12/08/the-epicenter-of-ai-is-in-seattle-says-jim-cramer.html', + 'info_dict': { + 'author': 'Jim Cramer', + 'channel': 'Mad Money with Jim Cramer', + 'description': 'md5:72925be21b952e95eba51178dddf4e3e', + 'duration': 113.0, + 'ext': 'mp4', + 'id': '107345474', + 'display_id': 'the-epicenter-of-ai-is-in-seattle-says-jim-cramer', + 'thumbnail': 'https://image.cnbcfm.com/api/v1/image/107345486-Screenshot_2023-12-08_at_70339_PM.png?v=1702080248', + 'timestamp': 1702080535, + 'title': 'The epicenter of AI is in Seattle, says Jim Cramer', + 'release_timestamp': 1702077347, + 'modified_timestamp': 1702080535, + 'release_date': '20231208', + 'upload_date': '20231209', + 'modified_date': '20231209', + }, + 'expected_warnings': ['Unable to download f4m manifest'], + }] def _real_extract(self, url): - path, display_id = self._match_valid_url(url).groups() - video_id = self._download_json( - 'https://webql-redesign.cnbcfm.com/graphql', display_id, query={ - 'query': '''{ - page(path: "%s") { - vcpsId - } -}''' % path, - })['data']['page']['vcpsId'] - return self.url_result( - 'http://video.cnbc.com/gallery/?video=%d' % video_id, - CNBCIE.ie_key()) + display_id = self._match_id(url) + webpage = self._download_webpage(url, display_id) + data = self._search_json(r'window\.__s_data=', webpage, 'video data', display_id) + + player_data = traverse_obj(data, ( + 'page', 'page', 'layout', ..., 'columns', ..., 'modules', + lambda _, v: v['name'] == 'clipPlayer', 'data', {dict}), get_all=False) + + return { + 'id': display_id, + 'display_id': display_id, + 'formats': self._extract_akamai_formats(player_data['playbackURL'], display_id), + **self._search_json_ld(webpage, display_id, fatal=False), + **traverse_obj(player_data, { + 'id': ('id', {str_or_none}), + 'title': ('title', {str}), + 'description': ('description', {str}), + 'author': ('author', ..., 'name', {str}), + 'timestamp': ('datePublished', {parse_iso8601}), + 'release_timestamp': ('uploadDate', {parse_iso8601}), + 'modified_timestamp': ('dateLastPublished', {parse_iso8601}), + 'thumbnail': ('thumbnail', {url_or_none}), + 'duration': ('duration', {int_or_none}), + 'channel': ('section', 'title', {str}), + }, get_all=False), + } diff --git a/yt_dlp/extractor/common.py b/yt_dlp/extractor/common.py index 89fc946ac..7fc766803 100644 --- a/yt_dlp/extractor/common.py +++ b/yt_dlp/extractor/common.py @@ -247,6 +247,8 @@ class InfoExtractor: (For internal use only) * http_chunk_size Chunk size for HTTP downloads * ffmpeg_args Extra arguments for ffmpeg downloader + * is_dash_periods Whether the format is a result of merging + multiple DASH periods. RTMP formats can also have the additional fields: page_url, app, play_path, tc_url, flash_version, rtmp_live, rtmp_conn, rtmp_protocol, rtmp_real_time @@ -278,7 +280,7 @@ class InfoExtractor: description: Full video description. uploader: Full name of the video uploader. license: License name the video is licensed under. - creator: The creator of the video. + creators: List of creators of the video. timestamp: UNIX timestamp of the moment the video was uploaded upload_date: Video upload date in UTC (YYYYMMDD). If not explicitly set, calculated from timestamp @@ -422,16 +424,16 @@ class InfoExtractor: track_number: Number of the track within an album or a disc, as an integer. track_id: Id of the track (useful in case of custom indexing, e.g. 6.iii), as a unicode string. - artist: Artist(s) of the track. - genre: Genre(s) of the track. + artists: List of artists of the track. + composers: List of composers of the piece. + genres: List of genres of the track. album: Title of the album the track belongs to. album_type: Type of the album (e.g. "Demo", "Full-length", "Split", "Compilation", etc). - album_artist: List of all artists appeared on the album (e.g. - "Ash Borer / Fell Voices" or "Various Artists", useful for splits - and compilations). + album_artists: List of all artists appeared on the album. + E.g. ["Ash Borer", "Fell Voices"] or ["Various Artists"]. + Useful for splits and compilations. disc_number: Number of the disc or other physical medium the track belongs to, as an integer. - composer: Composer of the piece The following fields should only be set for clips that should be cut from the original video: @@ -442,6 +444,18 @@ class InfoExtractor: rows: Number of rows in each storyboard fragment, as an integer columns: Number of columns in each storyboard fragment, as an integer + The following fields are deprecated and should not be set by new code: + composer: Use "composers" instead. + Composer(s) of the piece, comma-separated. + artist: Use "artists" instead. + Artist(s) of the track, comma-separated. + genre: Use "genres" instead. + Genre(s) of the track, comma-separated. + album_artist: Use "album_artists" instead. + All artists appeared on the album, comma-separated. + creator: Use "creators" instead. + The creator of the video. + Unless mentioned otherwise, the fields should be Unicode strings. Unless mentioned otherwise, None is equivalent to absence of information. @@ -2547,7 +2561,11 @@ def _extract_mpd_formats(self, *args, **kwargs): self._report_ignoring_subs('DASH') return fmts - def _extract_mpd_formats_and_subtitles( + def _extract_mpd_formats_and_subtitles(self, *args, **kwargs): + periods = self._extract_mpd_periods(*args, **kwargs) + return self._merge_mpd_periods(periods) + + def _extract_mpd_periods( self, mpd_url, video_id, mpd_id=None, note=None, errnote=None, fatal=True, data=None, headers={}, query={}): @@ -2560,17 +2578,16 @@ def _extract_mpd_formats_and_subtitles( errnote='Failed to download MPD manifest' if errnote is None else errnote, fatal=fatal, data=data, headers=headers, query=query) if res is False: - return [], {} + return [] mpd_doc, urlh = res if mpd_doc is None: - return [], {} + return [] # We could have been redirected to a new url when we retrieved our mpd file. mpd_url = urlh.url mpd_base_url = base_url(mpd_url) - return self._parse_mpd_formats_and_subtitles( - mpd_doc, mpd_id, mpd_base_url, mpd_url) + return self._parse_mpd_periods(mpd_doc, mpd_id, mpd_base_url, mpd_url) def _parse_mpd_formats(self, *args, **kwargs): fmts, subs = self._parse_mpd_formats_and_subtitles(*args, **kwargs) @@ -2578,8 +2595,39 @@ def _parse_mpd_formats(self, *args, **kwargs): self._report_ignoring_subs('DASH') return fmts - def _parse_mpd_formats_and_subtitles( - self, mpd_doc, mpd_id=None, mpd_base_url='', mpd_url=None): + def _parse_mpd_formats_and_subtitles(self, *args, **kwargs): + periods = self._parse_mpd_periods(*args, **kwargs) + return self._merge_mpd_periods(periods) + + def _merge_mpd_periods(self, periods): + """ + Combine all formats and subtitles from an MPD manifest into a single list, + by concatenate streams with similar formats. + """ + formats, subtitles = {}, {} + for period in periods: + for f in period['formats']: + assert 'is_dash_periods' not in f, 'format already processed' + f['is_dash_periods'] = True + format_key = tuple(v for k, v in f.items() if k not in ( + ('format_id', 'fragments', 'manifest_stream_number'))) + if format_key not in formats: + formats[format_key] = f + elif 'fragments' in f: + formats[format_key].setdefault('fragments', []).extend(f['fragments']) + + if subtitles and period['subtitles']: + self.report_warning(bug_reports_message( + 'Found subtitles in multiple periods in the DASH manifest; ' + 'if part of the subtitles are missing,' + ), only_once=True) + + for sub_lang, sub_info in period['subtitles'].items(): + subtitles.setdefault(sub_lang, []).extend(sub_info) + + return list(formats.values()), subtitles + + def _parse_mpd_periods(self, mpd_doc, mpd_id=None, mpd_base_url='', mpd_url=None): """ Parse formats from MPD manifest. References: @@ -2658,9 +2706,13 @@ def extract_Initialization(source): return ms_info mpd_duration = parse_duration(mpd_doc.get('mediaPresentationDuration')) - formats, subtitles = [], {} stream_numbers = collections.defaultdict(int) - for period in mpd_doc.findall(_add_ns('Period')): + for period_idx, period in enumerate(mpd_doc.findall(_add_ns('Period'))): + period_entry = { + 'id': period.get('id', f'period-{period_idx}'), + 'formats': [], + 'subtitles': collections.defaultdict(list), + } period_duration = parse_duration(period.get('duration')) or mpd_duration period_ms_info = extract_multisegment_info(period, { 'start_number': 1, @@ -2910,11 +2962,10 @@ def add_segment_url(): if content_type in ('video', 'audio', 'image/jpeg'): f['manifest_stream_number'] = stream_numbers[f['url']] stream_numbers[f['url']] += 1 - formats.append(f) + period_entry['formats'].append(f) elif content_type == 'text': - subtitles.setdefault(lang or 'und', []).append(f) - - return formats, subtitles + period_entry['subtitles'][lang or 'und'].append(f) + yield period_entry def _extract_ism_formats(self, *args, **kwargs): fmts, subs = self._extract_ism_formats_and_subtitles(*args, **kwargs) diff --git a/yt_dlp/extractor/err.py b/yt_dlp/extractor/err.py index 129f39ad6..abd00f2d5 100644 --- a/yt_dlp/extractor/err.py +++ b/yt_dlp/extractor/err.py @@ -9,7 +9,7 @@ class ERRJupiterIE(InfoExtractor): - _VALID_URL = r'https?://jupiter(?:pluss)?\.err\.ee/(?P\d+)' + _VALID_URL = r'https?://(?:jupiter(?:pluss)?|lasteekraan)\.err\.ee/(?P\d+)' _TESTS = [{ 'note': 'Jupiter: Movie: siin-me-oleme', 'url': 'https://jupiter.err.ee/1211107/siin-me-oleme', @@ -145,6 +145,31 @@ class ERRJupiterIE(InfoExtractor): 'season_number': 0, 'series': 'Лесные истории | Аисты', 'series_id': '1037497', + } + }, { + 'note': 'Lasteekraan: Pätu', + 'url': 'https://lasteekraan.err.ee/1092243/patu', + 'md5': 'a67eb9b9bcb3d201718c15d1638edf77', + 'info_dict': { + 'id': '1092243', + 'ext': 'mp4', + 'title': 'Pätu', + 'alt_title': '', + 'description': 'md5:64a7b5a80afd7042d3f8ec48c77befd9', + 'release_date': '20230614', + 'upload_date': '20200520', + 'modified_date': '20200520', + 'release_timestamp': 1686745800, + 'timestamp': 1589975640, + 'modified_timestamp': 1589975640, + 'release_year': 1990, + 'episode': 'Episode 1', + 'episode_id': '1092243', + 'episode_number': 1, + 'season': 'Season 1', + 'season_number': 1, + 'series': 'Pätu', + 'series_id': '1092236', }, }] diff --git a/yt_dlp/extractor/flextv.py b/yt_dlp/extractor/flextv.py new file mode 100644 index 000000000..f3d3eff85 --- /dev/null +++ b/yt_dlp/extractor/flextv.py @@ -0,0 +1,62 @@ +from .common import InfoExtractor +from ..networking.exceptions import HTTPError +from ..utils import ( + ExtractorError, + UserNotLive, + parse_iso8601, + str_or_none, + traverse_obj, + url_or_none, +) + + +class FlexTVIE(InfoExtractor): + _VALID_URL = r'https?://(?:www\.)?flextv\.co\.kr/channels/(?P\d+)/live' + _TESTS = [{ + 'url': 'https://www.flextv.co.kr/channels/231638/live', + 'info_dict': { + 'id': '231638', + 'ext': 'mp4', + 'title': r're:^214하나만\.\.\. ', + 'thumbnail': r're:^https?://.+\.jpg', + 'upload_date': r're:\d{8}', + 'timestamp': int, + 'live_status': 'is_live', + 'channel': 'Hi별', + 'channel_id': '244396', + }, + 'skip': 'The channel is offline', + }, { + 'url': 'https://www.flextv.co.kr/channels/746/live', + 'only_matching': True, + }] + + def _real_extract(self, url): + channel_id = self._match_id(url) + + try: + stream_data = self._download_json( + f'https://api.flextv.co.kr/api/channels/{channel_id}/stream', + channel_id, query={'option': 'all'}) + except ExtractorError as e: + if isinstance(e.cause, HTTPError) and e.cause.status == 400: + raise UserNotLive(video_id=channel_id) + raise + + playlist_url = stream_data['sources'][0]['url'] + formats, subtitles = self._extract_m3u8_formats_and_subtitles( + playlist_url, channel_id, 'mp4') + + return { + 'id': channel_id, + 'formats': formats, + 'subtitles': subtitles, + 'is_live': True, + **traverse_obj(stream_data, { + 'title': ('stream', 'title', {str}), + 'timestamp': ('stream', 'createdAt', {parse_iso8601}), + 'thumbnail': ('thumbUrl', {url_or_none}), + 'channel': ('owner', 'name', {str}), + 'channel_id': ('owner', 'id', {str_or_none}), + }), + } diff --git a/yt_dlp/extractor/goplay.py b/yt_dlp/extractor/goplay.py index 0a3c8340f..74aad1192 100644 --- a/yt_dlp/extractor/goplay.py +++ b/yt_dlp/extractor/goplay.py @@ -40,6 +40,22 @@ class GoPlayIE(InfoExtractor): 'title': 'A Family for the Holidays', }, 'skip': 'This video is only available for registered users' + }, { + 'url': 'https://www.goplay.be/video/de-mol/de-mol-s11/de-mol-s11-aflevering-1#autoplay', + 'info_dict': { + 'id': '03eb8f2f-153e-41cb-9805-0d3a29dab656', + 'ext': 'mp4', + 'title': 'S11 - Aflevering 1', + 'episode': 'Episode 1', + 'series': 'De Mol', + 'season_number': 11, + 'episode_number': 1, + 'season': 'Season 11' + }, + 'params': { + 'skip_download': True + }, + 'skip': 'This video is only available for registered users' }] _id_token = None @@ -77,16 +93,39 @@ def _real_extract(self, url): api = self._download_json( f'https://api.goplay.be/web/v1/videos/long-form/{video_id}', - video_id, headers={'Authorization': 'Bearer %s' % self._id_token}) + video_id, headers={ + 'Authorization': 'Bearer %s' % self._id_token, + **self.geo_verification_headers(), + }) - formats, subs = self._extract_m3u8_formats_and_subtitles( - api['manifestUrls']['hls'], video_id, ext='mp4', m3u8_id='HLS') + if 'manifestUrls' in api: + formats, subtitles = self._extract_m3u8_formats_and_subtitles( + api['manifestUrls']['hls'], video_id, ext='mp4', m3u8_id='HLS') + + else: + if 'ssai' not in api: + raise ExtractorError('expecting Google SSAI stream') + + ssai_content_source_id = api['ssai']['contentSourceID'] + ssai_video_id = api['ssai']['videoID'] + + dai = self._download_json( + f'https://dai.google.com/ondemand/dash/content/{ssai_content_source_id}/vid/{ssai_video_id}/streams', + video_id, data=b'{"api-key":"null"}', + headers={'content-type': 'application/json'}) + + periods = self._extract_mpd_periods(dai['stream_manifest'], video_id) + + # skip pre-roll and mid-roll ads + periods = [p for p in periods if '-ad-' not in p['id']] + + formats, subtitles = self._merge_mpd_periods(periods) info_dict.update({ 'id': video_id, 'formats': formats, + 'subtitles': subtitles, }) - return info_dict diff --git a/yt_dlp/extractor/nebula.py b/yt_dlp/extractor/nebula.py index 136b0e10a..cb8f6a67d 100644 --- a/yt_dlp/extractor/nebula.py +++ b/yt_dlp/extractor/nebula.py @@ -1,6 +1,7 @@ import itertools import json +from .art19 import Art19IE from .common import InfoExtractor from ..networking.exceptions import HTTPError from ..utils import ( @@ -112,7 +113,8 @@ def _extract_video_metadata(self, episode): class NebulaIE(NebulaBaseIE): - _VALID_URL = rf'{_BASE_URL_RE}/videos/(?P[-\w]+)' + IE_NAME = 'nebula:video' + _VALID_URL = rf'{_BASE_URL_RE}/videos/(?P[\w-]+)' _TESTS = [{ 'url': 'https://nebula.tv/videos/that-time-disney-remade-beauty-and-the-beast', 'info_dict': { @@ -236,8 +238,8 @@ def _real_extract(self, url): class NebulaClassIE(NebulaBaseIE): - IE_NAME = 'nebula:class' - _VALID_URL = rf'{_BASE_URL_RE}/(?P[-\w]+)/(?P\d+)' + IE_NAME = 'nebula:media' + _VALID_URL = rf'{_BASE_URL_RE}/(?!(?:myshows|library|videos)/)(?P[\w-]+)/(?P[\w-]+)/?(?:$|[?#])' _TESTS = [{ 'url': 'https://nebula.tv/copyright-for-fun-and-profit/14', 'info_dict': { @@ -253,6 +255,46 @@ class NebulaClassIE(NebulaBaseIE): 'title': 'Photos, Sculpture, and Video', }, 'params': {'skip_download': 'm3u8'}, + }, { + 'url': 'https://nebula.tv/extremitiespodcast/pyramiden-the-high-arctic-soviet-ghost-town', + 'info_dict': { + 'ext': 'mp3', + 'id': '018f65f0-0033-4021-8f87-2d132beb19aa', + 'description': 'md5:05d2b23ab780c955e2511a2b9127acff', + 'series_id': '335e8159-d663-491a-888f-1732285706ac', + 'modified_timestamp': 1599091504, + 'episode_id': '018f65f0-0033-4021-8f87-2d132beb19aa', + 'series': 'Extremities', + 'modified_date': '20200903', + 'upload_date': '20200902', + 'title': 'Pyramiden: The High-Arctic Soviet Ghost Town', + 'release_timestamp': 1571237958, + 'thumbnail': r're:^https?://content\.production\.cdn\.art19\.com.*\.jpeg$', + 'duration': 1546.05714, + 'timestamp': 1599085608, + 'release_date': '20191016', + }, + }, { + 'url': 'https://nebula.tv/thelayover/the-layover-episode-1', + 'info_dict': { + 'ext': 'mp3', + 'id': '9d74a762-00bb-45a8-9e8d-9ed47c04a1d0', + 'episode_number': 1, + 'thumbnail': r're:^https?://content\.production\.cdn\.art19\.com.*\.jpeg$', + 'release_date': '20230304', + 'modified_date': '20230403', + 'series': 'The Layover', + 'episode_id': '9d74a762-00bb-45a8-9e8d-9ed47c04a1d0', + 'modified_timestamp': 1680554566, + 'duration': 3130.46401, + 'release_timestamp': 1677943800, + 'title': 'The Layover — Episode 1', + 'series_id': '874303a5-4900-4626-a4b6-2aacac34466a', + 'upload_date': '20230303', + 'episode': 'Episode 1', + 'timestamp': 1677883672, + 'description': 'md5:002cca89258e3bc7c268d5b8c24ba482', + }, }] def _real_extract(self, url): @@ -268,16 +310,38 @@ def _real_extract(self, url): metadata = self._call_api( f'https://content.api.nebula.app/content/{slug}/{episode}/?include=lessons', - slug, note='Fetching video metadata') - return { - **self._extract_video_metadata(metadata), - **self._extract_formats(metadata['id'], slug), - } + slug, note='Fetching class/podcast metadata') + content_type = metadata.get('type') + if content_type == 'lesson': + return { + **self._extract_video_metadata(metadata), + **self._extract_formats(metadata['id'], slug), + } + elif content_type == 'podcast_episode': + episode_url = metadata['episode_url'] + if not episode_url and metadata.get('premium'): + self.raise_login_required() + + if Art19IE.suitable(episode_url): + return self.url_result(episode_url, Art19IE) + return traverse_obj(metadata, { + 'id': ('id', {str}), + 'url': ('episode_url', {url_or_none}), + 'title': ('title', {str}), + 'description': ('description', {str}), + 'timestamp': ('published_at', {parse_iso8601}), + 'duration': ('duration', {int_or_none}), + 'channel_id': ('channel_id', {str}), + 'chnanel': ('channel_title', {str}), + 'thumbnail': ('assets', 'regular', {url_or_none}), + }) + + raise ExtractorError(f'Unexpected content type {content_type!r}') class NebulaSubscriptionsIE(NebulaBaseIE): IE_NAME = 'nebula:subscriptions' - _VALID_URL = rf'{_BASE_URL_RE}/(?Pmyshows|library/latest-videos)' + _VALID_URL = rf'{_BASE_URL_RE}/(?Pmyshows|library/latest-videos)/?(?:$|[?#])' _TESTS = [{ 'url': 'https://nebula.tv/myshows', 'playlist_mincount': 1, @@ -310,7 +374,7 @@ def _real_extract(self, url): class NebulaChannelIE(NebulaBaseIE): IE_NAME = 'nebula:channel' - _VALID_URL = rf'{_BASE_URL_RE}/(?!myshows|library|videos/)(?P[-\w]+)/?(?:$|[?#])' + _VALID_URL = rf'{_BASE_URL_RE}/(?!myshows|library|videos)(?P[\w-]+)/?(?:$|[?#])' _TESTS = [{ 'url': 'https://nebula.tv/tom-scott-presents-money', 'info_dict': { @@ -343,6 +407,14 @@ class NebulaChannelIE(NebulaBaseIE): 'description': 'md5:6690248223eed044a9f11cd5a24f9742', }, 'playlist_count': 23, + }, { + 'url': 'https://nebula.tv/trussissuespodcast', + 'info_dict': { + 'id': 'trussissuespodcast', + 'title': 'The TLDR News Podcast', + 'description': 'md5:a08c4483bc0b705881d3e0199e721385', + }, + 'playlist_mincount': 80, }] def _generate_playlist_entries(self, collection_id, collection_slug): @@ -365,6 +437,17 @@ def _generate_class_entries(self, channel): lesson.get('share_url') or f'https://nebula.tv/{metadata["class_slug"]}/{metadata["slug"]}', {'id': lesson['id']}), NebulaClassIE, url_transparent=True, **metadata) + def _generate_podcast_entries(self, collection_id, collection_slug): + next_url = f'https://content.api.nebula.app/podcast_channels/{collection_id}/podcast_episodes/?ordering=-published_at&premium=true' + for page_num in itertools.count(1): + episodes = self._call_api(next_url, collection_slug, note=f'Retrieving podcast page {page_num}') + + for episode in traverse_obj(episodes, ('results', lambda _, v: url_or_none(v['share_url']))): + yield self.url_result(episode['share_url'], NebulaClassIE) + next_url = episodes.get('next') + if not next_url: + break + def _real_extract(self, url): collection_slug = self._match_id(url) channel = self._call_api( @@ -373,6 +456,8 @@ def _real_extract(self, url): if channel.get('type') == 'class': entries = self._generate_class_entries(channel) + elif channel.get('type') == 'podcast_channel': + entries = self._generate_podcast_entries(channel['id'], collection_slug) else: entries = self._generate_playlist_entries(channel['id'], collection_slug) diff --git a/yt_dlp/extractor/nerdcubed.py b/yt_dlp/extractor/nerdcubed.py index 7c801b5d3..5f5607a20 100644 --- a/yt_dlp/extractor/nerdcubed.py +++ b/yt_dlp/extractor/nerdcubed.py @@ -1,33 +1,38 @@ -import datetime - from .common import InfoExtractor +from .youtube import YoutubeIE +from ..utils import parse_iso8601, url_or_none +from ..utils.traversal import traverse_obj class NerdCubedFeedIE(InfoExtractor): - _VALID_URL = r'https?://(?:www\.)?nerdcubed\.co\.uk/feed\.json' + _VALID_URL = r'https?://(?:www\.)?nerdcubed\.co\.uk/?(?:$|[#?])' _TEST = { - 'url': 'http://www.nerdcubed.co.uk/feed.json', + 'url': 'http://www.nerdcubed.co.uk/', 'info_dict': { 'id': 'nerdcubed-feed', 'title': 'nerdcubed.co.uk feed', }, - 'playlist_mincount': 1300, + 'playlist_mincount': 5500, } + def _extract_video(self, feed_entry): + return self.url_result( + f'https://www.youtube.com/watch?v={feed_entry["id"]}', YoutubeIE, + **traverse_obj(feed_entry, { + 'id': ('id', {str}), + 'title': ('title', {str}), + 'description': ('description', {str}), + 'timestamp': ('publishedAt', {parse_iso8601}), + 'channel': ('source', 'name', {str}), + 'channel_id': ('source', 'id', {str}), + 'channel_url': ('source', 'url', {str}), + 'thumbnail': ('thumbnail', 'source', {url_or_none}), + }), url_transparent=True) + def _real_extract(self, url): - feed = self._download_json(url, url, 'Downloading NerdCubed JSON feed') + video_id = 'nerdcubed-feed' + feed = self._download_json('https://www.nerdcubed.co.uk/_/cdn/videos.json', video_id) - entries = [{ - '_type': 'url', - 'title': feed_entry['title'], - 'uploader': feed_entry['source']['name'] if feed_entry['source'] else None, - 'upload_date': datetime.datetime.strptime(feed_entry['date'], '%Y-%m-%d').strftime('%Y%m%d'), - 'url': 'http://www.youtube.com/watch?v=' + feed_entry['youtube_id'], - } for feed_entry in feed] - - return { - '_type': 'playlist', - 'title': 'nerdcubed.co.uk feed', - 'id': 'nerdcubed-feed', - 'entries': entries, - } + return self.playlist_result( + map(self._extract_video, traverse_obj(feed, ('videos', lambda _, v: v['id']))), + video_id, 'nerdcubed.co.uk feed') diff --git a/yt_dlp/extractor/nhk.py b/yt_dlp/extractor/nhk.py index 4b3d185a3..7cf5b246b 100644 --- a/yt_dlp/extractor/nhk.py +++ b/yt_dlp/extractor/nhk.py @@ -9,6 +9,7 @@ join_nonempty, parse_duration, traverse_obj, + try_call, unescapeHTML, unified_timestamp, url_or_none, @@ -473,22 +474,21 @@ class NhkRadiruIE(InfoExtractor): IE_DESC = 'NHK らじる (Radiru/Rajiru)' _VALID_URL = r'https?://www\.nhk\.or\.jp/radio/(?:player/ondemand|ondemand/detail)\.html\?p=(?P[\da-zA-Z]+)_(?P[\da-zA-Z]+)(?:_(?P[\da-zA-Z]+))?' _TESTS = [{ - 'url': 'https://www.nhk.or.jp/radio/player/ondemand.html?p=0449_01_3853544', - 'skip': 'Episode expired on 2023-04-16', + 'url': 'https://www.nhk.or.jp/radio/player/ondemand.html?p=0449_01_3926210', + 'skip': 'Episode expired on 2024-02-24', 'info_dict': { - 'channel': 'NHK-FM', - 'uploader': 'NHK-FM', - 'description': 'md5:94b08bdeadde81a97df4ec882acce3e9', + 'title': 'ジャズ・トゥナイト シリーズJAZZジャイアンツ 56 ジョニー・ホッジス', + 'id': '0449_01_3926210', 'ext': 'm4a', - 'id': '0449_01_3853544', 'series': 'ジャズ・トゥナイト', + 'uploader': 'NHK-FM', + 'channel': 'NHK-FM', 'thumbnail': 'https://www.nhk.or.jp/prog/img/449/g449.jpg', - 'timestamp': 1680969600, - 'title': 'ジャズ・トゥナイト NEWジャズ特集', - 'upload_date': '20230408', - 'release_timestamp': 1680962400, - 'release_date': '20230408', - 'was_live': True, + 'release_date': '20240217', + 'description': 'md5:a456ee8e5e59e6dd2a7d32e62386e811', + 'timestamp': 1708185600, + 'release_timestamp': 1708178400, + 'upload_date': '20240217', }, }, { # playlist, airs every weekday so it should _hopefully_ be okay forever @@ -519,7 +519,8 @@ class NhkRadiruIE(InfoExtractor): 'series': 'らじる文庫 by ラジオ深夜便 ', 'release_timestamp': 1481126700, 'upload_date': '20211101', - } + }, + 'expected_warnings': ['Unable to download JSON metadata', 'Failed to get extended description'], }, { # news 'url': 'https://www.nhk.or.jp/radio/player/ondemand.html?p=F261_01_3855109', @@ -539,9 +540,28 @@ class NhkRadiruIE(InfoExtractor): }, }] + _API_URL_TMPL = None + + def _extract_extended_description(self, episode_id, episode): + service, _, area = traverse_obj(episode, ('aa_vinfo2', {str}, {lambda x: (x or '').partition(',')})) + aa_vinfo3 = traverse_obj(episode, ('aa_vinfo3', {str})) + detail_url = try_call( + lambda: self._API_URL_TMPL.format(service=service, area=area, dateid=aa_vinfo3)) + if not detail_url: + return + + full_meta = traverse_obj( + self._download_json(detail_url, episode_id, 'Downloading extended metadata', fatal=False), + ('list', service, 0, {dict})) or {} + return join_nonempty('subtitle', 'content', 'act', 'music', delim='\n\n', from_dict=full_meta) + def _extract_episode_info(self, headline, programme_id, series_meta): episode_id = f'{programme_id}_{headline["headline_id"]}' episode = traverse_obj(headline, ('file_list', 0, {dict})) + description = self._extract_extended_description(episode_id, episode) + if not description: + self.report_warning('Failed to get extended description, falling back to summary') + description = traverse_obj(episode, ('file_title_sub', {str})) return { **series_meta, @@ -551,14 +571,21 @@ def _extract_episode_info(self, headline, programme_id, series_meta): 'was_live': True, 'series': series_meta.get('title'), 'thumbnail': url_or_none(headline.get('headline_image')) or series_meta.get('thumbnail'), + 'description': description, **traverse_obj(episode, { 'title': 'file_title', - 'description': 'file_title_sub', 'timestamp': ('open_time', {unified_timestamp}), 'release_timestamp': ('aa_vinfo4', {lambda x: x.split('_')[0]}, {unified_timestamp}), }), } + def _real_initialize(self): + if self._API_URL_TMPL: + return + api_config = self._download_xml( + 'https://www.nhk.or.jp/radio/config/config_web.xml', None, 'Downloading API config', fatal=False) + NhkRadiruIE._API_URL_TMPL = try_call(lambda: f'https:{api_config.find(".//url_program_detail").text}') + def _real_extract(self, url): site_id, corner_id, headline_id = self._match_valid_url(url).group('site', 'corner', 'headline') programme_id = f'{site_id}_{corner_id}' diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py index 797b5268a..05a1a3ddb 100644 --- a/yt_dlp/extractor/niconico.py +++ b/yt_dlp/extractor/niconico.py @@ -13,13 +13,11 @@ from ..utils import ( ExtractorError, OnDemandPagedList, - bug_reports_message, clean_html, float_or_none, int_or_none, join_nonempty, parse_duration, - parse_filesize, parse_iso8601, parse_resolution, qualities, @@ -55,25 +53,31 @@ class NiconicoIE(InfoExtractor): 'duration': 33, 'view_count': int, 'comment_count': int, + 'genres': ['未設定'], + 'tags': [], + 'expected_protocol': str, }, - 'skip': 'Requires an account', }, { # File downloaded with and without credentials are different, so omit # the md5 field 'url': 'http://www.nicovideo.jp/watch/nm14296458', 'info_dict': { 'id': 'nm14296458', - 'ext': 'swf', - 'title': '【鏡音リン】Dance on media【オリジナル】take2!', - 'description': 'md5:689f066d74610b3b22e0f1739add0f58', + 'ext': 'mp4', + 'title': '【Kagamine Rin】Dance on media【Original】take2!', + 'description': 'md5:9368f2b1f4178de64f2602c2f3d6cbf5', 'thumbnail': r're:https?://.*', 'uploader': 'りょうた', 'uploader_id': '18822557', 'upload_date': '20110429', 'timestamp': 1304065916, - 'duration': 209, + 'duration': 208.0, + 'comment_count': int, + 'view_count': int, + 'genres': ['音楽・サウンド'], + 'tags': ['Translation_Request', 'Kagamine_Rin', 'Rin_Original'], + 'expected_protocol': str, }, - 'skip': 'Requires an account', }, { # 'video exists but is marked as "deleted" # md5 is unstable @@ -107,22 +111,24 @@ class NiconicoIE(InfoExtractor): }, { # video not available via `getflv`; "old" HTML5 video 'url': 'http://www.nicovideo.jp/watch/sm1151009', - 'md5': '8fa81c364eb619d4085354eab075598a', + 'md5': 'f95a3d259172667b293530cc2e41ebda', 'info_dict': { 'id': 'sm1151009', 'ext': 'mp4', 'title': 'マスターシステム本体内蔵のスペハリのメインテーマ(PSG版)', - 'description': 'md5:6ee077e0581ff5019773e2e714cdd0b7', + 'description': 'md5:f95a3d259172667b293530cc2e41ebda', 'thumbnail': r're:https?://.*', 'duration': 184, - 'timestamp': 1190868283, - 'upload_date': '20070927', + 'timestamp': 1190835883, + 'upload_date': '20070926', 'uploader': 'denden2', 'uploader_id': '1392194', 'view_count': int, 'comment_count': int, + 'genres': ['ゲーム'], + 'tags': [], + 'expected_protocol': str, }, - 'skip': 'Requires an account', }, { # "New" HTML5 video # md5 is unstable @@ -132,16 +138,18 @@ class NiconicoIE(InfoExtractor): 'ext': 'mp4', 'title': '新作TVアニメ「戦姫絶唱シンフォギアAXZ」PV 最高画質', 'description': 'md5:e52974af9a96e739196b2c1ca72b5feb', - 'timestamp': 1498514060, + 'timestamp': 1498481660, 'upload_date': '20170626', - 'uploader': 'ゲスト', + 'uploader': 'no-namamae', 'uploader_id': '40826363', 'thumbnail': r're:https?://.*', 'duration': 198, 'view_count': int, 'comment_count': int, + 'genres': ['アニメ'], + 'tags': [], + 'expected_protocol': str, }, - 'skip': 'Requires an account', }, { # Video without owner 'url': 'http://www.nicovideo.jp/watch/sm18238488', @@ -151,7 +159,7 @@ class NiconicoIE(InfoExtractor): 'ext': 'mp4', 'title': '【実写版】ミュータントタートルズ', 'description': 'md5:15df8988e47a86f9e978af2064bf6d8e', - 'timestamp': 1341160408, + 'timestamp': 1341128008, 'upload_date': '20120701', 'uploader': None, 'uploader_id': None, @@ -159,8 +167,10 @@ class NiconicoIE(InfoExtractor): 'duration': 5271, 'view_count': int, 'comment_count': int, + 'genres': ['エンターテイメント'], + 'tags': [], + 'expected_protocol': str, }, - 'skip': 'Requires an account', }, { 'url': 'http://sp.nicovideo.jp/watch/sm28964488?ss_pos=1&cp_in=wt_tg', 'only_matching': True, @@ -172,9 +182,6 @@ class NiconicoIE(InfoExtractor): _VALID_URL = r'https?://(?:(?:www\.|secure\.|sp\.)?nicovideo\.jp/watch|nico\.ms)/(?P(?:[a-z]{2})?[0-9]+)' _NETRC_MACHINE = 'niconico' - _COMMENT_API_ENDPOINTS = ( - 'https://nvcomment.nicovideo.jp/legacy/api.json', - 'https://nmsg.nicovideo.jp/api.json',) _API_HEADERS = { 'X-Frontend-ID': '6', 'X-Frontend-Version': '0', @@ -356,15 +363,10 @@ def _extract_format_for_quality(self, video_id, audio_quality, video_quality, dm if not audio_quality.get('isAvailable') or not video_quality.get('isAvailable'): return None - def extract_video_quality(video_quality): - return parse_filesize('%sB' % self._search_regex( - r'\| ([0-9]*\.?[0-9]*[MK])', video_quality, 'vbr', default='')) - format_id = '-'.join( [remove_start(s['id'], 'archive_') for s in (video_quality, audio_quality)] + [dmc_protocol]) vid_qual_label = traverse_obj(video_quality, ('metadata', 'label')) - vid_quality = traverse_obj(video_quality, ('metadata', 'bitrate')) return { 'url': 'niconico_dmc:%s/%s/%s' % (video_id, video_quality['id'], audio_quality['id']), @@ -373,10 +375,15 @@ def extract_video_quality(video_quality): 'ext': 'mp4', # Session API are used in HTML5, which always serves mp4 'acodec': 'aac', 'vcodec': 'h264', - 'abr': float_or_none(traverse_obj(audio_quality, ('metadata', 'bitrate')), 1000), - 'vbr': float_or_none(vid_quality if vid_quality > 0 else extract_video_quality(vid_qual_label), 1000), - 'height': traverse_obj(video_quality, ('metadata', 'resolution', 'height')), - 'width': traverse_obj(video_quality, ('metadata', 'resolution', 'width')), + **traverse_obj(audio_quality, ('metadata', { + 'abr': ('bitrate', {functools.partial(float_or_none, scale=1000)}), + 'asr': ('samplingRate', {int_or_none}), + })), + **traverse_obj(video_quality, ('metadata', { + 'vbr': ('bitrate', {functools.partial(float_or_none, scale=1000)}), + 'height': ('resolution', 'height', {int_or_none}), + 'width': ('resolution', 'width', {int_or_none}), + })), 'quality': -2 if 'low' in video_quality['id'] else None, 'protocol': 'niconico_dmc', 'expected_protocol': dmc_protocol, # XXX: This is not a documented field @@ -386,6 +393,63 @@ def extract_video_quality(video_quality): } } + def _yield_dmc_formats(self, api_data, video_id): + dmc_data = traverse_obj(api_data, ('media', 'delivery', 'movie')) + audios = traverse_obj(dmc_data, ('audios', ..., {dict})) + videos = traverse_obj(dmc_data, ('videos', ..., {dict})) + protocols = traverse_obj(dmc_data, ('session', 'protocols', ..., {str})) + if not all((audios, videos, protocols)): + return + + for audio_quality, video_quality, protocol in itertools.product(audios, videos, protocols): + if fmt := self._extract_format_for_quality(video_id, audio_quality, video_quality, protocol): + yield fmt + + def _yield_dms_formats(self, api_data, video_id): + fmt_filter = lambda _, v: v['isAvailable'] and v['id'] + videos = traverse_obj(api_data, ('media', 'domand', 'videos', fmt_filter)) + audios = traverse_obj(api_data, ('media', 'domand', 'audios', fmt_filter)) + access_key = traverse_obj(api_data, ('media', 'domand', 'accessRightKey', {str})) + track_id = traverse_obj(api_data, ('client', 'watchTrackId', {str})) + if not all((videos, audios, access_key, track_id)): + return + + dms_m3u8_url = self._download_json( + f'https://nvapi.nicovideo.jp/v1/watch/{video_id}/access-rights/hls', video_id, + data=json.dumps({ + 'outputs': list(itertools.product((v['id'] for v in videos), (a['id'] for a in audios))) + }).encode(), query={'actionTrackId': track_id}, headers={ + 'x-access-right-key': access_key, + 'x-frontend-id': 6, + 'x-frontend-version': 0, + 'x-request-with': 'https://www.nicovideo.jp', + })['data']['contentUrl'] + # Getting all audio formats results in duplicate video formats which we filter out later + dms_fmts = self._extract_m3u8_formats(dms_m3u8_url, video_id) + + # m3u8 extraction does not provide audio bitrates, so extract from the API data and fix + for audio_fmt in traverse_obj(dms_fmts, lambda _, v: v['vcodec'] == 'none'): + yield { + **audio_fmt, + **traverse_obj(audios, (lambda _, v: audio_fmt['format_id'].startswith(v['id']), { + 'format_id': ('id', {str}), + 'abr': ('bitRate', {functools.partial(float_or_none, scale=1000)}), + 'asr': ('samplingRate', {int_or_none}), + }), get_all=False), + 'acodec': 'aac', + 'ext': 'm4a', + } + + # Sort before removing dupes to keep the format dicts with the lowest tbr + video_fmts = sorted((fmt for fmt in dms_fmts if fmt['vcodec'] != 'none'), key=lambda f: f['tbr']) + self._remove_duplicate_formats(video_fmts) + # Calculate the true vbr/tbr by subtracting the lowest abr + min_abr = min(traverse_obj(audios, (..., 'bitRate', {float_or_none})), default=0) / 1000 + for video_fmt in video_fmts: + video_fmt['tbr'] -= min_abr + video_fmt['format_id'] = f'video-{video_fmt["tbr"]:.0f}' + yield video_fmt + def _real_extract(self, url): video_id = self._match_id(url) @@ -412,19 +476,17 @@ def _real_extract(self, url): webpage, 'error reason', default=None) if not error_msg: raise - raise ExtractorError(re.sub(r'\s+', ' ', error_msg), expected=True) + raise ExtractorError(clean_html(error_msg), expected=True) - formats = [] - - def get_video_info(*items, get_first=True, **kwargs): - return traverse_obj(api_data, ('video', *items), get_all=not get_first, **kwargs) - - quality_info = api_data['media']['delivery']['movie'] - session_api_data = quality_info['session'] - for (audio_quality, video_quality, protocol) in itertools.product(quality_info['audios'], quality_info['videos'], session_api_data['protocols']): - fmt = self._extract_format_for_quality(video_id, audio_quality, video_quality, protocol) - if fmt: - formats.append(fmt) + club_joined = traverse_obj(api_data, ('channel', 'viewer', 'follow', 'isFollowed', {bool})) + if club_joined is None: + fail_msg = self._html_search_regex( + r']+\bclass="fail-message"[^>]*>(?P.+?)

', + webpage, 'fail message', default=None, group='msg') + if fail_msg: + self.raise_login_required(clean_html(fail_msg), metadata_available=True) + elif not club_joined: + self.raise_login_required('This video is for members only', metadata_available=True) # Start extracting information tags = None @@ -443,11 +505,15 @@ def get_video_info(*items, get_first=True, **kwargs): thumb_prefs = qualities(['url', 'middleUrl', 'largeUrl', 'player', 'ogp']) + def get_video_info(*items, get_first=True, **kwargs): + return traverse_obj(api_data, ('video', *items), get_all=not get_first, **kwargs) + return { 'id': video_id, '_api_data': api_data, 'title': get_video_info(('originalTitle', 'title')) or self._og_search_title(webpage, default=None), - 'formats': formats, + 'formats': [*self._yield_dmc_formats(api_data, video_id), + *self._yield_dms_formats(api_data, video_id)], 'thumbnails': [{ 'id': key, 'url': url, @@ -470,93 +536,19 @@ def get_video_info(*items, get_first=True, **kwargs): parse_duration(self._html_search_meta('video:duration', webpage, 'video duration', default=None)) or get_video_info('duration')), 'webpage_url': url_or_none(url) or f'https://www.nicovideo.jp/watch/{video_id}', - 'subtitles': self.extract_subtitles(video_id, api_data, session_api_data), + 'subtitles': self.extract_subtitles(video_id, api_data), } - def _get_subtitles(self, video_id, api_data, session_api_data): - comment_user_key = traverse_obj(api_data, ('comment', 'keys', 'userKey')) - user_id_str = session_api_data.get('serviceUserId') - - thread_ids = traverse_obj(api_data, ('comment', 'threads', lambda _, v: v['isActive'])) - legacy_danmaku = self._extract_legacy_comments(video_id, thread_ids, user_id_str, comment_user_key) or [] - - new_comments = traverse_obj(api_data, ('comment', 'nvComment')) - new_danmaku = self._extract_new_comments( - new_comments.get('server'), video_id, - new_comments.get('params'), new_comments.get('threadKey')) - - if not legacy_danmaku and not new_danmaku: - self.report_warning(f'Failed to get comments. {bug_reports_message()}') + def _get_subtitles(self, video_id, api_data): + comments_info = traverse_obj(api_data, ('comment', 'nvComment', {dict})) or {} + if not comments_info.get('server'): return - return { - 'comments': [{ - 'ext': 'json', - 'data': json.dumps(legacy_danmaku + new_danmaku), - }], - } - - def _extract_legacy_comments(self, video_id, threads, user_id, user_key): - auth_data = { - 'user_id': user_id, - 'userkey': user_key, - } if user_id and user_key else {'user_id': ''} - - api_url = traverse_obj(threads, (..., 'server'), get_all=False) - - # Request Start - post_data = [{'ping': {'content': 'rs:0'}}] - for i, thread in enumerate(threads): - thread_id = thread['id'] - thread_fork = thread['fork'] - # Post Start (2N) - post_data.append({'ping': {'content': f'ps:{i * 2}'}}) - post_data.append({'thread': { - 'fork': thread_fork, - 'language': 0, - 'nicoru': 3, - 'scores': 1, - 'thread': thread_id, - 'version': '20090904', - 'with_global': 1, - **auth_data, - }}) - # Post Final (2N) - post_data.append({'ping': {'content': f'pf:{i * 2}'}}) - - # Post Start (2N+1) - post_data.append({'ping': {'content': f'ps:{i * 2 + 1}'}}) - post_data.append({'thread_leaves': { - # format is '-:,tv(?:noviny)?|tn|novaplus|vymena|fanda|krasna|doma|prask)\.nova\.cz/(?:[^/]+/)+(?P[^/]+?)(?:\.html|/|$)' _TESTS = [{ 'url': 'http://tn.nova.cz/clanek/tajemstvi-ukryte-v-podzemi-specialni-nemocnice-v-prazske-krci.html#player_13260', - 'md5': '249baab7d0104e186e78b0899c7d5f28', + 'md5': 'da8f3f1fcdaf9fb0f112a32a165760a3', 'info_dict': { - 'id': '1757139', - 'display_id': 'tajemstvi-ukryte-v-podzemi-specialni-nemocnice-v-prazske-krci', + 'id': '8OvQqEvV3MW', + 'display_id': '8OvQqEvV3MW', 'ext': 'mp4', 'title': 'Podzemní nemocnice v pražské Krči', 'description': 'md5:f0a42dd239c26f61c28f19e62d20ef53', 'thumbnail': r're:^https?://.*\.(?:jpg)', + 'duration': 151, } }, { 'url': 'http://fanda.nova.cz/clanek/fun-and-games/krvavy-epos-zaklinac-3-divoky-hon-vychazi-vyhrajte-ho-pro-sebe.html', @@ -210,7 +211,7 @@ def _real_extract(self, url): # novaplus embed_id = self._search_regex( - r']+\bsrc=["\'](?:https?:)?//media\.cms\.nova\.cz/embed/([^/?#&]+)', + r']+\bsrc=["\'](?:https?:)?//media(?:tn)?\.cms\.nova\.cz/embed/([^/?#&"\']+)', webpage, 'embed url', default=None) if embed_id: return { diff --git a/yt_dlp/extractor/ntvru.py b/yt_dlp/extractor/ntvru.py index 91b7724eb..fe3965729 100644 --- a/yt_dlp/extractor/ntvru.py +++ b/yt_dlp/extractor/ntvru.py @@ -35,6 +35,7 @@ class NTVRuIE(InfoExtractor): 'duration': 172, 'view_count': int, }, + 'skip': '404 Not Found', }, { 'url': 'http://www.ntv.ru/peredacha/segodnya/m23700/o232416', 'md5': '82dbd49b38e3af1d00df16acbeab260c', @@ -78,7 +79,8 @@ class NTVRuIE(InfoExtractor): }] _VIDEO_ID_REGEXES = [ - r'Sign\s+[Oo]ut\s*<')) + r'id="profileMenuDropdown"', + r'class="ph-icon-logout"')) if is_logged(login_page): self._logged_in = True diff --git a/yt_dlp/extractor/rai.py b/yt_dlp/extractor/rai.py index f6219c2db..c1fc65c81 100644 --- a/yt_dlp/extractor/rai.py +++ b/yt_dlp/extractor/rai.py @@ -28,6 +28,29 @@ class RaiBaseIE(InfoExtractor): _GEO_COUNTRIES = ['IT'] _GEO_BYPASS = False + def _fix_m3u8_formats(self, media_url, video_id): + fmts = self._extract_m3u8_formats( + media_url, video_id, 'mp4', m3u8_id='hls', fatal=False) + + # Fix malformed m3u8 manifests by setting audio-only/video-only formats + for f in fmts: + if not f.get('acodec'): + f['acodec'] = 'mp4a' + if not f.get('vcodec'): + f['vcodec'] = 'avc1' + man_url = f['url'] + if re.search(r'chunklist(?:_b\d+)*_ao[_.]', man_url): # audio only + f['vcodec'] = 'none' + elif re.search(r'chunklist(?:_b\d+)*_vo[_.]', man_url): # video only + f['acodec'] = 'none' + else: # video+audio + if f['acodec'] == 'none': + f['acodec'] = 'mp4a' + if f['vcodec'] == 'none': + f['vcodec'] = 'avc1' + + return fmts + def _extract_relinker_info(self, relinker_url, video_id, audio_only=False): def fix_cdata(s): # remove \r\n\t before and after to avoid @@ -69,8 +92,7 @@ def fix_cdata(s): 'format_id': 'https-mp3', }) elif ext == 'm3u8' or 'format=m3u8' in media_url: - formats.extend(self._extract_m3u8_formats( - media_url, video_id, 'mp4', m3u8_id='hls', fatal=False)) + formats.extend(self._fix_m3u8_formats(media_url, video_id)) elif ext == 'f4m': # very likely no longer needed. Cannot find any url that uses it. manifest_url = update_url_query( @@ -153,10 +175,10 @@ def get_format_info(tbr): 'format_id': f'https-{tbr}', 'width': format_copy.get('width'), 'height': format_copy.get('height'), - 'tbr': format_copy.get('tbr'), - 'vcodec': format_copy.get('vcodec'), - 'acodec': format_copy.get('acodec'), - 'fps': format_copy.get('fps'), + 'tbr': format_copy.get('tbr') or tbr, + 'vcodec': format_copy.get('vcodec') or 'avc1', + 'acodec': format_copy.get('acodec') or 'mp4a', + 'fps': format_copy.get('fps') or 25, } if format_copy else { 'format_id': f'https-{tbr}', 'width': _QUALITY[tbr][0], @@ -245,7 +267,7 @@ class RaiPlayIE(RaiBaseIE): 'series': 'Report', 'season': '2013/14', 'subtitles': {'it': 'count:4'}, - 'release_year': 2022, + 'release_year': 2024, 'episode': 'Espresso nel caffè - 07/04/2014', 'timestamp': 1396919880, 'upload_date': '20140408', @@ -253,7 +275,7 @@ class RaiPlayIE(RaiBaseIE): }, 'params': {'skip_download': True}, }, { - # 1080p direct mp4 url + # 1080p 'url': 'https://www.raiplay.it/video/2021/11/Blanca-S1E1-Senza-occhi-b1255a4a-8e72-4a2f-b9f3-fc1308e00736.html', 'md5': 'aeda7243115380b2dd5e881fd42d949a', 'info_dict': { @@ -274,7 +296,7 @@ class RaiPlayIE(RaiBaseIE): 'episode': 'Senza occhi', 'timestamp': 1637318940, 'upload_date': '20211119', - 'formats': 'count:12', + 'formats': 'count:7', }, 'params': {'skip_download': True}, 'expected_warnings': ['Video not available. Likely due to geo-restriction.'] @@ -527,7 +549,7 @@ class RaiPlaySoundPlaylistIE(InfoExtractor): 'info_dict': { 'id': 'ilruggitodelconiglio', 'title': 'Il Ruggito del Coniglio', - 'description': 'md5:48cff6972435964284614d70474132e6', + 'description': 'md5:62a627b3a2d0635d08fa8b6e0a04f27e', }, 'playlist_mincount': 65, }, { @@ -634,19 +656,20 @@ def _real_extract(self, url): } -class RaiNewsIE(RaiIE): # XXX: Do not subclass from concrete IE +class RaiNewsIE(RaiBaseIE): _VALID_URL = rf'https?://(www\.)?rainews\.it/(?!articoli)[^?#]+-(?P{RaiBaseIE._UUID_RE})(?:-[^/?#]+)?\.html' _EMBED_REGEX = [rf']+data-src="(?P/iframe/[^?#]+?{RaiBaseIE._UUID_RE}\.html)'] _TESTS = [{ # new rainews player (#3911) - 'url': 'https://www.rainews.it/rubriche/24mm/video/2022/05/24mm-del-29052022-12cf645d-1ffd-4220-b27c-07c226dbdecf.html', + 'url': 'https://www.rainews.it/video/2024/02/membri-della-croce-rossa-evacuano-gli-abitanti-di-un-villaggio-nella-regione-ucraina-di-kharkiv-il-filmato-dallucraina--31e8017c-845c-43f5-9c48-245b43c3a079.html', 'info_dict': { - 'id': '12cf645d-1ffd-4220-b27c-07c226dbdecf', + 'id': '31e8017c-845c-43f5-9c48-245b43c3a079', 'ext': 'mp4', - 'title': 'Puntata del 29/05/2022', - 'duration': 1589, - 'upload_date': '20220529', + 'title': 'md5:1e81364b09de4a149042bac3c7d36f0b', + 'duration': 196, + 'upload_date': '20240225', 'uploader': 'rainews', + 'formats': 'count:2', }, 'params': {'skip_download': True}, }, { @@ -659,7 +682,8 @@ class RaiNewsIE(RaiIE): # XXX: Do not subclass from concrete IE 'description': 'I film in uscita questa settimana.', 'thumbnail': r're:^https?://.*\.png$', 'duration': 833, - 'upload_date': '20161103' + 'upload_date': '20161103', + 'formats': 'count:8', }, 'params': {'skip_download': True}, 'expected_warnings': ['unable to extract player_data'], @@ -684,7 +708,7 @@ def _real_extract(self, url): if not relinker_url: # fallback on old implementation for some old content try: - return self._extract_from_content_id(video_id, url) + return RaiIE._real_extract(self, url) except GeoRestrictedError: raise except ExtractorError as e: diff --git a/yt_dlp/extractor/rozhlas.py b/yt_dlp/extractor/rozhlas.py index 63134322d..411a62519 100644 --- a/yt_dlp/extractor/rozhlas.py +++ b/yt_dlp/extractor/rozhlas.py @@ -247,17 +247,17 @@ class MujRozhlasIE(RozhlasBaseIE): 'url': 'https://www.mujrozhlas.cz/vykopavky/ach-jo-zase-teleci-rizek-je-mnohem-min-cesky-nez-jsme-si-mysleli', 'md5': '6f8fd68663e64936623e67c152a669e0', 'info_dict': { - 'id': '10739193', + 'id': '10787730', 'ext': 'mp3', 'title': 'Ach jo, zase to telecí! Řízek je mnohem míň český, než jsme si mysleli', 'description': 'md5:db7141e9caaedc9041ec7cefb9a62908', 'timestamp': 1684915200, - 'modified_timestamp': 1684922446, + 'modified_timestamp': 1687550432, 'series': 'Vykopávky', 'thumbnail': 'https://portal.rozhlas.cz/sites/default/files/images/84377046610af6ddc54d910b1dd7a22b.jpg', 'channel_id': 'radio-wave', 'upload_date': '20230524', - 'modified_date': '20230524', + 'modified_date': '20230623', }, }, { # serial extraction @@ -277,6 +277,26 @@ class MujRozhlasIE(RozhlasBaseIE): 'title': 'Nespavci', 'description': 'md5:c430adcbf9e2b9eac88b745881e814dc', }, + }, { + # serialPart + 'url': 'https://www.mujrozhlas.cz/povidka/gustavo-adolfo-becquer-hora-duchu', + 'info_dict': { + 'id': '8889035', + 'ext': 'm4a', + 'title': 'Gustavo Adolfo Bécquer: Hora duchů', + 'description': 'md5:343a15257b376c276e210b78e900ffea', + 'chapter': 'Hora duchů a Polibek – dva tajemné příběhy Gustava Adolfa Bécquera', + 'thumbnail': 'https://portal.rozhlas.cz/sites/default/files/images/2adfe1387fb140634be725c1ccf26214.jpg', + 'timestamp': 1708173000, + 'episode': 'Episode 1', + 'episode_number': 1, + 'series': 'Povídka', + 'modified_date': '20240217', + 'upload_date': '20240217', + 'modified_timestamp': 1708173198, + 'channel_id': 'vltava', + }, + 'params': {'skip_download': 'dash'}, }] def _call_api(self, path, item_id, msg='API JSON'): @@ -322,7 +342,7 @@ def _real_extract(self, url): entity = info['siteEntityBundle'] - if entity == 'episode': + if entity in ('episode', 'serialPart'): return self._extract_audio_entry(self._call_api( 'episodes', info['contentId'], 'episode info API JSON')) diff --git a/yt_dlp/extractor/rule34video.py b/yt_dlp/extractor/rule34video.py index 85ad7e2ff..11095b262 100644 --- a/yt_dlp/extractor/rule34video.py +++ b/yt_dlp/extractor/rule34video.py @@ -9,7 +9,6 @@ get_element_html_by_class, get_elements_by_class, int_or_none, - join_nonempty, parse_count, parse_duration, unescapeHTML, @@ -57,7 +56,7 @@ class Rule34VideoIE(InfoExtractor): 'comment_count': int, 'timestamp': 1640131200, 'description': '', - 'creator': 'WildeerStudio', + 'creators': ['WildeerStudio'], 'upload_date': '20211222', 'uploader': 'CerZule', 'uploader_url': 'https://rule34video.com/members/36281/', @@ -81,13 +80,13 @@ def _real_extract(self, url): 'quality': quality, }) - categories, creator, uploader, uploader_url = [None] * 4 + categories, creators, uploader, uploader_url = [None] * 4 for col in get_elements_by_class('col', webpage): label = clean_html(get_element_by_class('label', col)) if label == 'Categories:': categories = list(map(clean_html, get_elements_by_class('item', col))) elif label == 'Artist:': - creator = join_nonempty(*map(clean_html, get_elements_by_class('item', col)), delim=', ') + creators = list(map(clean_html, get_elements_by_class('item', col))) elif label == 'Uploaded By:': uploader = clean_html(get_element_by_class('name', col)) uploader_url = extract_attributes(get_element_html_by_class('name', col) or '').get('href') @@ -115,7 +114,7 @@ def _real_extract(self, url): 'comment_count': int_or_none(self._search_regex( r'[^(]+\((\d+)\)', get_element_by_attribute('href', '#tab_comments', webpage), 'comment count', fatal=False)), 'age_limit': 18, - 'creator': creator, + 'creators': creators, 'uploader': uploader, 'uploader_url': uploader_url, 'categories': categories, diff --git a/yt_dlp/extractor/screencastify.py b/yt_dlp/extractor/screencastify.py index 136b8479b..3c43043de 100644 --- a/yt_dlp/extractor/screencastify.py +++ b/yt_dlp/extractor/screencastify.py @@ -5,7 +5,10 @@ class ScreencastifyIE(InfoExtractor): - _VALID_URL = r'https?://watch\.screencastify\.com/v/(?P[^/?#]+)' + _VALID_URL = [ + r'https?://watch\.screencastify\.com/v/(?P[^/?#]+)', + r'https?://app\.screencastify\.com/v[23]/watch/(?P[^/?#]+)', + ] _TESTS = [{ 'url': 'https://watch.screencastify.com/v/sYVkZip3quLKhHw4Ybk8', 'info_dict': { @@ -19,6 +22,21 @@ class ScreencastifyIE(InfoExtractor): 'params': { 'skip_download': 'm3u8', }, + }, { + 'url': 'https://app.screencastify.com/v3/watch/J5N7H11wofDN1jZUCr3t', + 'info_dict': { + 'id': 'J5N7H11wofDN1jZUCr3t', + 'ext': 'mp4', + 'uploader': 'Scott Piesen', + 'description': '', + 'title': 'Lesson Recording 1-17 Burrr...', + }, + 'params': { + 'skip_download': 'm3u8', + }, + }, { + 'url': 'https://app.screencastify.com/v2/watch/BQ26VbUdfbQLhKzkktOk', + 'only_matching': True, }] def _real_extract(self, url): diff --git a/yt_dlp/extractor/swearnet.py b/yt_dlp/extractor/swearnet.py index 6e216a2a5..aeaff28f2 100644 --- a/yt_dlp/extractor/swearnet.py +++ b/yt_dlp/extractor/swearnet.py @@ -1,5 +1,5 @@ from .common import InfoExtractor -from ..utils import int_or_none, traverse_obj +from ..utils import ExtractorError, int_or_none, traverse_obj class SwearnetEpisodeIE(InfoExtractor): @@ -51,7 +51,13 @@ def _real_extract(self, url): display_id, season_number, episode_number = self._match_valid_url(url).group('id', 'season_num', 'episode_num') webpage = self._download_webpage(url, display_id) - external_id = self._search_regex(r'externalid\s*=\s*"([^"]+)', webpage, 'externalid') + try: + external_id = self._search_regex(r'externalid\s*=\s*"([^"]+)', webpage, 'externalid') + except ExtractorError: + if 'Upgrade Now' in webpage: + self.raise_login_required() + raise + json_data = self._download_json( f'https://play.vidyard.com/player/{external_id}.json', display_id)['payload']['chapters'][0] diff --git a/yt_dlp/extractor/tiktok.py b/yt_dlp/extractor/tiktok.py index f26972cff..39a421922 100644 --- a/yt_dlp/extractor/tiktok.py +++ b/yt_dlp/extractor/tiktok.py @@ -6,7 +6,7 @@ import time from .common import InfoExtractor -from ..compat import compat_urllib_parse_unquote, compat_urllib_parse_urlparse +from ..compat import compat_urllib_parse_urlparse from ..networking import HEADRequest from ..utils import ( ExtractorError, @@ -15,7 +15,6 @@ UserNotLive, determine_ext, format_field, - get_first, int_or_none, join_nonempty, merge_dicts, @@ -219,8 +218,8 @@ def audio_meta(url): def extract_addr(addr, add_meta={}): parsed_meta, res = parse_url_key(addr.get('url_key', '')) if res: - known_resolutions.setdefault(res, {}).setdefault('height', add_meta.get('height') or addr.get('height')) - known_resolutions[res].setdefault('width', add_meta.get('width') or addr.get('width')) + known_resolutions.setdefault(res, {}).setdefault('height', int_or_none(addr.get('height'))) + known_resolutions[res].setdefault('width', int_or_none(addr.get('width'))) parsed_meta.update(known_resolutions.get(res, {})) add_meta.setdefault('height', int_or_none(res[:-1])) return [{ @@ -237,22 +236,26 @@ def extract_addr(addr, add_meta={}): # Hack: Add direct video links first to prioritize them when removing duplicate formats formats = [] + width = int_or_none(video_info.get('width')) + height = int_or_none(video_info.get('height')) if video_info.get('play_addr'): formats.extend(extract_addr(video_info['play_addr'], { 'format_id': 'play_addr', 'format_note': 'Direct video', 'vcodec': 'h265' if traverse_obj( video_info, 'is_bytevc1', 'is_h265') else 'h264', # TODO: Check for "direct iOS" videos, like https://www.tiktok.com/@cookierun_dev/video/7039716639834656002 - 'width': video_info.get('width'), - 'height': video_info.get('height'), + 'width': width, + 'height': height, })) if video_info.get('download_addr'): - formats.extend(extract_addr(video_info['download_addr'], { + download_addr = video_info['download_addr'] + dl_width = int_or_none(download_addr.get('width')) + formats.extend(extract_addr(download_addr, { 'format_id': 'download_addr', 'format_note': 'Download video%s' % (', watermarked' if video_info.get('has_watermark') else ''), 'vcodec': 'h264', - 'width': video_info.get('width'), - 'height': video_info.get('height'), + 'width': dl_width or width, + 'height': try_call(lambda: int(dl_width / 0.5625)) or height, # download_addr['height'] is wrong 'preference': -2 if video_info.get('has_watermark') else -1, })) if video_info.get('play_addr_h264'): @@ -315,9 +318,6 @@ def extract_addr(addr, add_meta={}): return { 'id': aweme_id, - 'extractor_key': TikTokIE.ie_key(), - 'extractor': TikTokIE.IE_NAME, - 'webpage_url': self._create_url(author_info.get('uid'), aweme_id), **traverse_obj(aweme_detail, { 'title': ('desc', {str}), 'description': ('desc', {str}), @@ -921,20 +921,23 @@ class DouyinIE(TikTokBaseIE): _VALID_URL = r'https?://(?:www\.)?douyin\.com/video/(?P[0-9]+)' _TESTS = [{ 'url': 'https://www.douyin.com/video/6961737553342991651', - 'md5': 'a97db7e3e67eb57bf40735c022ffa228', + 'md5': '9ecce7bc5b302601018ecb2871c63a75', 'info_dict': { 'id': '6961737553342991651', 'ext': 'mp4', 'title': '#杨超越 小小水手带你去远航❤️', 'description': '#杨超越 小小水手带你去远航❤️', + 'uploader': '6897520xka', 'uploader_id': '110403406559', 'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98', 'channel_id': 'MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98', 'creator': '杨超越', - 'duration': 19782, + 'creators': ['杨超越'], + 'duration': 19, 'timestamp': 1620905839, 'upload_date': '20210513', 'track': '@杨超越创作的原声', + 'artists': ['杨超越'], 'view_count': int, 'like_count': int, 'repost_count': int, @@ -943,20 +946,23 @@ class DouyinIE(TikTokBaseIE): }, }, { 'url': 'https://www.douyin.com/video/6982497745948921092', - 'md5': '34a87ebff3833357733da3fe17e37c0e', + 'md5': '15c5e660b7048af3707304e3cc02bbb5', 'info_dict': { 'id': '6982497745948921092', 'ext': 'mp4', 'title': '这个夏日和小羊@杨超越 一起遇见白色幻想', 'description': '这个夏日和小羊@杨超越 一起遇见白色幻想', + 'uploader': '0731chaoyue', 'uploader_id': '408654318141572', 'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAZJpnglcjW2f_CMVcnqA_6oVBXKWMpH0F8LIHuUu8-lA', 'channel_id': 'MS4wLjABAAAAZJpnglcjW2f_CMVcnqA_6oVBXKWMpH0F8LIHuUu8-lA', 'creator': '杨超越工作室', - 'duration': 42479, + 'creators': ['杨超越工作室'], + 'duration': 42, 'timestamp': 1625739481, 'upload_date': '20210708', 'track': '@杨超越工作室创作的原声', + 'artists': ['杨超越工作室'], 'view_count': int, 'like_count': int, 'repost_count': int, @@ -965,20 +971,23 @@ class DouyinIE(TikTokBaseIE): }, }, { 'url': 'https://www.douyin.com/video/6953975910773099811', - 'md5': 'dde3302460f19db59c47060ff013b902', + 'md5': '0e6443758b8355db9a3c34864a4276be', 'info_dict': { 'id': '6953975910773099811', 'ext': 'mp4', 'title': '#一起看海 出现在你的夏日里', 'description': '#一起看海 出现在你的夏日里', + 'uploader': '6897520xka', 'uploader_id': '110403406559', 'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98', 'channel_id': 'MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98', 'creator': '杨超越', - 'duration': 17343, + 'creators': ['杨超越'], + 'duration': 17, 'timestamp': 1619098692, 'upload_date': '20210422', 'track': '@杨超越创作的原声', + 'artists': ['杨超越'], 'view_count': int, 'like_count': int, 'repost_count': int, @@ -1004,20 +1013,23 @@ class DouyinIE(TikTokBaseIE): 'skip': 'No longer available', }, { 'url': 'https://www.douyin.com/video/6963263655114722595', - 'md5': 'cf9f11f0ec45d131445ec2f06766e122', + 'md5': '1440bcf59d8700f8e014da073a4dfea8', 'info_dict': { 'id': '6963263655114722595', 'ext': 'mp4', 'title': '#哪个爱豆的105度最甜 换个角度看看我哈哈', 'description': '#哪个爱豆的105度最甜 换个角度看看我哈哈', + 'uploader': '6897520xka', 'uploader_id': '110403406559', 'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98', 'channel_id': 'MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98', 'creator': '杨超越', - 'duration': 15115, + 'creators': ['杨超越'], + 'duration': 15, 'timestamp': 1621261163, 'upload_date': '20210517', 'track': '@杨超越创作的原声', + 'artists': ['杨超越'], 'view_count': int, 'like_count': int, 'repost_count': int, @@ -1025,34 +1037,23 @@ class DouyinIE(TikTokBaseIE): 'thumbnail': r're:https?://.+\.jpe?g', }, }] - _APP_VERSIONS = [('23.3.0', '230300')] - _APP_NAME = 'aweme' - _AID = 1128 - _API_HOSTNAME = 'aweme.snssdk.com' _UPLOADER_URL_FORMAT = 'https://www.douyin.com/user/%s' _WEBPAGE_HOST = 'https://www.douyin.com/' def _real_extract(self, url): video_id = self._match_id(url) - try: - return self._extract_aweme_app(video_id) - except ExtractorError as e: - e.expected = True - self.to_screen(f'{e}; trying with webpage') - - webpage = self._download_webpage(url, video_id) - render_data = self._search_json( - r'