From 16974726a4716da850036b9ae12104534050a8a8 Mon Sep 17 00:00:00 2001 From: Mozi <29089388+pzhlkj6612@users.noreply.github.com> Date: Sun, 10 Mar 2024 02:17:57 +0000 Subject: [PATCH 01/16] [ie/niconico] Directly download live timeshift videos; WebSocket fixes Major changes: - Make a downloader for live timeshift videos. Time-based download rate limit applies. RetryManager-based error recovery applies. - Fix the incorrect url for WebSocket reconnection. - Correctly close the WebSocket connection. - [!] Apply "FFmpegFixupM3u8PP" for both non-timeshift and timeshift MPEG-TS files by adding "m3u8_*" prefixes and inheriting from "HlsFD". - [!] Change the protocol from "hls+fmp4" to "hls" in "startWatching" WebSocket requests because I didn't see it in my test. Minor changes: - Support metadata extraction when no formats. - Set "live_status" instead of "is_live". - Clean up "info_dict": Change WebSocket configs to private to hide them from users; extract common fields and remove unused ones. - Update a download test. --- yt_dlp/downloader/__init__.py | 5 +- yt_dlp/downloader/niconico.py | 202 +++++++++++++++++++++++++++------- yt_dlp/extractor/niconico.py | 101 ++++++++++------- 3 files changed, 229 insertions(+), 79 deletions(-) diff --git a/yt_dlp/downloader/__init__.py b/yt_dlp/downloader/__init__.py index 51a9f28f06..444c348576 100644 --- a/yt_dlp/downloader/__init__.py +++ b/yt_dlp/downloader/__init__.py @@ -30,7 +30,7 @@ from .hls import HlsFD from .http import HttpFD from .ism import IsmFD from .mhtml import MhtmlFD -from .niconico import NiconicoDmcFD, NiconicoLiveFD +from .niconico import NiconicoDmcFD, NiconicoLiveFD, NiconicoLiveTimeshiftFD from .rtmp import RtmpFD from .rtsp import RtspFD from .websocket import WebSocketFragmentFD @@ -50,7 +50,8 @@ PROTOCOL_MAP = { 'ism': IsmFD, 'mhtml': MhtmlFD, 'niconico_dmc': NiconicoDmcFD, - 'niconico_live': NiconicoLiveFD, + 'm3u8_niconico_live': NiconicoLiveFD, + 'm3u8_niconico_live_timeshift': NiconicoLiveTimeshiftFD, 'fc2_live': FC2LiveFD, 'websocket_frag': WebSocketFragmentFD, 'youtube_live_chat': YoutubeLiveChatFD, diff --git a/yt_dlp/downloader/niconico.py b/yt_dlp/downloader/niconico.py index fef8bff73a..4c51bb166d 100644 --- a/yt_dlp/downloader/niconico.py +++ b/yt_dlp/downloader/niconico.py @@ -1,12 +1,23 @@ +import contextlib import json +import math import threading import time from . import get_suitable_downloader from .common import FileDownloader from .external import FFmpegFD +from ..downloader.hls import HlsFD from ..networking import Request -from ..utils import DownloadError, str_or_none, try_get +from ..networking.exceptions import RequestError +from ..utils import ( + DownloadError, + RetryManager, + str_or_none, + traverse_obj, + try_get, + urljoin, +) class NiconicoDmcFD(FileDownloader): @@ -56,34 +67,33 @@ class NiconicoDmcFD(FileDownloader): return success -class NiconicoLiveFD(FileDownloader): - """ Downloads niconico live without being stopped """ +class NiconicoLiveBaseFD(FileDownloader): + _WEBSOCKET_RECONNECT_DELAY = 10 - def real_download(self, filename, info_dict): - video_id = info_dict['video_id'] - ws_url = info_dict['url'] - ws_extractor = info_dict['ws'] - ws_origin_host = info_dict['origin'] - live_quality = info_dict.get('live_quality', 'high') - live_latency = info_dict.get('live_latency', 'high') - dl = FFmpegFD(self.ydl, self.params or {}) + @contextlib.contextmanager + def _ws_context(self, info_dict): + """ Hold a WebSocket object and release it when leaving """ - new_info_dict = info_dict.copy() - new_info_dict.update({ - 'protocol': 'm3u8', - }) + video_id = info_dict['id'] + live_latency = info_dict['live_latency'] + self.ws = info_dict['__ws'] + + self.m3u8_lock = threading.Event() + self.m3u8_url = info_dict['manifest_url'] + self.m3u8_lock.set() def communicate_ws(reconnect): if reconnect: - ws = self.ydl.urlopen(Request(ws_url, headers={'Origin': f'https://{ws_origin_host}'})) + self.ws = self.ydl.urlopen(Request( + self.ws.url, headers={'Origin': self.ws.wsw.request.headers['Origin']})) if self.ydl.params.get('verbose', False): self.to_screen('[debug] Sending startWatching request') - ws.send(json.dumps({ + self.ws.send(json.dumps({ 'type': 'startWatching', 'data': { 'stream': { - 'quality': live_quality, - 'protocol': 'hls+fmp4', + 'quality': 'abr', + 'protocol': 'hls', 'latency': live_latency, 'chasePlay': False }, @@ -94,11 +104,9 @@ class NiconicoLiveFD(FileDownloader): 'reconnect': True, } })) - else: - ws = ws_extractor - with ws: + with self.ws: while True: - recv = ws.recv() + recv = self.ws.recv() if not recv: continue data = json.loads(recv) @@ -106,35 +114,155 @@ class NiconicoLiveFD(FileDownloader): continue if data.get('type') == 'ping': # pong back - ws.send(r'{"type":"pong"}') - ws.send(r'{"type":"keepSeat"}') + self.ws.send(r'{"type":"pong"}') + self.ws.send(r'{"type":"keepSeat"}') + elif data.get('type') == 'stream': + self.m3u8_url = data['data']['uri'] + self.m3u8_lock.set() elif data.get('type') == 'disconnect': self.write_debug(data) - return True + return elif data.get('type') == 'error': self.write_debug(data) message = try_get(data, lambda x: x['body']['code'], str) or recv - return DownloadError(message) + raise DownloadError(message) elif self.ydl.params.get('verbose', False): if len(recv) > 100: recv = recv[:100] + '...' self.to_screen('[debug] Server said: %s' % recv) + stopped = threading.Event() + def ws_main(): reconnect = False - while True: + while not stopped.is_set(): try: - ret = communicate_ws(reconnect) - if ret is True: - return - except BaseException as e: - self.to_screen('[%s] %s: Connection error occured, reconnecting after 10 seconds: %s' % ('niconico:live', video_id, str_or_none(e))) - time.sleep(10) - continue - finally: + communicate_ws(reconnect) + break # Disconnected + except BaseException as e: # Including TransportError + if stopped.is_set(): + break + + self.m3u8_lock.clear() # m3u8 url may be changed + + self.to_screen('[%s] %s: Connection error occured, reconnecting after %d seconds: %s' % ('niconico:live', video_id, self._WEBSOCKET_RECONNECT_DELAY, str_or_none(e))) + time.sleep(self._WEBSOCKET_RECONNECT_DELAY) + reconnect = True + self.m3u8_lock.set() # Release possible locks + thread = threading.Thread(target=ws_main, daemon=True) thread.start() - return dl.download(filename, new_info_dict) + try: + yield self + finally: + stopped.set() + self.ws.close() + thread.join() + + def _master_m3u8_url(self): + """ Get the refreshed manifest url after WebSocket reconnection to prevent HTTP 403 """ + + self.m3u8_lock.wait() + return self.m3u8_url + + +class NiconicoLiveFD(NiconicoLiveBaseFD): + """ Downloads niconico live without being stopped """ + + def real_download(self, filename, info_dict): + with self._ws_context(info_dict): + new_info_dict = info_dict.copy() + new_info_dict.update({ + 'protocol': 'm3u8', + }) + + return FFmpegFD(self.ydl, self.params or {}).download(filename, new_info_dict) + + +class NiconicoLiveTimeshiftFD(NiconicoLiveBaseFD, HlsFD): + """ Downloads niconico live timeshift VOD """ + + _PER_FRAGMENT_DOWNLOAD_RATIO = 0.1 + + def real_download(self, filename, info_dict): + with self._ws_context(info_dict) as ws_context: + from ..extractor.niconico import NiconicoIE + ie = NiconicoIE(self.ydl) + + video_id = info_dict['id'] + + # Get format index + for format_index, fmt in enumerate(info_dict['formats']): + if fmt['format_id'] == info_dict['format_id']: + break + + # Get video info + total_duration = 0 + fragment_duration = 0 + for line in ie._download_webpage(info_dict['url'], video_id, note='Downloading m3u8').splitlines(): + if '#STREAM-DURATION' in line: + total_duration = int(float(line.split(':')[1])) + if '#EXT-X-TARGETDURATION' in line: + fragment_duration = int(line.split(':')[1]) + if not all({total_duration, fragment_duration}): + raise DownloadError('Unable to get required video info') + + ctx = { + 'filename': filename, + 'total_frags': math.ceil(total_duration / fragment_duration), + } + + self._prepare_and_start_frag_download(ctx, info_dict) + + downloaded_duration = ctx['fragment_index'] * fragment_duration + while True: + if downloaded_duration > total_duration: + break + + retry_manager = RetryManager(self.params.get('fragment_retries'), self.report_retry) + for retry in retry_manager: + try: + # Refresh master m3u8 (if possible) and get the url of the previously-chose format + master_m3u8_url = ws_context._master_m3u8_url() + formats = ie._extract_m3u8_formats( + master_m3u8_url, video_id, query={"start": downloaded_duration}, live=False, note=False, fatal=False) + media_m3u8_url = traverse_obj(formats, (format_index, {dict}, 'url'), get_all=False) + if not media_m3u8_url: + raise DownloadError('Unable to get playlist') + + # Get all fragments + media_m3u8 = ie._download_webpage(media_m3u8_url, video_id, note=False) + fragment_urls = traverse_obj(media_m3u8.splitlines(), ( + lambda _, v: not v.startswith('#'), {lambda url: urljoin(media_m3u8_url, url)})) + + with self.DurationLimiter(len(fragment_urls) * fragment_duration * self._PER_FRAGMENT_DOWNLOAD_RATIO): + for fragment_url in fragment_urls: + success = self._download_fragment(ctx, fragment_url, info_dict) + if not success: + return False + self._append_fragment(ctx, self._read_fragment(ctx)) + downloaded_duration += fragment_duration + + except (DownloadError, RequestError) as err: # Including HTTPError and TransportError + retry.error = err + continue + + if retry_manager.error: + return False + + return self._finish_frag_download(ctx, info_dict) + + class DurationLimiter(): + def __init__(self, target): + self.target = target + + def __enter__(self): + self.start = time.time() + + def __exit__(self, *exc): + remaining = self.target - (time.time() - self.start) + if remaining > 0: + time.sleep(remaining) diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py index 6a46246026..5fa84a34be 100644 --- a/yt_dlp/extractor/niconico.py +++ b/yt_dlp/extractor/niconico.py @@ -919,17 +919,30 @@ class NiconicoLiveIE(InfoExtractor): 'info_dict': { 'id': 'lv339533123', 'title': '激辛ペヤング食べます‪( ;ᯅ; )‬(歌枠オーディション参加中)', - 'view_count': 1526, - 'comment_count': 1772, + 'view_count': int, + 'comment_count': int, 'description': '初めましてもかって言います❕\nのんびり自由に適当に暮らしてます', 'uploader': 'もか', 'channel': 'ゲストさんのコミュニティ', 'channel_id': 'co5776900', 'channel_url': 'https://com.nicovideo.jp/community/co5776900', 'timestamp': 1670677328, - 'is_live': True, + 'ext': None, + 'live_latency': 'high', + 'live_status': 'was_live', + 'thumbnail': r're:^https://[\w.-]+/\w+/\w+', + 'thumbnails': list, + 'upload_date': '20221210', }, - 'skip': 'livestream', + 'params': { + 'skip_download': True, + 'ignore_no_formats_error': True, + }, + 'expected_warnings': [ + 'The live hasn\'t started yet or already ended.', + 'No video formats found!', + 'Requested format is not available', + ], }, { 'url': 'https://live2.nicovideo.jp/watch/lv339533123', 'only_matching': True, @@ -943,36 +956,14 @@ class NiconicoLiveIE(InfoExtractor): _KNOWN_LATENCY = ('high', 'low') - def _real_extract(self, url): - video_id = self._match_id(url) - webpage, urlh = self._download_webpage_handle(f'https://live.nicovideo.jp/watch/{video_id}', video_id) - - embedded_data = self._parse_json(unescapeHTML(self._search_regex( - r' Date: Mon, 11 Mar 2024 15:22:07 +0000 Subject: [PATCH 02/16] [ie/niconico] Move WebSocket headers to info_dict Thanks for coletdjnz's suggestion! --- yt_dlp/downloader/niconico.py | 3 +-- yt_dlp/extractor/niconico.py | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/yt_dlp/downloader/niconico.py b/yt_dlp/downloader/niconico.py index 4c51bb166d..7011d83e28 100644 --- a/yt_dlp/downloader/niconico.py +++ b/yt_dlp/downloader/niconico.py @@ -84,8 +84,7 @@ class NiconicoLiveBaseFD(FileDownloader): def communicate_ws(reconnect): if reconnect: - self.ws = self.ydl.urlopen(Request( - self.ws.url, headers={'Origin': self.ws.wsw.request.headers['Origin']})) + self.ws = self.ydl.urlopen(Request(self.ws.url, headers=info_dict.get('http_headers'))) if self.ydl.params.get('verbose', False): self.to_screen('[debug] Sending startWatching request') self.ws.send(json.dumps({ diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py index 5fa84a34be..805b401937 100644 --- a/yt_dlp/extractor/niconico.py +++ b/yt_dlp/extractor/niconico.py @@ -1009,6 +1009,7 @@ class NiconicoLiveIE(InfoExtractor): def _real_extract(self, url): video_id = self._match_id(url) webpage, urlh = self._download_webpage_handle(f'https://live.nicovideo.jp/watch/{video_id}', video_id) + headers = {'Origin': 'https://' + remove_start(urlparse(urlh.url).hostname, 'sp.')} embedded_data = self._parse_json(unescapeHTML(self._search_regex( r' Date: Mon, 11 Mar 2024 16:17:54 +0000 Subject: [PATCH 03/16] [ie/niconico] Use "network_exceptions"; add errnote for m3u8 download --- yt_dlp/downloader/niconico.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/yt_dlp/downloader/niconico.py b/yt_dlp/downloader/niconico.py index 7011d83e28..f50e89ac23 100644 --- a/yt_dlp/downloader/niconico.py +++ b/yt_dlp/downloader/niconico.py @@ -9,7 +9,7 @@ from .common import FileDownloader from .external import FFmpegFD from ..downloader.hls import HlsFD from ..networking import Request -from ..networking.exceptions import RequestError +from ..networking.exceptions import network_exceptions from ..utils import ( DownloadError, RetryManager, @@ -233,7 +233,8 @@ class NiconicoLiveTimeshiftFD(NiconicoLiveBaseFD, HlsFD): raise DownloadError('Unable to get playlist') # Get all fragments - media_m3u8 = ie._download_webpage(media_m3u8_url, video_id, note=False) + media_m3u8 = ie._download_webpage( + media_m3u8_url, video_id, note=False, errnote='Unable to download media m3u8') fragment_urls = traverse_obj(media_m3u8.splitlines(), ( lambda _, v: not v.startswith('#'), {lambda url: urljoin(media_m3u8_url, url)})) @@ -245,7 +246,7 @@ class NiconicoLiveTimeshiftFD(NiconicoLiveBaseFD, HlsFD): self._append_fragment(ctx, self._read_fragment(ctx)) downloaded_duration += fragment_duration - except (DownloadError, RequestError) as err: # Including HTTPError and TransportError + except (DownloadError, *network_exceptions) as err: retry.error = err continue From 972a2d51ad70dfe395799c5bf6566c36fe84efe8 Mon Sep 17 00:00:00 2001 From: Mozi <29089388+pzhlkj6612@users.noreply.github.com> Date: Tue, 12 Mar 2024 05:06:28 +0000 Subject: [PATCH 04/16] [ie/niconico] Apply suggestions: info_dict, protocols and downloaders - Use "downloader_options" to pass options used by the downloader. - Combine the two downloaders into one. - Don't inherit from "HlsFD". Co-authored-by: pukkandan --- yt_dlp/downloader/__init__.py | 3 +-- yt_dlp/downloader/niconico.py | 37 +++++++++++++---------------------- yt_dlp/extractor/niconico.py | 8 +++++--- 3 files changed, 20 insertions(+), 28 deletions(-) diff --git a/yt_dlp/downloader/__init__.py b/yt_dlp/downloader/__init__.py index 444c348576..9f6385058c 100644 --- a/yt_dlp/downloader/__init__.py +++ b/yt_dlp/downloader/__init__.py @@ -30,7 +30,7 @@ from .hls import HlsFD from .http import HttpFD from .ism import IsmFD from .mhtml import MhtmlFD -from .niconico import NiconicoDmcFD, NiconicoLiveFD, NiconicoLiveTimeshiftFD +from .niconico import NiconicoDmcFD, NiconicoLiveFD from .rtmp import RtmpFD from .rtsp import RtspFD from .websocket import WebSocketFragmentFD @@ -51,7 +51,6 @@ PROTOCOL_MAP = { 'mhtml': MhtmlFD, 'niconico_dmc': NiconicoDmcFD, 'm3u8_niconico_live': NiconicoLiveFD, - 'm3u8_niconico_live_timeshift': NiconicoLiveTimeshiftFD, 'fc2_live': FC2LiveFD, 'websocket_frag': WebSocketFragmentFD, 'youtube_live_chat': YoutubeLiveChatFD, diff --git a/yt_dlp/downloader/niconico.py b/yt_dlp/downloader/niconico.py index f50e89ac23..ea00a9a8e3 100644 --- a/yt_dlp/downloader/niconico.py +++ b/yt_dlp/downloader/niconico.py @@ -7,7 +7,7 @@ import time from . import get_suitable_downloader from .common import FileDownloader from .external import FFmpegFD -from ..downloader.hls import HlsFD +from ..downloader.fragment import FragmentFD from ..networking import Request from ..networking.exceptions import network_exceptions from ..utils import ( @@ -67,7 +67,10 @@ class NiconicoDmcFD(FileDownloader): return success -class NiconicoLiveBaseFD(FileDownloader): +class NiconicoLiveFD(FragmentFD): + """ Downloads niconico live/timeshift VOD """ + + _PER_FRAGMENT_DOWNLOAD_RATIO = 0.1 _WEBSOCKET_RECONNECT_DELAY = 10 @contextlib.contextmanager @@ -75,8 +78,8 @@ class NiconicoLiveBaseFD(FileDownloader): """ Hold a WebSocket object and release it when leaving """ video_id = info_dict['id'] - live_latency = info_dict['live_latency'] - self.ws = info_dict['__ws'] + live_latency = info_dict['downloader_options']['live_latency'] + self.ws = info_dict['downloader_options']['ws'] self.m3u8_lock = threading.Event() self.m3u8_url = info_dict['manifest_url'] @@ -167,27 +170,15 @@ class NiconicoLiveBaseFD(FileDownloader): self.m3u8_lock.wait() return self.m3u8_url - -class NiconicoLiveFD(NiconicoLiveBaseFD): - """ Downloads niconico live without being stopped """ - - def real_download(self, filename, info_dict): - with self._ws_context(info_dict): - new_info_dict = info_dict.copy() - new_info_dict.update({ - 'protocol': 'm3u8', - }) - - return FFmpegFD(self.ydl, self.params or {}).download(filename, new_info_dict) - - -class NiconicoLiveTimeshiftFD(NiconicoLiveBaseFD, HlsFD): - """ Downloads niconico live timeshift VOD """ - - _PER_FRAGMENT_DOWNLOAD_RATIO = 0.1 - def real_download(self, filename, info_dict): with self._ws_context(info_dict) as ws_context: + # live + if info_dict.get('is_live'): + info_dict = info_dict.copy() + info_dict['protocol'] = 'm3u8' + return FFmpegFD(self.ydl, self.params or {}).download(filename, info_dict) + + # timeshift VOD from ..extractor.niconico import NiconicoIE ie = NiconicoIE(self.ydl) diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py index 805b401937..cba25b5325 100644 --- a/yt_dlp/extractor/niconico.py +++ b/yt_dlp/extractor/niconico.py @@ -1002,7 +1002,7 @@ class NiconicoLiveIE(InfoExtractor): for fmt, q in zip(formats, reversed(qualities[1:])): fmt.update({ 'format_id': q, - 'protocol': 'm3u8_niconico_live' if is_live else 'm3u8_niconico_live_timeshift', + 'protocol': 'm3u8_niconico_live', }) yield fmt @@ -1075,7 +1075,9 @@ class NiconicoLiveIE(InfoExtractor): 'live_status': live_status, 'thumbnails': thumbnails, 'formats': [*self._yield_formats(ws, video_id, latency, live_status == 'is_live')] if ws else None, - 'live_latency': latency, 'http_headers': headers, - '__ws': ws, + 'downloader_options': { + 'live_latency': latency, + 'ws': ws, + }, } From d9a6507fe6da64c3a23d4665f265e80569adbbaa Mon Sep 17 00:00:00 2001 From: Mozi <29089388+pzhlkj6612@users.noreply.github.com> Date: Tue, 12 Mar 2024 15:34:37 +0000 Subject: [PATCH 05/16] [ie/niconico] Support "--load-info-json" by saving WebSocket url aka "--load-info". Don't save a Response object to info JSON. Just create a new WebSocket connection during the download. Due to Niconico's logic, the manifest m3u8 url will be unusable soon if there is no active WebSocket connection, so the reconnection will give us a valid manifest m3u8, unless the WebSocket url has already expired. --- yt_dlp/downloader/niconico.py | 53 +++++++++++++++++------------------ yt_dlp/extractor/niconico.py | 15 ++++++---- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/yt_dlp/downloader/niconico.py b/yt_dlp/downloader/niconico.py index ea00a9a8e3..6ec2c87665 100644 --- a/yt_dlp/downloader/niconico.py +++ b/yt_dlp/downloader/niconico.py @@ -79,33 +79,33 @@ class NiconicoLiveFD(FragmentFD): video_id = info_dict['id'] live_latency = info_dict['downloader_options']['live_latency'] - self.ws = info_dict['downloader_options']['ws'] + ws_url = info_dict['downloader_options']['ws_url'] + + self.ws = None self.m3u8_lock = threading.Event() - self.m3u8_url = info_dict['manifest_url'] - self.m3u8_lock.set() + self.m3u8_url = None - def communicate_ws(reconnect): - if reconnect: - self.ws = self.ydl.urlopen(Request(self.ws.url, headers=info_dict.get('http_headers'))) - if self.ydl.params.get('verbose', False): - self.to_screen('[debug] Sending startWatching request') - self.ws.send(json.dumps({ - 'type': 'startWatching', - 'data': { - 'stream': { - 'quality': 'abr', - 'protocol': 'hls', - 'latency': live_latency, - 'chasePlay': False - }, - 'room': { - 'protocol': 'webSocket', - 'commentable': True - }, - 'reconnect': True, - } - })) + def communicate_ws(): + self.ws = self.ydl.urlopen(Request(ws_url, headers=info_dict.get('http_headers'))) + if self.ydl.params.get('verbose', False): + self.to_screen('[debug] Sending startWatching request') + self.ws.send(json.dumps({ + 'type': 'startWatching', + 'data': { + 'stream': { + 'quality': 'abr', + 'protocol': 'hls', + 'latency': live_latency, + 'chasePlay': False + }, + 'room': { + 'protocol': 'webSocket', + 'commentable': True + }, + 'reconnect': True, + } + })) with self.ws: while True: recv = self.ws.recv() @@ -136,10 +136,9 @@ class NiconicoLiveFD(FragmentFD): stopped = threading.Event() def ws_main(): - reconnect = False while not stopped.is_set(): try: - communicate_ws(reconnect) + communicate_ws() break # Disconnected except BaseException as e: # Including TransportError if stopped.is_set(): @@ -150,8 +149,6 @@ class NiconicoLiveFD(FragmentFD): self.to_screen('[%s] %s: Connection error occured, reconnecting after %d seconds: %s' % ('niconico:live', video_id, self._WEBSOCKET_RECONNECT_DELAY, str_or_none(e))) time.sleep(self._WEBSOCKET_RECONNECT_DELAY) - reconnect = True - self.m3u8_lock.set() # Release possible locks thread = threading.Thread(target=ws_main, daemon=True) diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py index cba25b5325..5eee857f53 100644 --- a/yt_dlp/extractor/niconico.py +++ b/yt_dlp/extractor/niconico.py @@ -956,7 +956,10 @@ class NiconicoLiveIE(InfoExtractor): _KNOWN_LATENCY = ('high', 'low') - def _yield_formats(self, ws, video_id, latency, is_live): + def _yield_formats(self, ws_url, headers, latency, video_id, is_live): + ws = self._request_webpage( + Request(ws_url, headers=headers), video_id, note='Connecting to WebSocket server') + self.write_debug('[debug] Sending HLS server request') ws.send(json.dumps({ 'type': 'startWatching', @@ -998,6 +1001,8 @@ class NiconicoLiveIE(InfoExtractor): recv = recv[:100] + '...' self.write_debug('Server said: %s' % recv) + ws.close() + formats = self._extract_m3u8_formats(m3u8_url, video_id, ext='mp4', live=is_live) for fmt, q in zip(formats, reversed(qualities[1:])): fmt.update({ @@ -1014,14 +1019,11 @@ class NiconicoLiveIE(InfoExtractor): embedded_data = self._parse_json(unescapeHTML(self._search_regex( r' Date: Tue, 12 Mar 2024 16:02:48 +0000 Subject: [PATCH 06/16] null check for "self.ws" --- yt_dlp/downloader/niconico.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/yt_dlp/downloader/niconico.py b/yt_dlp/downloader/niconico.py index 6ec2c87665..ecc57e8dc5 100644 --- a/yt_dlp/downloader/niconico.py +++ b/yt_dlp/downloader/niconico.py @@ -158,7 +158,8 @@ class NiconicoLiveFD(FragmentFD): yield self finally: stopped.set() - self.ws.close() + if self.ws: + self.ws.close() thread.join() def _master_m3u8_url(self): From fe29c67a14839ecd7c4b7206c5bb70ed85cd6fc2 Mon Sep 17 00:00:00 2001 From: Mozi <29089388+pzhlkj6612@users.noreply.github.com> Date: Sun, 26 May 2024 16:03:48 +0000 Subject: [PATCH 07/16] use urllib.parse.urlparse() --- yt_dlp/extractor/niconico.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py index a66b78ea19..5d8b1e7812 100644 --- a/yt_dlp/extractor/niconico.py +++ b/yt_dlp/extractor/niconico.py @@ -1013,7 +1013,7 @@ class NiconicoLiveIE(InfoExtractor): def _real_extract(self, url): video_id = self._match_id(url) webpage, urlh = self._download_webpage_handle(f'https://live.nicovideo.jp/watch/{video_id}', video_id) - headers = {'Origin': 'https://' + remove_start(urlparse(urlh.url).hostname, 'sp.')} + headers = {'Origin': 'https://' + remove_start(urllib.parse.urlparse(urlh.url).hostname, 'sp.')} embedded_data = self._parse_json(unescapeHTML(self._search_regex( r' Date: Thu, 30 May 2024 15:16:27 +0000 Subject: [PATCH 08/16] [ie/niconico] accurately check live status; add availability check --- yt_dlp/extractor/niconico.py | 74 ++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py index 5d8b1e7812..a201fb6de8 100644 --- a/yt_dlp/extractor/niconico.py +++ b/yt_dlp/extractor/niconico.py @@ -911,6 +911,8 @@ class NiconicoUserIE(InfoExtractor): class NiconicoLiveIE(InfoExtractor): IE_NAME = 'niconico:live' IE_DESC = 'ニコニコ生放送' + _GEO_COUNTRIES = ['JP'] + _GEO_BYPASS = False _VALID_URL = r'https?://(?:sp\.)?live2?\.nicovideo\.jp/(?:watch|gate)/(?Plv\d+)' _TESTS = [{ 'note': 'this test case includes invisible characters for title, pasting them as-is', @@ -959,7 +961,7 @@ class NiconicoLiveIE(InfoExtractor): ws = self._request_webpage( Request(ws_url, headers=headers), video_id, note='Connecting to WebSocket server') - self.write_debug('[debug] Sending HLS server request') + self.write_debug('Sending HLS server request') ws.send(json.dumps({ 'type': 'startWatching', 'data': { @@ -1023,8 +1025,6 @@ class NiconicoLiveIE(InfoExtractor): ws_url = update_url_query(ws_url, { 'frontend_id': traverse_obj(embedded_data, ('site', 'frontendId')) or '9', }) - else: - self.raise_no_formats('The live hasn\'t started yet or already ended.', expected=True) title = traverse_obj(embedded_data, ('program', 'title')) or self._html_search_meta( ('og:title', 'twitter:title'), webpage, 'live title', fatal=False) @@ -1050,11 +1050,7 @@ class NiconicoLiveIE(InfoExtractor): **res, }) - live_status = { - 'Before': 'is_live', - 'Open': 'was_live', - 'End': 'was_live', - }.get(traverse_obj(embedded_data, ('programTimeshift', 'publication', 'status', {str})), 'is_live') + live_status, availability = self._check_status_and_availability(embedded_data, video_id) latency = try_get(self._configuration_arg('latency'), lambda x: x[0]) if latency not in self._KNOWN_LATENCY: @@ -1074,6 +1070,7 @@ class NiconicoLiveIE(InfoExtractor): 'description': clean_html(traverse_obj(embedded_data, ('program', 'description'))), 'timestamp': int_or_none(traverse_obj(embedded_data, ('program', 'openTime'))), 'live_status': live_status, + 'availability': availability, 'thumbnails': thumbnails, 'formats': [*self._yield_formats( ws_url, headers, latency, video_id, live_status == 'is_live')] if ws_url else None, @@ -1083,3 +1080,64 @@ class NiconicoLiveIE(InfoExtractor): 'ws_url': ws_url, }, } + + def _check_status_and_availability(self, embedded_data, video_id): + live_status = { + 'Before': 'is_live', + 'Open': 'was_live', + 'End': 'was_live', + }.get(traverse_obj(embedded_data, ('programTimeshift', 'publication', 'status', {str})), 'is_live') + + if traverse_obj(embedded_data, ('userProgramWatch', 'canWatch', {bool})): + if needs_subscription := traverse_obj(embedded_data, ('program', 'isMemberFree', {bool})): + msg = 'You have no right to access the paid content. ' + if not traverse_obj(embedded_data, ('program', 'trialWatch', 'isShown', {bool})): + msg += 'This video may be completely blank' + else: + # TODO: get the exact duration of the free part + msg += 'There may be some blank parts in this video' + self.report_warning(msg, video_id) + return live_status, self._availability(needs_subscription=needs_subscription) + + if traverse_obj(embedded_data, ('userProgramWatch', 'isCountryRestrictionTarget', {bool})): + self.raise_geo_restricted(countries=self._GEO_COUNTRIES, metadata_available=True) + return live_status, self._availability() + + rejected_reasons = traverse_obj(embedded_data, ('userProgramWatch', 'rejectedReasons', ..., {str})) + self.write_debug(f'userProgramWatch.rejectedReasons: {rejected_reasons!r}') + + if 'programNotBegun' in rejected_reasons: + live_status = 'is_upcoming' + elif 'timeshiftBeforeOpen' in rejected_reasons: + live_status = 'post_live' + elif 'noTimeshiftProgram' in rejected_reasons: + self.report_warning('Timeshift is disabled', video_id) + live_status = 'was_live' + elif any(x in ['timeshiftClosed', 'timeshiftClosedAndNotFollow'] for x in rejected_reasons): + self.report_warning('Timeshift viewing period has ended', video_id) + live_status = 'was_live' + + availability = self._availability(**{ + 'needs_premium': 'notLogin' in rejected_reasons, + 'needs_subscription': any(x in [ + 'notSocialGroupMember', + 'notCommunityMember', + 'notChannelMember', + 'notCommunityMemberAndNotHaveTimeshiftTicket', + 'notChannelMemberAndNotHaveTimeshiftTicket', + ] for x in rejected_reasons), + 'needs_auth': any(x in [ + 'timeshiftTicketExpired', + 'notHaveTimeshiftTicket', + 'notCommunityMemberAndNotHaveTimeshiftTicket', + 'notChannelMemberAndNotHaveTimeshiftTicket', + 'notHavePayTicket', + 'notActivatedBySerial', + 'notHavePayTicketAndNotActivatedBySerial', + 'notUseTimeshiftTicket', + 'notUseTimeshiftTicketOnOnceTimeshift', + 'notUseTimeshiftTicketOnUnlimitedTimeshift', + ] for x in rejected_reasons), + }) + + return live_status, availability From f65ad7f3c2e708ccbdcaf6b97f01a0741e13742a Mon Sep 17 00:00:00 2001 From: Mozi <29089388+pzhlkj6612@users.noreply.github.com> Date: Fri, 31 May 2024 18:16:46 +0000 Subject: [PATCH 09/16] [ie/niconico] adjust the warning about the blank part in videos --- yt_dlp/extractor/niconico.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py index a201fb6de8..c8266f1664 100644 --- a/yt_dlp/extractor/niconico.py +++ b/yt_dlp/extractor/niconico.py @@ -1089,26 +1089,32 @@ class NiconicoLiveIE(InfoExtractor): }.get(traverse_obj(embedded_data, ('programTimeshift', 'publication', 'status', {str})), 'is_live') if traverse_obj(embedded_data, ('userProgramWatch', 'canWatch', {bool})): - if needs_subscription := traverse_obj(embedded_data, ('program', 'isMemberFree', {bool})): - msg = 'You have no right to access the paid content. ' - if not traverse_obj(embedded_data, ('program', 'trialWatch', 'isShown', {bool})): - msg += 'This video may be completely blank' - else: - # TODO: get the exact duration of the free part - msg += 'There may be some blank parts in this video' - self.report_warning(msg, video_id) - return live_status, self._availability(needs_subscription=needs_subscription) + is_member_free = traverse_obj(embedded_data, ('program', 'isMemberFree', {bool})) + is_shown = traverse_obj(embedded_data, ('program', 'trialWatch', 'isShown', {bool})) + self.write_debug(f'.program.isMemberFree: {is_member_free}; .program.trialWatch.isShown: {is_shown}') + + if is_member_free is None and is_shown is None: + return live_status, self._availability() + + if is_member_free is False: + msg = 'You cannot access the paid content, thus the video may be blank.' + else: + msg = 'You cannot access restricted content, thus a part of the video or the entire video may be blank.' + self.report_warning(msg, video_id) + return live_status, self._availability(needs_subscription=True) if traverse_obj(embedded_data, ('userProgramWatch', 'isCountryRestrictionTarget', {bool})): self.raise_geo_restricted(countries=self._GEO_COUNTRIES, metadata_available=True) return live_status, self._availability() rejected_reasons = traverse_obj(embedded_data, ('userProgramWatch', 'rejectedReasons', ..., {str})) - self.write_debug(f'userProgramWatch.rejectedReasons: {rejected_reasons!r}') + self.write_debug(f'.userProgramWatch.rejectedReasons: {rejected_reasons!r}') if 'programNotBegun' in rejected_reasons: + self.report_warning('Live has not started', video_id) live_status = 'is_upcoming' elif 'timeshiftBeforeOpen' in rejected_reasons: + self.report_warning('Live has ended but timeshift is not yet processed', video_id) live_status = 'post_live' elif 'noTimeshiftProgram' in rejected_reasons: self.report_warning('Timeshift is disabled', video_id) @@ -1140,4 +1146,11 @@ class NiconicoLiveIE(InfoExtractor): ] for x in rejected_reasons), }) + if availability == 'premium_only': + self.raise_login_required('This video requires premium', metadata_available=True) + elif availability == 'subscriber_only': + self.raise_login_required('This video is for members only', metadata_available=True) + elif availability == 'needs_auth': + self.raise_login_required(metadata_available=True) + return live_status, availability From efe4b7101af1012e0287287bbf91d6febe5ebf19 Mon Sep 17 00:00:00 2001 From: Mozi <29089388+pzhlkj6612@users.noreply.github.com> Date: Fri, 31 May 2024 18:31:48 +0000 Subject: [PATCH 10/16] [ie/niconico] support login for niconico live --- yt_dlp/extractor/niconico.py | 95 ++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 47 deletions(-) diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py index c8266f1664..271060c831 100644 --- a/yt_dlp/extractor/niconico.py +++ b/yt_dlp/extractor/niconico.py @@ -32,12 +32,56 @@ from ..utils import ( ) -class NiconicoIE(InfoExtractor): - IE_NAME = 'niconico' - IE_DESC = 'ニコニコ動画' +class NiconicoBaseIE(InfoExtractor): + _NETRC_MACHINE = 'niconico' _GEO_COUNTRIES = ['JP'] _GEO_BYPASS = False + def _perform_login(self, username, password): + login_ok = True + login_form_strs = { + 'mail_tel': username, + 'password': password, + } + self._request_webpage( + 'https://account.nicovideo.jp/login', None, + note='Acquiring Login session') + page = self._download_webpage( + 'https://account.nicovideo.jp/login/redirector?show_button_twitter=1&site=niconico&show_button_facebook=1', None, + note='Logging in', errnote='Unable to log in', + data=urlencode_postdata(login_form_strs), + headers={ + 'Referer': 'https://account.nicovideo.jp/login', + 'Content-Type': 'application/x-www-form-urlencoded', + }) + if 'oneTimePw' in page: + post_url = self._search_regex( + r']+action=(["\'])(?P.+?)\1', page, 'post url', group='url') + page = self._download_webpage( + urljoin('https://account.nicovideo.jp', post_url), None, + note='Performing MFA', errnote='Unable to complete MFA', + data=urlencode_postdata({ + 'otp': self._get_tfa_info('6 digits code') + }), headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + }) + if 'oneTimePw' in page or 'formError' in page: + err_msg = self._html_search_regex( + r'formError["\']+>(.*?)', page, 'form_error', + default='There\'s an error but the message can\'t be parsed.', + flags=re.DOTALL) + self.report_warning(f'Unable to log in: MFA challenge failed, "{err_msg}"') + return False + login_ok = 'class="notice error"' not in page + if not login_ok: + self.report_warning('Unable to log in: bad username or password') + return login_ok + + +class NiconicoIE(NiconicoBaseIE): + IE_NAME = 'niconico' + IE_DESC = 'ニコニコ動画' + _TESTS = [{ 'url': 'http://www.nicovideo.jp/watch/sm22312215', 'md5': 'd1a75c0823e2f629128c43e1212760f9', @@ -180,7 +224,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' _API_HEADERS = { 'X-Frontend-ID': '6', 'X-Frontend-Version': '0', @@ -189,46 +232,6 @@ class NiconicoIE(InfoExtractor): 'Origin': 'https://www.nicovideo.jp', } - def _perform_login(self, username, password): - login_ok = True - login_form_strs = { - 'mail_tel': username, - 'password': password, - } - self._request_webpage( - 'https://account.nicovideo.jp/login', None, - note='Acquiring Login session') - page = self._download_webpage( - 'https://account.nicovideo.jp/login/redirector?show_button_twitter=1&site=niconico&show_button_facebook=1', None, - note='Logging in', errnote='Unable to log in', - data=urlencode_postdata(login_form_strs), - headers={ - 'Referer': 'https://account.nicovideo.jp/login', - 'Content-Type': 'application/x-www-form-urlencoded', - }) - if 'oneTimePw' in page: - post_url = self._search_regex( - r']+action=(["\'])(?P.+?)\1', page, 'post url', group='url') - page = self._download_webpage( - urljoin('https://account.nicovideo.jp', post_url), None, - note='Performing MFA', errnote='Unable to complete MFA', - data=urlencode_postdata({ - 'otp': self._get_tfa_info('6 digits code') - }), headers={ - 'Content-Type': 'application/x-www-form-urlencoded', - }) - if 'oneTimePw' in page or 'formError' in page: - err_msg = self._html_search_regex( - r'formError["\']+>(.*?)', page, 'form_error', - default='There\'s an error but the message can\'t be parsed.', - flags=re.DOTALL) - self.report_warning(f'Unable to log in: MFA challenge failed, "{err_msg}"') - return False - login_ok = 'class="notice error"' not in page - if not login_ok: - self.report_warning('Unable to log in: bad username or password') - return login_ok - def _get_heartbeat_info(self, info_dict): video_id, video_src_id, audio_src_id = info_dict['url'].split(':')[1].split('/') dmc_protocol = info_dict['expected_protocol'] @@ -908,11 +911,9 @@ class NiconicoUserIE(InfoExtractor): return self.playlist_result(self._entries(list_id), list_id, ie=NiconicoIE.ie_key()) -class NiconicoLiveIE(InfoExtractor): +class NiconicoLiveIE(NiconicoBaseIE): IE_NAME = 'niconico:live' IE_DESC = 'ニコニコ生放送' - _GEO_COUNTRIES = ['JP'] - _GEO_BYPASS = False _VALID_URL = r'https?://(?:sp\.)?live2?\.nicovideo\.jp/(?:watch|gate)/(?Plv\d+)' _TESTS = [{ 'note': 'this test case includes invisible characters for title, pasting them as-is', From 1e23756e50b8642ff8089354f5d5333263c346eb Mon Sep 17 00:00:00 2001 From: Mozi <29089388+pzhlkj6612@users.noreply.github.com> Date: Sat, 1 Jun 2024 02:16:37 +0000 Subject: [PATCH 11/16] [ie/niconico] raise_login_required() for all possible availabilities --- yt_dlp/extractor/niconico.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py index 271060c831..f017e8d6cc 100644 --- a/yt_dlp/extractor/niconico.py +++ b/yt_dlp/extractor/niconico.py @@ -1053,6 +1053,14 @@ class NiconicoLiveIE(NiconicoBaseIE): live_status, availability = self._check_status_and_availability(embedded_data, video_id) + if availability == 'premium_only': + self.raise_login_required('This video requires premium', metadata_available=True) + elif availability == 'subscriber_only': + self.raise_login_required('This video is for members only', metadata_available=True) + elif availability == 'needs_auth': + # PPV or tickets for limited time viewing + self.raise_login_required('This video requires additional steps to watch', metadata_available=True) + latency = try_get(self._configuration_arg('latency'), lambda x: x[0]) if latency not in self._KNOWN_LATENCY: latency = 'high' @@ -1098,11 +1106,13 @@ class NiconicoLiveIE(NiconicoBaseIE): return live_status, self._availability() if is_member_free is False: - msg = 'You cannot access the paid content, thus the video may be blank.' + availability = {'needs_auth': True} + msg = 'Paid content cannot be accessed, the video may be blank.' else: - msg = 'You cannot access restricted content, thus a part of the video or the entire video may be blank.' + availability = {'needs_subscription': True} + msg = 'Restricted content cannot be accessed, a part of the video or the entire video may be blank.' self.report_warning(msg, video_id) - return live_status, self._availability(needs_subscription=True) + return live_status, self._availability(**availability) if traverse_obj(embedded_data, ('userProgramWatch', 'isCountryRestrictionTarget', {bool})): self.raise_geo_restricted(countries=self._GEO_COUNTRIES, metadata_available=True) @@ -1147,11 +1157,4 @@ class NiconicoLiveIE(NiconicoBaseIE): ] for x in rejected_reasons), }) - if availability == 'premium_only': - self.raise_login_required('This video requires premium', metadata_available=True) - elif availability == 'subscriber_only': - self.raise_login_required('This video is for members only', metadata_available=True) - elif availability == 'needs_auth': - self.raise_login_required(metadata_available=True) - return live_status, availability From dcefdfe5080071336daa6a3911357f247cf43a19 Mon Sep 17 00:00:00 2001 From: Mozi <29089388+pzhlkj6612@users.noreply.github.com> Date: Tue, 20 Aug 2024 07:42:53 +0000 Subject: [PATCH 12/16] apply future Ruff rules --- yt_dlp/downloader/niconico.py | 18 +++--- yt_dlp/extractor/niconico.py | 114 ++++++++++++++++------------------ 2 files changed, 64 insertions(+), 68 deletions(-) diff --git a/yt_dlp/downloader/niconico.py b/yt_dlp/downloader/niconico.py index ecc57e8dc5..265f6b2e30 100644 --- a/yt_dlp/downloader/niconico.py +++ b/yt_dlp/downloader/niconico.py @@ -26,7 +26,7 @@ class NiconicoDmcFD(FileDownloader): def real_download(self, filename, info_dict): from ..extractor.niconico import NiconicoIE - self.to_screen('[%s] Downloading from DMC' % self.FD_NAME) + self.to_screen(f'[{self.FD_NAME}] Downloading from DMC') ie = NiconicoIE(self.ydl) info_dict, heartbeat_info_dict = ie._get_heartbeat_info(info_dict) @@ -45,7 +45,7 @@ class NiconicoDmcFD(FileDownloader): try: self.ydl.urlopen(request).read() except Exception: - self.to_screen('[%s] Heartbeat failed' % self.FD_NAME) + self.to_screen(f'[{self.FD_NAME}] Heartbeat failed') with heartbeat_lock: if not download_complete: @@ -97,14 +97,14 @@ class NiconicoLiveFD(FragmentFD): 'quality': 'abr', 'protocol': 'hls', 'latency': live_latency, - 'chasePlay': False + 'chasePlay': False, }, 'room': { 'protocol': 'webSocket', - 'commentable': True + 'commentable': True, }, 'reconnect': True, - } + }, })) with self.ws: while True: @@ -131,7 +131,7 @@ class NiconicoLiveFD(FragmentFD): elif self.ydl.params.get('verbose', False): if len(recv) > 100: recv = recv[:100] + '...' - self.to_screen('[debug] Server said: %s' % recv) + self.to_screen(f'[debug] Server said: {recv}') stopped = threading.Event() @@ -146,7 +146,7 @@ class NiconicoLiveFD(FragmentFD): self.m3u8_lock.clear() # m3u8 url may be changed - self.to_screen('[%s] %s: Connection error occured, reconnecting after %d seconds: %s' % ('niconico:live', video_id, self._WEBSOCKET_RECONNECT_DELAY, str_or_none(e))) + self.to_screen('[{}] {}: Connection error occured, reconnecting after {} seconds: {}'.format('niconico:live', video_id, self._WEBSOCKET_RECONNECT_DELAY, str_or_none(e))) time.sleep(self._WEBSOCKET_RECONNECT_DELAY) self.m3u8_lock.set() # Release possible locks @@ -216,7 +216,7 @@ class NiconicoLiveFD(FragmentFD): # Refresh master m3u8 (if possible) and get the url of the previously-chose format master_m3u8_url = ws_context._master_m3u8_url() formats = ie._extract_m3u8_formats( - master_m3u8_url, video_id, query={"start": downloaded_duration}, live=False, note=False, fatal=False) + master_m3u8_url, video_id, query={'start': downloaded_duration}, live=False, note=False, fatal=False) media_m3u8_url = traverse_obj(formats, (format_index, {dict}, 'url'), get_all=False) if not media_m3u8_url: raise DownloadError('Unable to get playlist') @@ -244,7 +244,7 @@ class NiconicoLiveFD(FragmentFD): return self._finish_frag_download(ctx, info_dict) - class DurationLimiter(): + class DurationLimiter: def __init__(self, target): self.target = target diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py index f017e8d6cc..a6b5e39b46 100644 --- a/yt_dlp/extractor/niconico.py +++ b/yt_dlp/extractor/niconico.py @@ -61,7 +61,7 @@ class NiconicoBaseIE(InfoExtractor): urljoin('https://account.nicovideo.jp', post_url), None, note='Performing MFA', errnote='Unable to complete MFA', data=urlencode_postdata({ - 'otp': self._get_tfa_info('6 digits code') + 'otp': self._get_tfa_info('6 digits code'), }), headers={ 'Content-Type': 'application/x-www-form-urlencoded', }) @@ -267,7 +267,7 @@ class NiconicoIE(NiconicoBaseIE): 'http_output_download_parameters': { 'use_ssl': yesno(session_api_data['urls'][0]['isSsl']), 'use_well_known_port': yesno(session_api_data['urls'][0]['isWellKnownPort']), - } + }, } elif dmc_protocol == 'hls': protocol = 'm3u8' @@ -280,14 +280,14 @@ class NiconicoIE(NiconicoBaseIE): 'transfer_preset': '', 'use_ssl': yesno(session_api_data['urls'][0]['isSsl']), 'use_well_known_port': yesno(session_api_data['urls'][0]['isWellKnownPort']), - } + }, } if 'hls_encryption' in parsed_token and encryption: protocol_parameters['hls_parameters']['encryption'] = { parsed_token['hls_encryption']: { 'encrypted_key': encryption['encryptedKey'], 'key_uri': encryption['keyUri'], - } + }, } else: protocol = 'm3u8_native' @@ -298,7 +298,7 @@ class NiconicoIE(NiconicoBaseIE): session_api_endpoint['url'], video_id, query={'_format': 'json'}, headers={'Content-Type': 'application/json'}, - note='Downloading JSON metadata for %s' % info_dict['format_id'], + note='Downloading JSON metadata for {}'.format(info_dict['format_id']), data=json.dumps({ 'session': { 'client_info': { @@ -308,7 +308,7 @@ class NiconicoIE(NiconicoBaseIE): 'auth_type': try_get(session_api_data, lambda x: x['authTypes'][session_api_data['protocols'][0]]), 'content_key_timeout': session_api_data.get('contentKeyTimeout'), 'service_id': 'nicovideo', - 'service_user_id': session_api_data.get('serviceUserId') + 'service_user_id': session_api_data.get('serviceUserId'), }, 'content_id': session_api_data.get('contentId'), 'content_src_id_sets': [{ @@ -316,34 +316,34 @@ class NiconicoIE(NiconicoBaseIE): 'src_id_to_mux': { 'audio_src_ids': [audio_src_id], 'video_src_ids': [video_src_id], - } - }] + }, + }], }], 'content_type': 'movie', 'content_uri': '', 'keep_method': { 'heartbeat': { - 'lifetime': session_api_data.get('heartbeatLifetime') - } + 'lifetime': session_api_data.get('heartbeatLifetime'), + }, }, 'priority': session_api_data['priority'], 'protocol': { 'name': 'http', 'parameters': { 'http_parameters': { - 'parameters': protocol_parameters - } - } + 'parameters': protocol_parameters, + }, + }, }, 'recipe_id': session_api_data.get('recipeId'), 'session_operation_auth': { 'session_operation_auth_by_signature': { 'signature': session_api_data.get('signature'), 'token': session_api_data.get('token'), - } + }, }, - 'timing_constraint': 'unlimited' - } + 'timing_constraint': 'unlimited', + }, }).encode()) info_dict['url'] = session_response['data']['session']['content_uri'] @@ -355,7 +355,7 @@ class NiconicoIE(NiconicoBaseIE): 'data': json.dumps(session_response['data']), # interval, convert milliseconds to seconds, then halve to make a buffer. 'interval': float_or_none(session_api_data.get('heartbeatLifetime'), scale=3000), - 'ping': ping + 'ping': ping, } return info_dict, heartbeat_info_dict @@ -371,7 +371,7 @@ class NiconicoIE(NiconicoBaseIE): vid_qual_label = traverse_obj(video_quality, ('metadata', 'label')) return { - 'url': 'niconico_dmc:%s/%s/%s' % (video_id, video_quality['id'], audio_quality['id']), + 'url': 'niconico_dmc:{}/{}/{}'.format(video_id, video_quality['id'], audio_quality['id']), 'format_id': format_id, 'format_note': join_nonempty('DMC', vid_qual_label, dmc_protocol.upper(), delim=' '), 'ext': 'mp4', # Session API are used in HTML5, which always serves mp4 @@ -392,7 +392,7 @@ class NiconicoIE(NiconicoBaseIE): 'http_headers': { 'Origin': 'https://www.nicovideo.jp', 'Referer': 'https://www.nicovideo.jp/watch/' + video_id, - } + }, } def _yield_dmc_formats(self, api_data, video_id): @@ -419,7 +419,7 @@ class NiconicoIE(NiconicoBaseIE): 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))) + '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, @@ -467,7 +467,7 @@ class NiconicoIE(NiconicoBaseIE): except ExtractorError as e: try: api_data = self._download_json( - 'https://www.nicovideo.jp/api/watch/v3/%s?_frontendId=6&_frontendVersion=0&actionTrackId=AAAAAAAAAA_%d' % (video_id, round(time.time() * 1000)), video_id, + f'https://www.nicovideo.jp/api/watch/v3/{video_id}?_frontendId=6&_frontendVersion=0&actionTrackId=AAAAAAAAAA_{round(time.time() * 1000)}', video_id, note='Downloading API JSON', errnote='Unable to fetch data')['data'] except ExtractorError: if not isinstance(e.cause, HTTPError): @@ -589,7 +589,7 @@ class NiconicoPlaylistBaseIE(InfoExtractor): _API_HEADERS = { 'X-Frontend-ID': '6', 'X-Frontend-Version': '0', - 'X-Niconico-Language': 'en-us' + 'X-Niconico-Language': 'en-us', } def _call_api(self, list_id, resource, query): @@ -604,7 +604,7 @@ class NiconicoPlaylistBaseIE(InfoExtractor): def _fetch_page(self, list_id, page): page += 1 - resp = self._call_api(list_id, 'page %d' % page, { + resp = self._call_api(list_id, f'page {page}', { 'page': page, 'pageSize': self._PAGE_SIZE, }) @@ -792,14 +792,14 @@ class NicovideoSearchURLIE(NicovideoSearchBaseIE): 'url': 'http://www.nicovideo.jp/search/sm9', 'info_dict': { 'id': 'sm9', - 'title': 'sm9' + 'title': 'sm9', }, 'playlist_mincount': 40, }, { 'url': 'https://www.nicovideo.jp/search/sm9?sort=h&order=d&end=2020-12-31&start=2020-01-01', 'info_dict': { 'id': 'sm9', - 'title': 'sm9' + 'title': 'sm9', }, 'playlist_count': 31, }] @@ -817,7 +817,7 @@ class NicovideoSearchDateIE(NicovideoSearchBaseIE, SearchInfoExtractor): 'url': 'nicosearchdateall:a', 'info_dict': { 'id': 'a', - 'title': 'a' + 'title': 'a', }, 'playlist_mincount': 1610, }] @@ -864,7 +864,7 @@ class NicovideoTagURLIE(NicovideoSearchBaseIE): 'url': 'https://www.nicovideo.jp/tag/ドキュメンタリー淫夢', 'info_dict': { 'id': 'ドキュメンタリー淫夢', - 'title': 'ドキュメンタリー淫夢' + 'title': 'ドキュメンタリー淫夢', }, 'playlist_mincount': 400, }] @@ -883,12 +883,12 @@ class NiconicoUserIE(InfoExtractor): }, 'playlist_mincount': 101, } - _API_URL = "https://nvapi.nicovideo.jp/v1/users/%s/videos?sortKey=registeredAt&sortOrder=desc&pageSize=%s&page=%s" + _API_URL = 'https://nvapi.nicovideo.jp/v1/users/%s/videos?sortKey=registeredAt&sortOrder=desc&pageSize=%s&page=%s' _PAGE_SIZE = 100 _API_HEADERS = { 'X-Frontend-ID': '6', - 'X-Frontend-Version': '0' + 'X-Frontend-Version': '0', } def _entries(self, list_id): @@ -898,12 +898,12 @@ class NiconicoUserIE(InfoExtractor): json_parsed = self._download_json( self._API_URL % (list_id, self._PAGE_SIZE, page_num + 1), list_id, headers=self._API_HEADERS, - note='Downloading JSON metadata%s' % (' page %d' % page_num if page_num else '')) + note='Downloading JSON metadata%s' % (f' page {page_num}' if page_num else '')) if not page_num: total_count = int_or_none(json_parsed['data'].get('totalCount')) - for entry in json_parsed["data"]["items"]: + for entry in json_parsed['data']['items']: count += 1 - yield self.url_result('https://www.nicovideo.jp/watch/%s' % entry['id']) + yield self.url_result('https://www.nicovideo.jp/watch/{}'.format(entry['id'])) page_num += 1 def _real_extract(self, url): @@ -920,7 +920,7 @@ class NiconicoLiveIE(NiconicoBaseIE): 'url': 'https://live.nicovideo.jp/watch/lv339533123', 'info_dict': { 'id': 'lv339533123', - 'title': '激辛ペヤング食べます‪( ;ᯅ; )‬(歌枠オーディション参加中)', + 'title': '激辛ペヤング食べます\u202a( ;ᯅ; )\u202c(歌枠オーディション参加中)', 'view_count': int, 'comment_count': int, 'description': '初めましてもかって言います❕\nのんびり自由に適当に暮らしてます', @@ -970,14 +970,14 @@ class NiconicoLiveIE(NiconicoBaseIE): 'quality': 'abr', 'protocol': 'hls', 'latency': latency, - 'chasePlay': False + 'chasePlay': False, }, 'room': { 'protocol': 'webSocket', - 'commentable': True + 'commentable': True, }, 'reconnect': False, - } + }, })) while True: @@ -1001,7 +1001,7 @@ class NiconicoLiveIE(NiconicoBaseIE): elif self.get_param('verbose', False): if len(recv) > 100: recv = recv[:100] + '...' - self.write_debug('Server said: %s' % recv) + self.write_debug(f'Server said: {recv}') ws.close() @@ -1134,27 +1134,23 @@ class NiconicoLiveIE(NiconicoBaseIE): self.report_warning('Timeshift viewing period has ended', video_id) live_status = 'was_live' - availability = self._availability(**{ - 'needs_premium': 'notLogin' in rejected_reasons, - 'needs_subscription': any(x in [ - 'notSocialGroupMember', - 'notCommunityMember', - 'notChannelMember', - 'notCommunityMemberAndNotHaveTimeshiftTicket', - 'notChannelMemberAndNotHaveTimeshiftTicket', - ] for x in rejected_reasons), - 'needs_auth': any(x in [ - 'timeshiftTicketExpired', - 'notHaveTimeshiftTicket', - 'notCommunityMemberAndNotHaveTimeshiftTicket', - 'notChannelMemberAndNotHaveTimeshiftTicket', - 'notHavePayTicket', - 'notActivatedBySerial', - 'notHavePayTicketAndNotActivatedBySerial', - 'notUseTimeshiftTicket', - 'notUseTimeshiftTicketOnOnceTimeshift', - 'notUseTimeshiftTicketOnUnlimitedTimeshift', - ] for x in rejected_reasons), - }) + availability = self._availability(needs_premium='notLogin' in rejected_reasons, needs_subscription=any(x in [ + 'notSocialGroupMember', + 'notCommunityMember', + 'notChannelMember', + 'notCommunityMemberAndNotHaveTimeshiftTicket', + 'notChannelMemberAndNotHaveTimeshiftTicket', + ] for x in rejected_reasons), needs_auth=any(x in [ + 'timeshiftTicketExpired', + 'notHaveTimeshiftTicket', + 'notCommunityMemberAndNotHaveTimeshiftTicket', + 'notChannelMemberAndNotHaveTimeshiftTicket', + 'notHavePayTicket', + 'notActivatedBySerial', + 'notHavePayTicketAndNotActivatedBySerial', + 'notUseTimeshiftTicket', + 'notUseTimeshiftTicketOnOnceTimeshift', + 'notUseTimeshiftTicketOnUnlimitedTimeshift', + ] for x in rejected_reasons)) return live_status, availability From feaefd8ec64b62b7072a7f1c3da8be873f36fcc6 Mon Sep 17 00:00:00 2001 From: Mozi <29089388+pzhlkj6612@users.noreply.github.com> Date: Tue, 20 Aug 2024 07:57:44 +0000 Subject: [PATCH 13/16] pythonic way to get the format_index --- yt_dlp/downloader/niconico.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/yt_dlp/downloader/niconico.py b/yt_dlp/downloader/niconico.py index 265f6b2e30..17a8f8f91b 100644 --- a/yt_dlp/downloader/niconico.py +++ b/yt_dlp/downloader/niconico.py @@ -181,11 +181,7 @@ class NiconicoLiveFD(FragmentFD): ie = NiconicoIE(self.ydl) video_id = info_dict['id'] - - # Get format index - for format_index, fmt in enumerate(info_dict['formats']): - if fmt['format_id'] == info_dict['format_id']: - break + format_index = next((i for i, fmt in enumerate(info_dict['formats']) if fmt['format_id'] == info_dict['format_id'])) # Get video info total_duration = 0 From e720e8879daaebd368a7aad1708397d5e36732e0 Mon Sep 17 00:00:00 2001 From: Mozi <29089388+pzhlkj6612@users.noreply.github.com> Date: Tue, 20 Aug 2024 15:56:03 +0000 Subject: [PATCH 14/16] do not rename protocol; add conditions to FFmpegFixupM3u8PP --- yt_dlp/YoutubeDL.py | 3 ++- yt_dlp/downloader/__init__.py | 2 +- yt_dlp/extractor/niconico.py | 2 +- yt_dlp/postprocessor/ffmpeg.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py index 9691a1ea7c..a571f4ede3 100644 --- a/yt_dlp/YoutubeDL.py +++ b/yt_dlp/YoutubeDL.py @@ -3546,7 +3546,8 @@ class YoutubeDL: 'writing DASH m4a. Only some players support this container', FFmpegFixupM4aPP) ffmpeg_fixup(downloader == 'hlsnative' and not self.params.get('hls_use_mpegts') - or info_dict.get('is_live') and self.params.get('hls_use_mpegts') is None, + or info_dict.get('is_live') and self.params.get('hls_use_mpegts') is None + or downloader == 'niconico_live', 'Possible MPEG-TS in MP4 container or malformed AAC timestamps', FFmpegFixupM3u8PP) ffmpeg_fixup(downloader == 'dashsegments' diff --git a/yt_dlp/downloader/__init__.py b/yt_dlp/downloader/__init__.py index 9f6385058c..51a9f28f06 100644 --- a/yt_dlp/downloader/__init__.py +++ b/yt_dlp/downloader/__init__.py @@ -50,7 +50,7 @@ PROTOCOL_MAP = { 'ism': IsmFD, 'mhtml': MhtmlFD, 'niconico_dmc': NiconicoDmcFD, - 'm3u8_niconico_live': NiconicoLiveFD, + 'niconico_live': NiconicoLiveFD, 'fc2_live': FC2LiveFD, 'websocket_frag': WebSocketFragmentFD, 'youtube_live_chat': YoutubeLiveChatFD, diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py index 9649329327..11d23f5af1 100644 --- a/yt_dlp/extractor/niconico.py +++ b/yt_dlp/extractor/niconico.py @@ -1007,7 +1007,7 @@ class NiconicoLiveIE(NiconicoBaseIE): for fmt, q in zip(formats, reversed(qualities[1:])): fmt.update({ 'format_id': q, - 'protocol': 'm3u8_niconico_live', + 'protocol': 'niconico_live', }) yield fmt diff --git a/yt_dlp/postprocessor/ffmpeg.py b/yt_dlp/postprocessor/ffmpeg.py index 164c46d143..422aab8242 100644 --- a/yt_dlp/postprocessor/ffmpeg.py +++ b/yt_dlp/postprocessor/ffmpeg.py @@ -888,7 +888,7 @@ class FFmpegFixupM4aPP(FFmpegFixupPostProcessor): class FFmpegFixupM3u8PP(FFmpegFixupPostProcessor): def _needs_fixup(self, info): yield info['ext'] in ('mp4', 'm4a') - yield info['protocol'].startswith('m3u8') + yield info['protocol'].startswith('m3u8') or info['protocol'] == 'niconico_live' try: metadata = self.get_metadata_object(info['filepath']) except PostProcessingError as e: From 06bd726ab312008d3d54f5ac214c0180975e0ad6 Mon Sep 17 00:00:00 2001 From: Mozi <29089388+pzhlkj6612@users.noreply.github.com> Date: Sat, 19 Oct 2024 17:59:18 +0000 Subject: [PATCH 15/16] Fix formats sorting; simplify m3u8 extraction in downloader; clean code --- yt_dlp/downloader/niconico.py | 28 ++++++++---------- yt_dlp/extractor/niconico.py | 56 +++++++++++++++++------------------ 2 files changed, 39 insertions(+), 45 deletions(-) diff --git a/yt_dlp/downloader/niconico.py b/yt_dlp/downloader/niconico.py index 17a8f8f91b..e8b0d53423 100644 --- a/yt_dlp/downloader/niconico.py +++ b/yt_dlp/downloader/niconico.py @@ -15,7 +15,6 @@ from ..utils import ( RetryManager, str_or_none, traverse_obj, - try_get, urljoin, ) @@ -78,6 +77,7 @@ class NiconicoLiveFD(FragmentFD): """ Hold a WebSocket object and release it when leaving """ video_id = info_dict['id'] + format_id = info_dict['format_id'] live_latency = info_dict['downloader_options']['live_latency'] ws_url = info_dict['downloader_options']['ws_url'] @@ -89,12 +89,12 @@ class NiconicoLiveFD(FragmentFD): def communicate_ws(): self.ws = self.ydl.urlopen(Request(ws_url, headers=info_dict.get('http_headers'))) if self.ydl.params.get('verbose', False): - self.to_screen('[debug] Sending startWatching request') + self.write_debug('Sending HLS server request') self.ws.send(json.dumps({ 'type': 'startWatching', 'data': { 'stream': { - 'quality': 'abr', + 'quality': format_id, 'protocol': 'hls', 'latency': live_latency, 'chasePlay': False, @@ -103,7 +103,6 @@ class NiconicoLiveFD(FragmentFD): 'protocol': 'webSocket', 'commentable': True, }, - 'reconnect': True, }, })) with self.ws: @@ -112,7 +111,7 @@ class NiconicoLiveFD(FragmentFD): if not recv: continue data = json.loads(recv) - if not data or not isinstance(data, dict): + if not isinstance(data, dict): continue if data.get('type') == 'ping': # pong back @@ -126,12 +125,12 @@ class NiconicoLiveFD(FragmentFD): return elif data.get('type') == 'error': self.write_debug(data) - message = try_get(data, lambda x: x['body']['code'], str) or recv + message = traverse_obj(data, ('data', 'code')) or recv raise DownloadError(message) elif self.ydl.params.get('verbose', False): if len(recv) > 100: recv = recv[:100] + '...' - self.to_screen(f'[debug] Server said: {recv}') + self.write_debug(f'Server said: {recv}') stopped = threading.Event() @@ -146,7 +145,8 @@ class NiconicoLiveFD(FragmentFD): self.m3u8_lock.clear() # m3u8 url may be changed - self.to_screen('[{}] {}: Connection error occured, reconnecting after {} seconds: {}'.format('niconico:live', video_id, self._WEBSOCKET_RECONNECT_DELAY, str_or_none(e))) + self.to_screen('[{}] {}: Connection error occured, reconnecting after {} seconds: {}'.format( + 'niconico:live', video_id, self._WEBSOCKET_RECONNECT_DELAY, str_or_none(e))) time.sleep(self._WEBSOCKET_RECONNECT_DELAY) self.m3u8_lock.set() # Release possible locks @@ -181,7 +181,6 @@ class NiconicoLiveFD(FragmentFD): ie = NiconicoIE(self.ydl) video_id = info_dict['id'] - format_index = next((i for i, fmt in enumerate(info_dict['formats']) if fmt['format_id'] == info_dict['format_id'])) # Get video info total_duration = 0 @@ -209,13 +208,10 @@ class NiconicoLiveFD(FragmentFD): retry_manager = RetryManager(self.params.get('fragment_retries'), self.report_retry) for retry in retry_manager: try: - # Refresh master m3u8 (if possible) and get the url of the previously-chose format - master_m3u8_url = ws_context._master_m3u8_url() - formats = ie._extract_m3u8_formats( - master_m3u8_url, video_id, query={'start': downloaded_duration}, live=False, note=False, fatal=False) - media_m3u8_url = traverse_obj(formats, (format_index, {dict}, 'url'), get_all=False) - if not media_m3u8_url: - raise DownloadError('Unable to get playlist') + # Refresh master m3u8 (if possible) to get the new URL of the previously-chose format + media_m3u8_url = ie._extract_m3u8_formats( + ws_context._master_m3u8_url(), video_id, note=False, + query={'start': downloaded_duration}, live=False)[0]['url'] # Get all fragments media_m3u8 = ie._download_webpage( diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py index f39d4d3ba6..61beb4515d 100644 --- a/yt_dlp/extractor/niconico.py +++ b/yt_dlp/extractor/niconico.py @@ -7,7 +7,6 @@ import time import urllib.parse from .common import InfoExtractor, SearchInfoExtractor -from ..networking import Request from ..networking.exceptions import HTTPError from ..utils import ( ExtractorError, @@ -957,7 +956,7 @@ class NiconicoLiveIE(NiconicoBaseIE): def _yield_formats(self, ws_url, headers, latency, video_id, is_live): ws = self._request_webpage( - Request(ws_url, headers=headers), video_id, note='Connecting to WebSocket server') + ws_url, video_id, note='Connecting to WebSocket server', headers=headers) self.write_debug('Sending HLS server request') ws.send(json.dumps({ @@ -973,37 +972,36 @@ class NiconicoLiveIE(NiconicoBaseIE): 'protocol': 'webSocket', 'commentable': True, }, - 'reconnect': False, }, })) - while True: - recv = ws.recv() - if not recv: - continue - data = json.loads(recv) - if not isinstance(data, dict): - continue - if data.get('type') == 'stream': - m3u8_url = data['data']['uri'] - qualities = data['data']['availableQualities'] - break - elif data.get('type') == 'disconnect': - self.write_debug(recv) - raise ExtractorError('Disconnected at middle of extraction') - elif data.get('type') == 'error': - self.write_debug(recv) - message = traverse_obj(data, ('body', 'code')) or recv - raise ExtractorError(message) - elif self.get_param('verbose', False): - if len(recv) > 100: - recv = recv[:100] + '...' - self.write_debug(f'Server said: {recv}') + with ws: + while True: + recv = ws.recv() + if not recv: + continue + data = json.loads(recv) + if not isinstance(data, dict): + continue + if data.get('type') == 'stream': + m3u8_url = data['data']['uri'] + qualities = data['data']['availableQualities'] + break + elif data.get('type') == 'disconnect': + self.write_debug(data) + raise ExtractorError('Disconnected at middle of extraction') + elif data.get('type') == 'error': + self.write_debug(data) + message = traverse_obj(data, ('data', 'code')) or recv + raise ExtractorError(message) + elif self.get_param('verbose', False): + if len(recv) > 100: + recv = recv[:100] + '...' + self.write_debug(f'Server said: {recv}') - ws.close() - - formats = self._extract_m3u8_formats(m3u8_url, video_id, ext='mp4', live=is_live) - for fmt, q in zip(formats, reversed(qualities[1:])): + formats = sorted(self._extract_m3u8_formats( + m3u8_url, video_id, ext='mp4', live=is_live), key=lambda f: f['tbr'], reverse=True) + for fmt, q in zip(formats, qualities[1:]): fmt.update({ 'format_id': q, 'protocol': 'niconico_live', From 3095d815c9c42641a7a211a2982d40b6fea4d4bd Mon Sep 17 00:00:00 2001 From: Mozi <29089388+pzhlkj6612@users.noreply.github.com> Date: Sat, 19 Oct 2024 18:22:53 +0000 Subject: [PATCH 16/16] no all() for both things --- yt_dlp/downloader/niconico.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yt_dlp/downloader/niconico.py b/yt_dlp/downloader/niconico.py index e8b0d53423..62bed646f3 100644 --- a/yt_dlp/downloader/niconico.py +++ b/yt_dlp/downloader/niconico.py @@ -190,7 +190,7 @@ class NiconicoLiveFD(FragmentFD): total_duration = int(float(line.split(':')[1])) if '#EXT-X-TARGETDURATION' in line: fragment_duration = int(line.split(':')[1]) - if not all({total_duration, fragment_duration}): + if not (total_duration and fragment_duration): raise DownloadError('Unable to get required video info') ctx = {