forked from fcode/delarte
Fix issue #6 on FFMPEG header error
Handle the audio and video channel downloading to temporary files prior to calling ffmpeg. Although it might not be necessary, the download is made by "chunks" as it would be by a client/player. Downloading progress feedback is printed to the terminal.
This commit is contained in:
parent
547b65d94c
commit
be4363a339
|
@ -8,12 +8,16 @@ This file is part of [`delarte`](https://git.afpy.org/fcode/delarte.git)
|
|||
"""
|
||||
__version__ = "0.1"
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from http import HTTPStatus
|
||||
from http.client import HTTPSConnection, HTTPConnection
|
||||
from tempfile import NamedTemporaryFile
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
import m3u8
|
||||
|
@ -75,9 +79,9 @@ def build_file_base_name(config):
|
|||
return config["attributes"]["metadata"]["title"].replace("/", "-")
|
||||
|
||||
|
||||
def make_srt_tempfile(subtitles_index_url):
|
||||
def download_subtitles_input(index_url, progress):
|
||||
"""Return a temporary file name where VTT subtitle has been downloaded/converted to SRT."""
|
||||
subtitles_index = m3u8.load(subtitles_index_url)
|
||||
subtitles_index = m3u8.load(index_url)
|
||||
urls = [subtitles_index.base_uri + "/" + f for f in subtitles_index.files]
|
||||
|
||||
if not urls:
|
||||
|
@ -86,13 +90,15 @@ def make_srt_tempfile(subtitles_index_url):
|
|||
if len(urls) > 1:
|
||||
raise ValueError("Multiple subtitle files")
|
||||
|
||||
progress(0, 2)
|
||||
http_response = urlopen(urls[0])
|
||||
if http_response.status != HTTPStatus.OK:
|
||||
raise RuntimeError("Subtitle request failed")
|
||||
|
||||
buffer = io.StringIO(http_response.read().decode("utf8"))
|
||||
progress(1, 2)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
with NamedTemporaryFile(
|
||||
"w", delete=False, prefix="delarte.", suffix=".srt", encoding="utf8"
|
||||
) as f:
|
||||
i = 1
|
||||
|
@ -106,6 +112,7 @@ def make_srt_tempfile(subtitles_index_url):
|
|||
)
|
||||
print(caption.text + "\n", file=f)
|
||||
i += 1
|
||||
progress(2, 2)
|
||||
return f.name
|
||||
|
||||
|
||||
|
@ -177,17 +184,19 @@ def select_resolution(version_index, resolution_code):
|
|||
return None
|
||||
|
||||
|
||||
def build_ffmpeg_cmd(video_index_url, audio_track, subtitles_track, file_base_name):
|
||||
def build_ffmpeg_cmd(inputs, file_base_name):
|
||||
"""Build FFMPEG args."""
|
||||
audio_lang, audio_index_url = audio_track
|
||||
if subtitles_track:
|
||||
subtitles_lang, subtitles_file = subtitles_track
|
||||
video_input, audio_track, subtitles_track = inputs
|
||||
|
||||
cmd = ["ffmpeg"]
|
||||
cmd.extend(["-i", video_index_url])
|
||||
cmd.extend(["-i", audio_index_url])
|
||||
audio_lang, audio_input = audio_track
|
||||
if subtitles_track:
|
||||
cmd.extend(["-i", subtitles_file])
|
||||
subtitles_lang, subtitles_input = subtitles_track
|
||||
|
||||
cmd = ["ffmpeg", "-hide_banner"]
|
||||
cmd.extend(["-i", video_input])
|
||||
cmd.extend(["-i", audio_input])
|
||||
if subtitles_track:
|
||||
cmd.extend(["-i", subtitles_input])
|
||||
|
||||
cmd.extend(["-c:v", "copy"])
|
||||
cmd.extend(["-c:a", "copy"])
|
||||
|
@ -203,3 +212,127 @@ def build_ffmpeg_cmd(video_index_url, audio_track, subtitles_track, file_base_na
|
|||
|
||||
cmd.append(f"{file_base_name}.mkv")
|
||||
return cmd
|
||||
|
||||
|
||||
def parse_byterange(obj):
|
||||
"""Parse a M3U8 `byterange` (count@offset) into http range (range_start, rang_end)."""
|
||||
count, offset = [int(v) for v in obj.byterange.split("@")]
|
||||
return offset, offset + count - 1
|
||||
|
||||
|
||||
def load_av_index(index_url):
|
||||
"""Load a M3U8 audio or video index."""
|
||||
index = m3u8.load(index_url)
|
||||
|
||||
file_name = index.segment_map[0].uri
|
||||
range_start, range_end = parse_byterange(index.segment_map[0])
|
||||
if range_start != 0:
|
||||
raise ValueError("Invalid a/v index: does not start at 0")
|
||||
chunks = [(range_start, range_end)]
|
||||
total = range_end + 1
|
||||
|
||||
for segment in index.segments:
|
||||
if segment.uri != file_name:
|
||||
raise ValueError("Invalid a/v index: multiple file names")
|
||||
|
||||
range_start, range_end = parse_byterange(segment)
|
||||
if range_start != total:
|
||||
raise ValueError(
|
||||
f"Invalid a/v index: discontious ranges ({range_start} != {total})"
|
||||
)
|
||||
|
||||
chunks.append((range_start, range_end))
|
||||
total = range_end + 1
|
||||
|
||||
return urlparse(index.segment_map[0].absolute_uri), chunks
|
||||
|
||||
|
||||
def download_av_input(index_url, progress):
|
||||
"""Download an audio or video stream to temporary directory."""
|
||||
url, ranges = load_av_index(index_url)
|
||||
total = ranges[-1][1]
|
||||
|
||||
Connector = HTTPSConnection if url.scheme == "https" else HTTPConnection
|
||||
connection = Connector(url.hostname)
|
||||
connection.connect()
|
||||
|
||||
with (
|
||||
NamedTemporaryFile(
|
||||
mode="w+b", delete=False, prefix="delarte.", suffix=".mp4"
|
||||
) as f,
|
||||
contextlib.closing(connection) as c,
|
||||
):
|
||||
for range_start, range_end in ranges:
|
||||
c.request(
|
||||
"GET",
|
||||
url.path,
|
||||
headers={
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "fr,en;q=0.7,en-US;q=0.3",
|
||||
"Accept-Encoding": "gzip, deflate, br, identity",
|
||||
"Range": f"bytes={range_start}-{range_end}",
|
||||
"Origin": "https://www.arte.tv",
|
||||
"Connection": "keep-alive",
|
||||
"Referer": "https://www.arte.tv/",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
"Sec-GPC": "1",
|
||||
"DNT": "1",
|
||||
},
|
||||
)
|
||||
r = c.getresponse()
|
||||
if r.status != 206:
|
||||
raise ValueError(f"Invalid response status {r.status}")
|
||||
|
||||
content = r.read()
|
||||
if len(content) != range_end - range_start + 1:
|
||||
raise ValueError("Invalid range length")
|
||||
f.write(content)
|
||||
|
||||
progress(range_end, total)
|
||||
|
||||
return f.name
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def download_inputs(remote_inputs, progress):
|
||||
"""Download inputs in temporary files."""
|
||||
# It is implemented as a context manager that will delete temporary files on exit.
|
||||
|
||||
video_index_url, audio_track, subtitles_track = remote_inputs
|
||||
|
||||
video_filename = None
|
||||
audio_filename = None
|
||||
subtitles_filename = None
|
||||
|
||||
try:
|
||||
video_filename = download_av_input(
|
||||
video_index_url, lambda i, n: progress("video", i, n)
|
||||
)
|
||||
|
||||
(audio_lang, audio_index_url) = audio_track
|
||||
audio_filename = download_av_input(
|
||||
audio_index_url, lambda i, n: progress("audio", i, n)
|
||||
)
|
||||
|
||||
if subtitles_track:
|
||||
(subtitles_lang, subtitles_index_url) = subtitles_track
|
||||
subtitles_filename = download_subtitles_input(
|
||||
subtitles_index_url, lambda i, n: progress("subtitles", i, n)
|
||||
)
|
||||
|
||||
yield (
|
||||
video_filename,
|
||||
(audio_lang, audio_filename),
|
||||
(subtitles_lang, subtitles_filename),
|
||||
)
|
||||
else:
|
||||
yield (video_filename, (audio_lang, audio_filename), None)
|
||||
finally:
|
||||
if video_filename and os.path.isfile(video_filename):
|
||||
os.unlink(video_filename)
|
||||
if audio_filename and os.path.isfile(audio_filename):
|
||||
os.unlink(audio_filename)
|
||||
if subtitles_filename and os.path.isfile(subtitles_filename):
|
||||
os.unlink(subtitles_filename)
|
||||
|
|
|
@ -5,22 +5,22 @@ usage: delarte [-h|--help] - print this message
|
|||
or: delarte program_page_url version - show available resolutions
|
||||
or: delarte program_page_url version resolution - download the given video
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import (
|
||||
build_ffmpeg_cmd,
|
||||
build_file_base_name,
|
||||
download_inputs,
|
||||
select_resolution,
|
||||
select_version,
|
||||
iter_resolutions,
|
||||
iter_versions,
|
||||
load_config_api,
|
||||
load_version_index,
|
||||
make_srt_tempfile,
|
||||
)
|
||||
|
||||
|
||||
|
@ -44,6 +44,33 @@ def print_available_resolutions(version_index, f):
|
|||
print(f"\t{code} - {label}", file=f)
|
||||
|
||||
|
||||
def create_progress():
|
||||
"""Create a progress handler for input downloads."""
|
||||
state = {
|
||||
"last_update_time": 0,
|
||||
"last_channel": None,
|
||||
}
|
||||
|
||||
def progress(channel, current, total):
|
||||
now = time.time()
|
||||
|
||||
if current == total:
|
||||
print(f"\rDownloading {channel}: 100.0%")
|
||||
state["last_update_time"] = now
|
||||
elif channel != state["last_channel"]:
|
||||
print(f"Dowloading {channel}: 0.0%", end="")
|
||||
state["last_update_time"] = now
|
||||
state["last_channel"] = channel
|
||||
elif now - state["last_update_time"] > 1:
|
||||
print(
|
||||
f"\rDownloading {channel}: {int(1000.0 * current / total) / 10.0}%",
|
||||
end="",
|
||||
)
|
||||
state["last_update_time"] = now
|
||||
|
||||
return progress
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI command."""
|
||||
args = sys.argv[1:]
|
||||
|
@ -92,27 +119,17 @@ def main():
|
|||
print_available_resolutions(version_index, sys.stdout)
|
||||
return 0
|
||||
|
||||
stream_info = select_resolution(version_index, args.pop(0))
|
||||
if stream_info is None:
|
||||
remote_inputs = select_resolution(version_index, args.pop(0))
|
||||
if remote_inputs is None:
|
||||
fail("Invalid resolution")
|
||||
print_available_resolutions(version_index, sys.stderr)
|
||||
return 0
|
||||
|
||||
video_index_url, audio_track, subtitles_track = stream_info
|
||||
if subtitles_track:
|
||||
subtitles_lang, subtitles_index_url = subtitles_track
|
||||
subtitle_file = make_srt_tempfile(subtitles_index_url)
|
||||
subtitles_track = (subtitles_lang, subtitle_file)
|
||||
|
||||
file_base_name = build_file_base_name(config)
|
||||
|
||||
args = build_ffmpeg_cmd(
|
||||
video_index_url, audio_track, subtitles_track, file_base_name
|
||||
)
|
||||
|
||||
subprocess.run(args)
|
||||
if subtitle_file:
|
||||
os.unlink(subtitle_file)
|
||||
with download_inputs(remote_inputs, create_progress()) as temp_inputs:
|
||||
args = build_ffmpeg_cmd(temp_inputs, file_base_name)
|
||||
subprocess.run(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
Loading…
Reference in New Issue