From d4c52a28af8245eb3e37746b7e5df4ce7c30bb3b Mon Sep 17 00:00:00 2001 From: Lesmiscore Date: Sat, 11 Jun 2022 16:08:51 +0900 Subject: [PATCH 01/11] Fixup frame timestamp in MP4 file without ffmpeg --- test/test_mp4parser.py | 51 +++++++++++ yt_dlp/YoutubeDL.py | 4 + yt_dlp/downloader/__init__.py | 5 +- yt_dlp/downloader/websocket.py | 91 +++++++++++++++----- yt_dlp/extractor/twitcasting.py | 1 + yt_dlp/mp4_parser.py | 136 ++++++++++++++++++++++++++++++ yt_dlp/postprocessor/__init__.py | 1 + yt_dlp/postprocessor/mp4direct.py | 126 +++++++++++++++++++++++++++ 8 files changed, 395 insertions(+), 20 deletions(-) create mode 100644 test/test_mp4parser.py create mode 100644 yt_dlp/mp4_parser.py create mode 100644 yt_dlp/postprocessor/mp4direct.py diff --git a/test/test_mp4parser.py b/test/test_mp4parser.py new file mode 100644 index 000000000..f44f903da --- /dev/null +++ b/test/test_mp4parser.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python + +# Allow direct execution +import os +import sys +import unittest +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import io + +from yt_dlp.mp4_parser import ( + parse_mp4_boxes, + write_mp4_boxes, +) + +TEST_SEQUENCE = [ + ('test', b'123456'), + ('trak', b''), + ('helo', b'abcdef'), + ('1984', b'1q84'), + ('moov', b''), + ('keys', b'2022'), + (None, 'moov'), + ('topp', b'1991'), + (None, 'trak'), +] + +# on-file reprensetation of the above sequence +TEST_BYTES = b'\x00\x00\x00\x0etest123456\x00\x00\x00Btrak\x00\x00\x00\x0eheloabcdef\x00\x00\x00\x0c19841q84\x00\x00\x00\x14moov\x00\x00\x00\x0ckeys2022\x00\x00\x00\x0ctopp1991' + + +class TestMP4Parser(unittest.TestCase): + def test_write_sequence(self): + with io.BytesIO() as w: + write_mp4_boxes(w, TEST_SEQUENCE) + bs = w.getvalue() + self.assertEqual(TEST_BYTES, bs) + + def test_read_bytes(self): + with io.BytesIO(TEST_BYTES) as r: + result = list(parse_mp4_boxes(r)) + self.assertListEqual(TEST_SEQUENCE, result) + + def test_mismatched_box_end(self): + with io.BytesIO() as w, self.assertRaises(AssertionError): + write_mp4_boxes(w, [ + ('moov', b''), + ('trak', b''), + (None, 'moov'), + (None, 'trak'), + ]) diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py index bf62f2820..dd9199071 100644 --- a/yt_dlp/YoutubeDL.py +++ b/yt_dlp/YoutubeDL.py @@ -55,6 +55,7 @@ from .postprocessor import ( FFmpegMergerPP, FFmpegPostProcessor, MoveFilesAfterDownloadPP, + MP4FixupTimestampPP, get_postprocessor, ) from .update import detect_variant @@ -3256,8 +3257,11 @@ class YoutubeDL: ffmpeg_fixup(info_dict.get('is_live') and downloader == 'DashSegmentsFD', 'Possible duplicate MOOV atoms', FFmpegFixupDuplicateMoovPP) + is_fmp4 = info_dict.get('protocol') == 'websocket_frag' and info_dict.get('container') == 'fmp4' ffmpeg_fixup(downloader == 'web_socket_fragment', 'Malformed timestamps detected', FFmpegFixupTimestampPP) ffmpeg_fixup(downloader == 'web_socket_fragment', 'Malformed duration detected', FFmpegFixupDurationPP) + ffmpeg_fixup(downloader == 'web_socket_to_file' and is_fmp4, 'Malformed timestamps detected', MP4FixupTimestampPP) + ffmpeg_fixup(downloader == 'web_socket_to_file' and is_fmp4, 'Possible duplicate MOOV atoms', FFmpegFixupDuplicateMoovPP) fixup() try: diff --git a/yt_dlp/downloader/__init__.py b/yt_dlp/downloader/__init__.py index a7dc6c9d0..ab41bfbde 100644 --- a/yt_dlp/downloader/__init__.py +++ b/yt_dlp/downloader/__init__.py @@ -33,7 +33,7 @@ from .mhtml import MhtmlFD from .niconico import NiconicoDmcFD from .rtmp import RtmpFD from .rtsp import RtspFD -from .websocket import WebSocketFragmentFD +from .websocket import WebSocketFragmentFD, WebSocketToFileFD from .youtube_live_chat import YoutubeLiveChatFD PROTOCOL_MAP = { @@ -118,6 +118,9 @@ def _get_suitable_downloader(info_dict, protocol, params, default): elif params.get('hls_prefer_native') is False: return FFmpegFD + if protocol == 'websocket_frag' and info_dict.get('container') == 'fmp4' and external_downloader != 'ffmpeg': + return WebSocketToFileFD + return PROTOCOL_MAP.get(protocol, default) diff --git a/yt_dlp/downloader/websocket.py b/yt_dlp/downloader/websocket.py index 727a15828..b44f2be4f 100644 --- a/yt_dlp/downloader/websocket.py +++ b/yt_dlp/downloader/websocket.py @@ -1,7 +1,6 @@ import contextlib -import os -import signal import threading +import time from .common import FileDownloader from .external import FFmpegFD @@ -9,23 +8,29 @@ from ..compat import asyncio from ..dependencies import websockets -class FFmpegSinkFD(FileDownloader): +class AsyncSinkFD(FileDownloader): + async def connect(self, stdin, info_dict): + try: + await self.real_connection(stdin, info_dict) + except OSError: + pass + finally: + with contextlib.suppress(OSError): + stdin.flush() + stdin.close() + + async def real_connection(self, sink, info_dict): + """ Override this in subclasses """ + raise NotImplementedError('This method must be implemented by subclasses') + + +class FFmpegSinkFD(AsyncSinkFD): """ A sink to ffmpeg for downloading fragments in any form """ def real_download(self, filename, info_dict): info_copy = info_dict.copy() info_copy['url'] = '-' - - async def call_conn(proc, stdin): - try: - await self.real_connection(stdin, info_dict) - except OSError: - pass - finally: - with contextlib.suppress(OSError): - stdin.flush() - stdin.close() - os.kill(os.getpid(), signal.SIGINT) + connect = self.connect class FFmpegStdinFD(FFmpegFD): @classmethod @@ -33,17 +38,57 @@ class FFmpegSinkFD(FileDownloader): return FFmpegFD.get_basename() def on_process_started(self, proc, stdin): - thread = threading.Thread(target=asyncio.run, daemon=True, args=(call_conn(proc, stdin), )) + thread = threading.Thread(target=asyncio.run, daemon=True, args=(connect(stdin, info_dict), )) thread.start() return FFmpegStdinFD(self.ydl, self.params or {}).download(filename, info_copy) - async def real_connection(self, sink, info_dict): - """ Override this in subclasses """ - raise NotImplementedError('This method must be implemented by subclasses') +class FileSinkFD(AsyncSinkFD): + """ A sink to a file for downloading fragments in any form """ + def real_download(self, filename, info_dict): + tempname = self.temp_name(filename) + try: + with open(tempname, 'wb') as w: + started = time.time() + status = { + 'filename': info_dict.get('_filename'), + 'status': 'downloading', + 'elapsed': 0, + 'downloaded_bytes': 0, + } + self._hook_progress(status, info_dict) + + thread = threading.Thread(target=asyncio.run, daemon=True, args=(self.connect(w, info_dict), )) + thread.start() + time_and_size, avg_len = [], 10 + while thread.is_alive(): + time.sleep(0.1) + + downloaded, curr = w.tell(), time.time() + # taken from ffmpeg attachment + time_and_size.append((downloaded, curr)) + time_and_size = time_and_size[-avg_len:] + if len(time_and_size) > 1: + last, early = time_and_size[0], time_and_size[-1] + average_speed = (early[0] - last[0]) / (early[1] - last[1]) + else: + average_speed = None + + status.update({ + 'downloaded_bytes': downloaded, + 'speed': average_speed, + 'elapsed': curr - started, + }) + self._hook_progress(status, info_dict) + except KeyboardInterrupt: + pass + finally: + self.ydl.replace(tempname, filename) + return True -class WebSocketFragmentFD(FFmpegSinkFD): + +class _WebSocketFD(AsyncSinkFD): async def real_connection(self, sink, info_dict): async with websockets.connect(info_dict['url'], extra_headers=info_dict.get('http_headers', {})) as ws: while True: @@ -51,3 +96,11 @@ class WebSocketFragmentFD(FFmpegSinkFD): if isinstance(recv, str): recv = recv.encode('utf8') sink.write(recv) + + +class WebSocketFragmentFD(_WebSocketFD, FFmpegSinkFD): + pass + + +class WebSocketToFileFD(_WebSocketFD, FileSinkFD): + pass diff --git a/yt_dlp/extractor/twitcasting.py b/yt_dlp/extractor/twitcasting.py index 0dbb97a36..23e11207e 100644 --- a/yt_dlp/extractor/twitcasting.py +++ b/yt_dlp/extractor/twitcasting.py @@ -173,6 +173,7 @@ class TwitCastingIE(InfoExtractor): 'source_preference': -10, # TwitCasting simply sends moof atom directly over WS 'protocol': 'websocket_frag', + 'container': 'fmp4', }) self._sort_formats(formats, ('source',)) diff --git a/yt_dlp/mp4_parser.py b/yt_dlp/mp4_parser.py new file mode 100644 index 000000000..b0baafb4a --- /dev/null +++ b/yt_dlp/mp4_parser.py @@ -0,0 +1,136 @@ +import struct + +from typing import Tuple +from io import BytesIO, RawIOBase + + +class LengthLimiter(RawIOBase): + def __init__(self, r: RawIOBase, size: int): + self.r = r + self.remaining = size + + def read(self, sz: int = None) -> bytes: + if self.remaining == 0: + return b'' + if sz in (-1, None): + sz = self.remaining + sz = min(sz, self.remaining) + ret = self.r.read(sz) + if ret: + self.remaining -= len(ret) + return ret + + def readall(self) -> bytes: + if self.remaining == 0: + return b'' + ret = self.read(self.remaining) + if ret: + self.remaining -= len(ret) + return ret + + def readable(self) -> bool: + return bool(self.remaining) + + +def read_harder(r, size): + retry = 0 + buf = b'' + while len(buf) < size and retry < 3: + ret = r.read(size - len(buf)) + if not ret: + retry += 1 + continue + retry = 0 + buf += ret + + return buf + + +def pack_be32(value: int) -> bytes: + return struct.pack('>I', value) + + +def pack_be64(value: int) -> bytes: + return struct.pack('>L', value) + + +def unpack_be32(value: bytes) -> int: + return struct.unpack('>I', value)[0] + + +def unpack_ver_flags(value: bytes) -> Tuple[int, int]: + ver, up_flag, down_flag = struct.unpack('>BBH', value) + return ver, (up_flag << 16 | down_flag) + + +def unpack_be64(value: bytes) -> int: + return struct.unpack('>L', value)[0] + + +# https://github.com/gpac/mp4box.js/blob/4e1bc23724d2603754971abc00c2bd5aede7be60/src/box.js#L13-L40 +MP4_CONTAINER_BOXES = ('moov', 'trak', 'edts', 'mdia', 'minf', 'dinf', 'stbl', 'mvex', 'moof', 'traf', 'vttc', 'tref', 'iref', 'mfra', 'meco', 'hnti', 'hinf', 'strk', 'strd', 'sinf', 'rinf', 'schi', 'trgr', 'udta', 'iprp', 'ipco') + + +def parse_mp4_boxes(r: RawIOBase): + """ + Parses an ISO BMFF (which MP4 follows) and yields its boxes as a sequence. + This does not interpret content of these boxes. + + Sequence details: + ('atom', b'blablabla'): A box, with content (not container boxes) + ('atom', b''): Possibly container box (must check MP4_CONTAINER_BOXES) or really an empty box + (None, 'atom'): End of a container box + + Example: Path: + ('test', b'123456') /test + ('box1', b'') /box1 (start of container box) + ('helo', b'abcdef') /box1/helo + ('1984', b'1q84') /box1/1984 + ('http', b'') /box1/http (start of container box) + ('keys', b'2022') /box1/http/keys + (None , 'http') /box1/http (end of container box) + ('topp', b'1991') /box1/topp + (None , 'box1') /box1 (end of container box) + """ + + while True: + size_b = read_harder(r, 4) + if not size_b: + break + type_b = r.read(4) + # 00 00 00 20 is big-endian + box_size = unpack_be32(size_b) + type_s = type_b.decode() + if type_s in MP4_CONTAINER_BOXES: + yield (type_s, b'') + yield from parse_mp4_boxes(LengthLimiter(r, box_size - 8)) + yield (None, type_s) + continue + # subtract by 8 + full_body = read_harder(r, box_size - 8) + yield (type_s, full_body) + + +def write_mp4_boxes(w: RawIOBase, box_iter): + """ + Writes an ISO BMFF file from a given sequence to a given writer. + The iterator to be passed must follow parse_mp4_boxes's protocol. + """ + + stack = [ + (None, w), # parent box, IO + ] + for btype, content in box_iter: + if btype in MP4_CONTAINER_BOXES: + bio = BytesIO() + stack.append((btype, bio)) + continue + elif btype is None: + assert stack[-1][0] == content + btype, bio = stack.pop() + content = bio.getvalue() + + wt = stack[-1][1] + wt.write(pack_be32(len(content) + 8)) + wt.write(btype.encode()[:4]) + wt.write(content) diff --git a/yt_dlp/postprocessor/__init__.py b/yt_dlp/postprocessor/__init__.py index f168be46a..9e3657c93 100644 --- a/yt_dlp/postprocessor/__init__.py +++ b/yt_dlp/postprocessor/__init__.py @@ -30,6 +30,7 @@ from .metadataparser import ( ) from .modify_chapters import ModifyChaptersPP from .movefilesafterdownload import MoveFilesAfterDownloadPP +from .mp4direct import MP4FixupTimestampPP from .sponskrub import SponSkrubPP from .sponsorblock import SponsorBlockPP from .xattrpp import XAttrMetadataPP diff --git a/yt_dlp/postprocessor/mp4direct.py b/yt_dlp/postprocessor/mp4direct.py new file mode 100644 index 000000000..d4b51618a --- /dev/null +++ b/yt_dlp/postprocessor/mp4direct.py @@ -0,0 +1,126 @@ +from .common import PostProcessor +from ..utils import prepend_extension + +from ..mp4_parser import ( + write_mp4_boxes, + parse_mp4_boxes, + pack_be32, + pack_be64, + unpack_ver_flags, + unpack_be32, + unpack_be64, +) + + +class MP4FixupTimestampPP(PostProcessor): + + @property + def available(self): + return True + + def analyze_mp4(self, filepath): + """ returns (baseMediaDecodeTime offset, sample duration cutoff) """ + smallest_bmdt, known_sdur = float('inf'), set() + with open(filepath, 'rb') as r: + for btype, content in parse_mp4_boxes(r): + if btype == 'tfdt': + version, _ = unpack_ver_flags(content[0:4]) + # baseMediaDecodeTime always comes to the first + if version == 0: + bmdt = unpack_be32(content[4:8]) + else: + bmdt = unpack_be64(content[4:12]) + if bmdt == 0: + continue + smallest_bmdt = min(bmdt, smallest_bmdt) + elif btype == 'tfhd': + version, flags = unpack_ver_flags(content[0:4]) + if not flags & 0x08: + # this box does not contain "sample duration" + continue + # https://github.com/gpac/mp4box.js/blob/4e1bc23724d2603754971abc00c2bd5aede7be60/src/box.js#L203-L209 + # https://github.com/gpac/mp4box.js/blob/4e1bc23724d2603754971abc00c2bd5aede7be60/src/parsing/tfhd.js + sdur_start = 8 # header + track id + if flags & 0x01: + sdur_start += 8 + if flags & 0x02: + sdur_start += 4 + # the next 4 bytes are "sample duration" + sample_dur = unpack_be32(content[sdur_start:sdur_start + 4]) + known_sdur.add(sample_dur) + + maximum_sdur = max(known_sdur) + for multiplier in (0.7, 0.8, 0.9, 0.95): + sdur_cutoff = maximum_sdur * multiplier + if len(set(x for x in known_sdur if x > sdur_cutoff)) < 3: + break + else: + sdur_cutoff = float('inf') + + return smallest_bmdt, sdur_cutoff + + def modify_mp4(self, src, dst, bmdt_offset, sdur_cutoff): + with open(src, 'rb') as r, open(dst, 'wb') as w: + def converter(): + for btype, content in parse_mp4_boxes(r): + if btype == 'tfdt': + version, _ = unpack_ver_flags(content[0:4]) + if version == 0: + bmdt = unpack_be32(content[4:8]) + else: + bmdt = unpack_be64(content[4:12]) + if bmdt == 0: + yield (btype, content) + continue + # calculate new baseMediaDecodeTime + bmdt = max(0, bmdt - bmdt_offset) + # pack everything again and insert as a new box + if version == 0: + bmdt_b = pack_be32(bmdt) + else: + bmdt_b = pack_be64(bmdt) + yield ('tfdt', content[0:4] + bmdt_b + content[8 + version * 4:]) + continue + elif btype == 'tfhd': + version, flags = unpack_ver_flags(content[0:4]) + if not flags & 0x08: + yield (btype, content) + continue + sdur_start = 8 + if flags & 0x01: + sdur_start += 8 + if flags & 0x02: + sdur_start += 4 + sample_dur = unpack_be32(content[sdur_start:sdur_start + 4]) + if sample_dur > sdur_cutoff: + sample_dur = 0 + sd_b = pack_be32(sample_dur) + yield ('tfhd', content[:sdur_start] + sd_b + content[sdur_start + 4:]) + continue + yield (btype, content) + + write_mp4_boxes(w, converter()) + + def run(self, information): + filename = information['filepath'] + temp_filename = prepend_extension(filename, 'temp') + + self.write_debug('Analyzing MP4') + bmdt_offset, sdur_cutoff = self.analyze_mp4(filename) + working = float('inf') not in (bmdt_offset, sdur_cutoff) + # if any of them are Infinity, there's something wrong + # baseMediaDecodeTime = to shift PTS + # sample duration = to define duration in each segment + self.write_debug(f'baseMediaDecodeTime offset = {bmdt_offset}, sample duration cutoff = {sdur_cutoff}') + if bmdt_offset == float('inf'): + # safeguard + bmdt_offset = 0 + self.modify_mp4(filename, temp_filename, bmdt_offset, sdur_cutoff) + if working: + self.to_screen('Duration of the file has been fixed') + else: + self.report_warning(f'Failed to fix duration of the file. (baseMediaDecodeTime offset = {bmdt_offset}, sample duration cutoff = {sdur_cutoff})') + + self._downloader.replace(temp_filename, filename) + + return [], information From 3fac07c04ae488730073b1d9a24bd70bfb0eadc7 Mon Sep 17 00:00:00 2001 From: Lesmiscore Date: Sat, 11 Jun 2022 16:13:21 +0900 Subject: [PATCH 02/11] use constant from math module --- yt_dlp/postprocessor/mp4direct.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/yt_dlp/postprocessor/mp4direct.py b/yt_dlp/postprocessor/mp4direct.py index d4b51618a..b52a9c6cb 100644 --- a/yt_dlp/postprocessor/mp4direct.py +++ b/yt_dlp/postprocessor/mp4direct.py @@ -1,3 +1,5 @@ +from math import inf + from .common import PostProcessor from ..utils import prepend_extension @@ -20,7 +22,7 @@ class MP4FixupTimestampPP(PostProcessor): def analyze_mp4(self, filepath): """ returns (baseMediaDecodeTime offset, sample duration cutoff) """ - smallest_bmdt, known_sdur = float('inf'), set() + smallest_bmdt, known_sdur = inf, set() with open(filepath, 'rb') as r: for btype, content in parse_mp4_boxes(r): if btype == 'tfdt': @@ -55,7 +57,7 @@ class MP4FixupTimestampPP(PostProcessor): if len(set(x for x in known_sdur if x > sdur_cutoff)) < 3: break else: - sdur_cutoff = float('inf') + sdur_cutoff = inf return smallest_bmdt, sdur_cutoff @@ -107,12 +109,12 @@ class MP4FixupTimestampPP(PostProcessor): self.write_debug('Analyzing MP4') bmdt_offset, sdur_cutoff = self.analyze_mp4(filename) - working = float('inf') not in (bmdt_offset, sdur_cutoff) + working = inf not in (bmdt_offset, sdur_cutoff) # if any of them are Infinity, there's something wrong # baseMediaDecodeTime = to shift PTS # sample duration = to define duration in each segment self.write_debug(f'baseMediaDecodeTime offset = {bmdt_offset}, sample duration cutoff = {sdur_cutoff}') - if bmdt_offset == float('inf'): + if bmdt_offset == inf: # safeguard bmdt_offset = 0 self.modify_mp4(filename, temp_filename, bmdt_offset, sdur_cutoff) From 099db789358bfad6792af91a2d937b3f7ecce134 Mon Sep 17 00:00:00 2001 From: Lesmiscore Date: Sat, 11 Jun 2022 16:52:42 +0900 Subject: [PATCH 03/11] remove duplicated MOOV bpces --- yt_dlp/postprocessor/mp4direct.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/yt_dlp/postprocessor/mp4direct.py b/yt_dlp/postprocessor/mp4direct.py index b52a9c6cb..90b5500bc 100644 --- a/yt_dlp/postprocessor/mp4direct.py +++ b/yt_dlp/postprocessor/mp4direct.py @@ -24,7 +24,22 @@ class MP4FixupTimestampPP(PostProcessor): """ returns (baseMediaDecodeTime offset, sample duration cutoff) """ smallest_bmdt, known_sdur = inf, set() with open(filepath, 'rb') as r: + moov_over, in_secondary_moov = False, False for btype, content in parse_mp4_boxes(r): + # skip duplicate MOOV boxes + if btype == 'moov': + if moov_over: + in_secondary_moov = True + continue + elif btype is None and content == 'moov': + in_secondary_moov = False + + if moov_over: + continue + moov_over = True + elif in_secondary_moov: + continue + if btype == 'tfdt': version, _ = unpack_ver_flags(content[0:4]) # baseMediaDecodeTime always comes to the first From cc17902c096f69692d727ea4d75ee3b9068a0ca5 Mon Sep 17 00:00:00 2001 From: Lesmiscore Date: Sat, 18 Jun 2022 22:49:44 +0900 Subject: [PATCH 04/11] fix --- yt_dlp/postprocessor/mp4direct.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/yt_dlp/postprocessor/mp4direct.py b/yt_dlp/postprocessor/mp4direct.py index 90b5500bc..d304a7ed9 100644 --- a/yt_dlp/postprocessor/mp4direct.py +++ b/yt_dlp/postprocessor/mp4direct.py @@ -24,22 +24,7 @@ class MP4FixupTimestampPP(PostProcessor): """ returns (baseMediaDecodeTime offset, sample duration cutoff) """ smallest_bmdt, known_sdur = inf, set() with open(filepath, 'rb') as r: - moov_over, in_secondary_moov = False, False for btype, content in parse_mp4_boxes(r): - # skip duplicate MOOV boxes - if btype == 'moov': - if moov_over: - in_secondary_moov = True - continue - elif btype is None and content == 'moov': - in_secondary_moov = False - - if moov_over: - continue - moov_over = True - elif in_secondary_moov: - continue - if btype == 'tfdt': version, _ = unpack_ver_flags(content[0:4]) # baseMediaDecodeTime always comes to the first @@ -79,7 +64,21 @@ class MP4FixupTimestampPP(PostProcessor): def modify_mp4(self, src, dst, bmdt_offset, sdur_cutoff): with open(src, 'rb') as r, open(dst, 'wb') as w: def converter(): + moov_over, in_secondary_moov = False, False for btype, content in parse_mp4_boxes(r): + # skip duplicate MOOV boxes + if btype == 'moov': + if moov_over: + in_secondary_moov = True + continue + elif btype is None and content == 'moov': + in_secondary_moov = False + + if moov_over: + continue + moov_over = True + elif in_secondary_moov: + continue if btype == 'tfdt': version, _ = unpack_ver_flags(content[0:4]) if version == 0: From df63ae447730d94174a7742d9451e20b1720036b Mon Sep 17 00:00:00 2001 From: Lesmiscore Date: Thu, 14 Jul 2022 09:03:39 +0900 Subject: [PATCH 05/11] fix references and nesting --- yt_dlp/downloader/websocket.py | 3 +- yt_dlp/postprocessor/mp4direct.py | 98 ++++++++++++++----------------- 2 files changed, 46 insertions(+), 55 deletions(-) diff --git a/yt_dlp/downloader/websocket.py b/yt_dlp/downloader/websocket.py index b44f2be4f..9babf031b 100644 --- a/yt_dlp/downloader/websocket.py +++ b/yt_dlp/downloader/websocket.py @@ -1,4 +1,5 @@ import contextlib +import os import threading import time @@ -84,7 +85,7 @@ class FileSinkFD(AsyncSinkFD): except KeyboardInterrupt: pass finally: - self.ydl.replace(tempname, filename) + os.replace(tempname, filename) return True diff --git a/yt_dlp/postprocessor/mp4direct.py b/yt_dlp/postprocessor/mp4direct.py index d304a7ed9..cde516d28 100644 --- a/yt_dlp/postprocessor/mp4direct.py +++ b/yt_dlp/postprocessor/mp4direct.py @@ -1,3 +1,5 @@ +import os + from math import inf from .common import PostProcessor @@ -61,61 +63,49 @@ class MP4FixupTimestampPP(PostProcessor): return smallest_bmdt, sdur_cutoff - def modify_mp4(self, src, dst, bmdt_offset, sdur_cutoff): - with open(src, 'rb') as r, open(dst, 'wb') as w: - def converter(): - moov_over, in_secondary_moov = False, False - for btype, content in parse_mp4_boxes(r): - # skip duplicate MOOV boxes - if btype == 'moov': - if moov_over: - in_secondary_moov = True - continue - elif btype is None and content == 'moov': - in_secondary_moov = False - - if moov_over: - continue - moov_over = True - elif in_secondary_moov: - continue - if btype == 'tfdt': - version, _ = unpack_ver_flags(content[0:4]) - if version == 0: - bmdt = unpack_be32(content[4:8]) - else: - bmdt = unpack_be64(content[4:12]) - if bmdt == 0: - yield (btype, content) - continue - # calculate new baseMediaDecodeTime - bmdt = max(0, bmdt - bmdt_offset) - # pack everything again and insert as a new box - if version == 0: - bmdt_b = pack_be32(bmdt) - else: - bmdt_b = pack_be64(bmdt) - yield ('tfdt', content[0:4] + bmdt_b + content[8 + version * 4:]) - continue - elif btype == 'tfhd': - version, flags = unpack_ver_flags(content[0:4]) - if not flags & 0x08: - yield (btype, content) - continue - sdur_start = 8 - if flags & 0x01: - sdur_start += 8 - if flags & 0x02: - sdur_start += 4 - sample_dur = unpack_be32(content[sdur_start:sdur_start + 4]) - if sample_dur > sdur_cutoff: - sample_dur = 0 - sd_b = pack_be32(sample_dur) - yield ('tfhd', content[:sdur_start] + sd_b + content[sdur_start + 4:]) - continue + @staticmethod + def transform(r, bmdt_offset, sdur_cutoff): + for btype, content in r: + if btype == 'tfdt': + version, _ = unpack_ver_flags(content[0:4]) + if version == 0: + bmdt = unpack_be32(content[4:8]) + else: + bmdt = unpack_be64(content[4:12]) + if bmdt == 0: + yield (btype, content) + continue + # calculate new baseMediaDecodeTime + bmdt = max(0, bmdt - bmdt_offset) + # pack everything again and insert as a new box + if version == 0: + bmdt_b = pack_be32(bmdt) + else: + bmdt_b = pack_be64(bmdt) + yield ('tfdt', content[0:4] + bmdt_b + content[8 + version * 4:]) + continue + elif btype == 'tfhd': + version, flags = unpack_ver_flags(content[0:4]) + if not flags & 0x08: yield (btype, content) + continue + sdur_start = 8 + if flags & 0x01: + sdur_start += 8 + if flags & 0x02: + sdur_start += 4 + sample_dur = unpack_be32(content[sdur_start:sdur_start + 4]) + if sample_dur > sdur_cutoff: + sample_dur = 0 + sd_b = pack_be32(sample_dur) + yield ('tfhd', content[:sdur_start] + sd_b + content[sdur_start + 4:]) + continue + yield (btype, content) + - write_mp4_boxes(w, converter()) + def modify_mp4(self, src, dst, bmdt_offset, sdur_cutoff): + with open(src, 'rb') as r, open(dst, 'wb') as w: + write_mp4_boxes(w, self.transform(parse_mp4_boxes(r))) def run(self, information): filename = information['filepath'] @@ -137,6 +127,6 @@ class MP4FixupTimestampPP(PostProcessor): else: self.report_warning(f'Failed to fix duration of the file. (baseMediaDecodeTime offset = {bmdt_offset}, sample duration cutoff = {sdur_cutoff})') - self._downloader.replace(temp_filename, filename) + os.replace(temp_filename, filename) return [], information From 012993fa8d71a3c035595827eb75e3d16446ebad Mon Sep 17 00:00:00 2001 From: Lesmiscore Date: Thu, 14 Jul 2022 09:11:54 +0900 Subject: [PATCH 06/11] apply patch --- yt_dlp/downloader/websocket.py | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/yt_dlp/downloader/websocket.py b/yt_dlp/downloader/websocket.py index 9babf031b..9d42f36af 100644 --- a/yt_dlp/downloader/websocket.py +++ b/yt_dlp/downloader/websocket.py @@ -9,7 +9,7 @@ from ..compat import asyncio from ..dependencies import websockets -class AsyncSinkFD(FileDownloader): +class _WebSocketFD(FileDownloader): async def connect(self, stdin, info_dict): try: await self.real_connection(stdin, info_dict) @@ -21,11 +21,15 @@ class AsyncSinkFD(FileDownloader): stdin.close() async def real_connection(self, sink, info_dict): - """ Override this in subclasses """ - raise NotImplementedError('This method must be implemented by subclasses') + async with websockets.connect(info_dict['url'], extra_headers=info_dict.get('http_headers', {})) as ws: + while True: + recv = await ws.recv() + if isinstance(recv, str): + recv = recv.encode('utf8') + sink.write(recv) -class FFmpegSinkFD(AsyncSinkFD): +class WebSocketFragmentFD(_WebSocketFD): """ A sink to ffmpeg for downloading fragments in any form """ def real_download(self, filename, info_dict): @@ -45,7 +49,7 @@ class FFmpegSinkFD(AsyncSinkFD): return FFmpegStdinFD(self.ydl, self.params or {}).download(filename, info_copy) -class FileSinkFD(AsyncSinkFD): +class WebSocketToFileFD(_WebSocketFD): """ A sink to a file for downloading fragments in any form """ def real_download(self, filename, info_dict): tempname = self.temp_name(filename) @@ -87,21 +91,3 @@ class FileSinkFD(AsyncSinkFD): finally: os.replace(tempname, filename) return True - - -class _WebSocketFD(AsyncSinkFD): - async def real_connection(self, sink, info_dict): - async with websockets.connect(info_dict['url'], extra_headers=info_dict.get('http_headers', {})) as ws: - while True: - recv = await ws.recv() - if isinstance(recv, str): - recv = recv.encode('utf8') - sink.write(recv) - - -class WebSocketFragmentFD(_WebSocketFD, FFmpegSinkFD): - pass - - -class WebSocketToFileFD(_WebSocketFD, FileSinkFD): - pass From d2d940ef25b90567bc2ba7efcf316aefe6de18a0 Mon Sep 17 00:00:00 2001 From: Lesmiscore Date: Thu, 14 Jul 2022 09:21:25 +0900 Subject: [PATCH 07/11] add docstrings --- yt_dlp/mp4_parser.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/yt_dlp/mp4_parser.py b/yt_dlp/mp4_parser.py index b0baafb4a..08b516f94 100644 --- a/yt_dlp/mp4_parser.py +++ b/yt_dlp/mp4_parser.py @@ -5,6 +5,10 @@ from io import BytesIO, RawIOBase class LengthLimiter(RawIOBase): + """ + A bytes IO to limit length to be read. + """ + def __init__(self, r: RawIOBase, size: int): self.r = r self.remaining = size @@ -33,6 +37,13 @@ class LengthLimiter(RawIOBase): def read_harder(r, size): + """ + Try to read from the stream. + + @params r byte stream to read + @params size Number of bytes to read in total + """ + retry = 0 buf = b'' while len(buf) < size and retry < 3: @@ -47,29 +58,38 @@ def read_harder(r, size): def pack_be32(value: int) -> bytes: + """ Pack value to 4-byte-long bytes in the big-endian byte order """ return struct.pack('>I', value) def pack_be64(value: int) -> bytes: + """ Pack value to 8-byte-long bytes in the big-endian byte order """ return struct.pack('>L', value) def unpack_be32(value: bytes) -> int: + """ Convert 4-byte-long bytes in the big-endian byte order, to an integer value """ return struct.unpack('>I', value)[0] +def unpack_be64(value: bytes) -> int: + """ Convert 8-byte-long bytes in the big-endian byte order, to an integer value """ + return struct.unpack('>L', value)[0] + + def unpack_ver_flags(value: bytes) -> Tuple[int, int]: + """ + Unpack 4-byte-long value into version and flags. + @returns (version, flags) + """ + ver, up_flag, down_flag = struct.unpack('>BBH', value) return ver, (up_flag << 16 | down_flag) -def unpack_be64(value: bytes) -> int: - return struct.unpack('>L', value)[0] - - # https://github.com/gpac/mp4box.js/blob/4e1bc23724d2603754971abc00c2bd5aede7be60/src/box.js#L13-L40 MP4_CONTAINER_BOXES = ('moov', 'trak', 'edts', 'mdia', 'minf', 'dinf', 'stbl', 'mvex', 'moof', 'traf', 'vttc', 'tref', 'iref', 'mfra', 'meco', 'hnti', 'hinf', 'strk', 'strd', 'sinf', 'rinf', 'schi', 'trgr', 'udta', 'iprp', 'ipco') - +""" List of boxes that nests the other boxes """ def parse_mp4_boxes(r: RawIOBase): """ From cd6e20b68e331d2e07b27f729e701f274fceeaad Mon Sep 17 00:00:00 2001 From: Lesmiscore Date: Thu, 14 Jul 2022 09:26:52 +0900 Subject: [PATCH 08/11] linter --- yt_dlp/mp4_parser.py | 1 + yt_dlp/postprocessor/mp4direct.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/yt_dlp/mp4_parser.py b/yt_dlp/mp4_parser.py index 08b516f94..436080ba5 100644 --- a/yt_dlp/mp4_parser.py +++ b/yt_dlp/mp4_parser.py @@ -91,6 +91,7 @@ def unpack_ver_flags(value: bytes) -> Tuple[int, int]: MP4_CONTAINER_BOXES = ('moov', 'trak', 'edts', 'mdia', 'minf', 'dinf', 'stbl', 'mvex', 'moof', 'traf', 'vttc', 'tref', 'iref', 'mfra', 'meco', 'hnti', 'hinf', 'strk', 'strd', 'sinf', 'rinf', 'schi', 'trgr', 'udta', 'iprp', 'ipco') """ List of boxes that nests the other boxes """ + def parse_mp4_boxes(r: RawIOBase): """ Parses an ISO BMFF (which MP4 follows) and yields its boxes as a sequence. diff --git a/yt_dlp/postprocessor/mp4direct.py b/yt_dlp/postprocessor/mp4direct.py index cde516d28..506138ee3 100644 --- a/yt_dlp/postprocessor/mp4direct.py +++ b/yt_dlp/postprocessor/mp4direct.py @@ -102,7 +102,6 @@ class MP4FixupTimestampPP(PostProcessor): continue yield (btype, content) - def modify_mp4(self, src, dst, bmdt_offset, sdur_cutoff): with open(src, 'rb') as r, open(dst, 'wb') as w: write_mp4_boxes(w, self.transform(parse_mp4_boxes(r))) From 2ed0b5568ea0d719897c35b8faf09d12a66df2a7 Mon Sep 17 00:00:00 2001 From: Lesmiscore Date: Thu, 14 Jul 2022 09:38:57 +0900 Subject: [PATCH 09/11] fix example to use real boxes --- yt_dlp/mp4_parser.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/yt_dlp/mp4_parser.py b/yt_dlp/mp4_parser.py index 436080ba5..437be3354 100644 --- a/yt_dlp/mp4_parser.py +++ b/yt_dlp/mp4_parser.py @@ -104,14 +104,14 @@ def parse_mp4_boxes(r: RawIOBase): Example: Path: ('test', b'123456') /test - ('box1', b'') /box1 (start of container box) - ('helo', b'abcdef') /box1/helo - ('1984', b'1q84') /box1/1984 - ('http', b'') /box1/http (start of container box) - ('keys', b'2022') /box1/http/keys - (None , 'http') /box1/http (end of container box) - ('topp', b'1991') /box1/topp - (None , 'box1') /box1 (end of container box) + ('moov', b'') /moov (start of container box) + ('helo', b'abcdef') /moov/helo + ('1984', b'1q84') /moov/1984 + ('trak', b'') /moov/trak (start of container box) + ('keys', b'2022') /moov/trak/keys + (None , 'trak') /moov/trak (end of container box) + ('topp', b'1991') /moov/topp + (None , 'moov') /moov (end of container box) """ while True: From 302b23a9a370d6ecefd33f475a4d4f029be5329e Mon Sep 17 00:00:00 2001 From: Lesmiscore Date: Thu, 14 Jul 2022 12:15:43 +0900 Subject: [PATCH 10/11] mold mp4_parser into mp4direct --- yt_dlp/mp4_parser.py | 157 ---------------------------- yt_dlp/postprocessor/mp4direct.py | 164 ++++++++++++++++++++++++++++-- 2 files changed, 155 insertions(+), 166 deletions(-) delete mode 100644 yt_dlp/mp4_parser.py diff --git a/yt_dlp/mp4_parser.py b/yt_dlp/mp4_parser.py deleted file mode 100644 index 437be3354..000000000 --- a/yt_dlp/mp4_parser.py +++ /dev/null @@ -1,157 +0,0 @@ -import struct - -from typing import Tuple -from io import BytesIO, RawIOBase - - -class LengthLimiter(RawIOBase): - """ - A bytes IO to limit length to be read. - """ - - def __init__(self, r: RawIOBase, size: int): - self.r = r - self.remaining = size - - def read(self, sz: int = None) -> bytes: - if self.remaining == 0: - return b'' - if sz in (-1, None): - sz = self.remaining - sz = min(sz, self.remaining) - ret = self.r.read(sz) - if ret: - self.remaining -= len(ret) - return ret - - def readall(self) -> bytes: - if self.remaining == 0: - return b'' - ret = self.read(self.remaining) - if ret: - self.remaining -= len(ret) - return ret - - def readable(self) -> bool: - return bool(self.remaining) - - -def read_harder(r, size): - """ - Try to read from the stream. - - @params r byte stream to read - @params size Number of bytes to read in total - """ - - retry = 0 - buf = b'' - while len(buf) < size and retry < 3: - ret = r.read(size - len(buf)) - if not ret: - retry += 1 - continue - retry = 0 - buf += ret - - return buf - - -def pack_be32(value: int) -> bytes: - """ Pack value to 4-byte-long bytes in the big-endian byte order """ - return struct.pack('>I', value) - - -def pack_be64(value: int) -> bytes: - """ Pack value to 8-byte-long bytes in the big-endian byte order """ - return struct.pack('>L', value) - - -def unpack_be32(value: bytes) -> int: - """ Convert 4-byte-long bytes in the big-endian byte order, to an integer value """ - return struct.unpack('>I', value)[0] - - -def unpack_be64(value: bytes) -> int: - """ Convert 8-byte-long bytes in the big-endian byte order, to an integer value """ - return struct.unpack('>L', value)[0] - - -def unpack_ver_flags(value: bytes) -> Tuple[int, int]: - """ - Unpack 4-byte-long value into version and flags. - @returns (version, flags) - """ - - ver, up_flag, down_flag = struct.unpack('>BBH', value) - return ver, (up_flag << 16 | down_flag) - - -# https://github.com/gpac/mp4box.js/blob/4e1bc23724d2603754971abc00c2bd5aede7be60/src/box.js#L13-L40 -MP4_CONTAINER_BOXES = ('moov', 'trak', 'edts', 'mdia', 'minf', 'dinf', 'stbl', 'mvex', 'moof', 'traf', 'vttc', 'tref', 'iref', 'mfra', 'meco', 'hnti', 'hinf', 'strk', 'strd', 'sinf', 'rinf', 'schi', 'trgr', 'udta', 'iprp', 'ipco') -""" List of boxes that nests the other boxes """ - - -def parse_mp4_boxes(r: RawIOBase): - """ - Parses an ISO BMFF (which MP4 follows) and yields its boxes as a sequence. - This does not interpret content of these boxes. - - Sequence details: - ('atom', b'blablabla'): A box, with content (not container boxes) - ('atom', b''): Possibly container box (must check MP4_CONTAINER_BOXES) or really an empty box - (None, 'atom'): End of a container box - - Example: Path: - ('test', b'123456') /test - ('moov', b'') /moov (start of container box) - ('helo', b'abcdef') /moov/helo - ('1984', b'1q84') /moov/1984 - ('trak', b'') /moov/trak (start of container box) - ('keys', b'2022') /moov/trak/keys - (None , 'trak') /moov/trak (end of container box) - ('topp', b'1991') /moov/topp - (None , 'moov') /moov (end of container box) - """ - - while True: - size_b = read_harder(r, 4) - if not size_b: - break - type_b = r.read(4) - # 00 00 00 20 is big-endian - box_size = unpack_be32(size_b) - type_s = type_b.decode() - if type_s in MP4_CONTAINER_BOXES: - yield (type_s, b'') - yield from parse_mp4_boxes(LengthLimiter(r, box_size - 8)) - yield (None, type_s) - continue - # subtract by 8 - full_body = read_harder(r, box_size - 8) - yield (type_s, full_body) - - -def write_mp4_boxes(w: RawIOBase, box_iter): - """ - Writes an ISO BMFF file from a given sequence to a given writer. - The iterator to be passed must follow parse_mp4_boxes's protocol. - """ - - stack = [ - (None, w), # parent box, IO - ] - for btype, content in box_iter: - if btype in MP4_CONTAINER_BOXES: - bio = BytesIO() - stack.append((btype, bio)) - continue - elif btype is None: - assert stack[-1][0] == content - btype, bio = stack.pop() - content = bio.getvalue() - - wt = stack[-1][1] - wt.write(pack_be32(len(content) + 8)) - wt.write(btype.encode()[:4]) - wt.write(content) diff --git a/yt_dlp/postprocessor/mp4direct.py b/yt_dlp/postprocessor/mp4direct.py index 506138ee3..70e50907a 100644 --- a/yt_dlp/postprocessor/mp4direct.py +++ b/yt_dlp/postprocessor/mp4direct.py @@ -1,19 +1,165 @@ import os +import struct +from io import BytesIO, RawIOBase from math import inf +from typing import Tuple from .common import PostProcessor from ..utils import prepend_extension -from ..mp4_parser import ( - write_mp4_boxes, - parse_mp4_boxes, - pack_be32, - pack_be64, - unpack_ver_flags, - unpack_be32, - unpack_be64, -) + +class LengthLimiter(RawIOBase): + """ + A bytes IO to limit length to be read. + """ + + def __init__(self, r: RawIOBase, size: int): + self.r = r + self.remaining = size + + def read(self, sz: int = None) -> bytes: + if self.remaining == 0: + return b'' + if sz in (-1, None): + sz = self.remaining + sz = min(sz, self.remaining) + ret = self.r.read(sz) + if ret: + self.remaining -= len(ret) + return ret + + def readall(self) -> bytes: + if self.remaining == 0: + return b'' + ret = self.read(self.remaining) + if ret: + self.remaining -= len(ret) + return ret + + def readable(self) -> bool: + return bool(self.remaining) + + +def read_harder(r, size): + """ + Try to read from the stream. + + @params r byte stream to read + @params size Number of bytes to read in total + """ + + retry = 0 + buf = b'' + while len(buf) < size and retry < 3: + ret = r.read(size - len(buf)) + if not ret: + retry += 1 + continue + retry = 0 + buf += ret + + return buf + + +def pack_be32(value: int) -> bytes: + """ Pack value to 4-byte-long bytes in the big-endian byte order """ + return struct.pack('>I', value) + + +def pack_be64(value: int) -> bytes: + """ Pack value to 8-byte-long bytes in the big-endian byte order """ + return struct.pack('>L', value) + + +def unpack_be32(value: bytes) -> int: + """ Convert 4-byte-long bytes in the big-endian byte order, to an integer value """ + return struct.unpack('>I', value)[0] + + +def unpack_be64(value: bytes) -> int: + """ Convert 8-byte-long bytes in the big-endian byte order, to an integer value """ + return struct.unpack('>L', value)[0] + + +def unpack_ver_flags(value: bytes) -> Tuple[int, int]: + """ + Unpack 4-byte-long value into version and flags. + @returns (version, flags) + """ + + ver, up_flag, down_flag = struct.unpack('>BBH', value) + return ver, (up_flag << 16 | down_flag) + + +# https://github.com/gpac/mp4box.js/blob/4e1bc23724d2603754971abc00c2bd5aede7be60/src/box.js#L13-L40 +MP4_CONTAINER_BOXES = ('moov', 'trak', 'edts', 'mdia', 'minf', 'dinf', 'stbl', 'mvex', 'moof', 'traf', 'vttc', 'tref', 'iref', 'mfra', 'meco', 'hnti', 'hinf', 'strk', 'strd', 'sinf', 'rinf', 'schi', 'trgr', 'udta', 'iprp', 'ipco') +""" List of boxes that nests the other boxes """ + + +def parse_mp4_boxes(r: RawIOBase): + """ + Parses an ISO BMFF (which MP4 follows) and yields its boxes as a sequence. + This does not interpret content of these boxes. + + Sequence details: + ('atom', b'blablabla'): A box, with content (not container boxes) + ('atom', b''): Possibly container box (must check MP4_CONTAINER_BOXES) or really an empty box + (None, 'atom'): End of a container box + + Example: Path: + ('test', b'123456') /test + ('moov', b'') /moov (start of container box) + ('helo', b'abcdef') /moov/helo + ('1984', b'1q84') /moov/1984 + ('trak', b'') /moov/trak (start of container box) + ('keys', b'2022') /moov/trak/keys + (None , 'trak') /moov/trak (end of container box) + ('topp', b'1991') /moov/topp + (None , 'moov') /moov (end of container box) + """ + + while True: + size_b = read_harder(r, 4) + if not size_b: + break + type_b = r.read(4) + # 00 00 00 20 is big-endian + box_size = unpack_be32(size_b) + type_s = type_b.decode() + if type_s in MP4_CONTAINER_BOXES: + yield (type_s, b'') + yield from parse_mp4_boxes(LengthLimiter(r, box_size - 8)) + yield (None, type_s) + continue + # subtract by 8 + full_body = read_harder(r, box_size - 8) + yield (type_s, full_body) + + +def write_mp4_boxes(w: RawIOBase, box_iter): + """ + Writes an ISO BMFF file from a given sequence to a given writer. + The iterator to be passed must follow parse_mp4_boxes's protocol. + """ + + stack = [ + (None, w), # parent box, IO + ] + for btype, content in box_iter: + if btype in MP4_CONTAINER_BOXES: + bio = BytesIO() + stack.append((btype, bio)) + continue + elif btype is None: + assert stack[-1][0] == content + btype, bio = stack.pop() + content = bio.getvalue() + + wt = stack[-1][1] + wt.write(pack_be32(len(content) + 8)) + wt.write(btype.encode()[:4]) + wt.write(content) class MP4FixupTimestampPP(PostProcessor): From d2344827c8b6d9c7a03e092487276fe5d2346d65 Mon Sep 17 00:00:00 2001 From: Lesmiscore Date: Thu, 14 Jul 2022 13:07:25 +0900 Subject: [PATCH 11/11] fix import --- test/test_mp4parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_mp4parser.py b/test/test_mp4parser.py index f44f903da..dbaa61f95 100644 --- a/test/test_mp4parser.py +++ b/test/test_mp4parser.py @@ -8,7 +8,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import io -from yt_dlp.mp4_parser import ( +from yt_dlp.postprocessor.mp4direct import ( parse_mp4_boxes, write_mp4_boxes, )