Change error handling
Creation of a `common.Error` exception whose string representation is taken from its docstring. Creation of a `common.UnexpectedError` to serve as base for exceptions raised while checking assumptions on requests and responses. The later are handled by displaying a message inviting user to submit the error to us, so we can correct our assumptions.
This commit is contained in:
parent
88ffe31a94
commit
4c518993ef
|
@ -14,29 +14,17 @@ import time
|
|||
|
||||
import requests
|
||||
|
||||
from . import api
|
||||
from . import hls
|
||||
from . import muxing
|
||||
from . import naming
|
||||
from . import www
|
||||
from . import cli
|
||||
from . import api, cli, common, hls, muxing, naming, www
|
||||
|
||||
|
||||
def _fail(message, code=1):
|
||||
print(message, file=sys.stderr)
|
||||
return code
|
||||
|
||||
|
||||
def _print_available_renditions(config, f):
|
||||
print(f"Available versions:", file=f)
|
||||
def _print_available_renditions(config):
|
||||
for code, label in api.iter_renditions(config):
|
||||
print(f"\t{code} - {label}", file=f)
|
||||
print(f"\t{code} - {label}")
|
||||
|
||||
|
||||
def _print_available_variants(version_index, f):
|
||||
print(f"Available resolutions:", file=f)
|
||||
def _print_available_variants(version_index):
|
||||
for code, label in hls.iter_variants(version_index):
|
||||
print(f"\t{code} - {label}", file=f)
|
||||
print(f"\t{code} - {label}")
|
||||
|
||||
|
||||
def create_progress():
|
||||
|
@ -79,42 +67,65 @@ def main():
|
|||
|
||||
try:
|
||||
www_lang, program_id = www.parse_url(args.pop(0))
|
||||
except ValueError as e:
|
||||
return _fail(f"Invalid url: {e}")
|
||||
|
||||
try:
|
||||
config = api.load_config(http_session, www_lang, program_id)
|
||||
except ValueError:
|
||||
return _fail("Invalid program")
|
||||
|
||||
if not args:
|
||||
_print_available_renditions(config, sys.stdout)
|
||||
return 0
|
||||
if not args:
|
||||
print(f"Available versions:")
|
||||
_print_available_renditions(config)
|
||||
return 0
|
||||
|
||||
master_playlist_url = api.select_rendition(config, args.pop(0))
|
||||
if master_playlist_url is None:
|
||||
_fail("Invalid version")
|
||||
_print_available_renditions(config, sys.stderr)
|
||||
rendition_code = args.pop(0)
|
||||
master_playlist_url = api.select_rendition(config, rendition_code)
|
||||
if master_playlist_url is None:
|
||||
print(f"{rendition_code!r} is not a valid version, accepted values are:")
|
||||
_print_available_renditions(config)
|
||||
return 1
|
||||
|
||||
master_playlist = hls.load_master_playlist(http_session, master_playlist_url)
|
||||
|
||||
if not args:
|
||||
print(f"Available resolutions:")
|
||||
_print_available_variants(master_playlist)
|
||||
return 0
|
||||
|
||||
variant_code = args.pop(0)
|
||||
remote_inputs = hls.select_variant(master_playlist, variant_code)
|
||||
if remote_inputs is None:
|
||||
print(f"{variant_code!r} is not a valid resolution, accepted values are:")
|
||||
_print_available_variants(master_playlist)
|
||||
return 0
|
||||
|
||||
file_base_name = naming.build_file_base_name(config)
|
||||
|
||||
progress = create_progress()
|
||||
|
||||
with hls.download_inputs(http_session, remote_inputs, progress) as temp_inputs:
|
||||
muxing.mux(temp_inputs, file_base_name, progress)
|
||||
|
||||
except common.UnexpectedError as e:
|
||||
print(str(e))
|
||||
print(
|
||||
"This program is the result of browser/server traffic analysis and involves "
|
||||
"some level of trying and guessing. This error might mean that we did not try "
|
||||
"enough of that we guessed poorly."
|
||||
)
|
||||
print("Please consider submitting the issue to us so we may fix it.")
|
||||
print("")
|
||||
print("Issue tracker: https://git.afpy.org/fcode/delarte/issues")
|
||||
print(f"Title: {e.args(0)}")
|
||||
print("Body:")
|
||||
print(f" {repr(e)}")
|
||||
return 1
|
||||
|
||||
master_playlist = hls.load_master_playlist(http_session, master_playlist_url)
|
||||
except common.Error as e:
|
||||
print(str(e))
|
||||
print(repr(e))
|
||||
return 1
|
||||
|
||||
if not args:
|
||||
_print_available_variants(master_playlist, sys.stdout)
|
||||
return 0
|
||||
|
||||
remote_inputs = hls.select_variant(master_playlist, args.pop(0))
|
||||
if remote_inputs is None:
|
||||
_fail("Invalid resolution")
|
||||
_print_available_variants(master_playlist, sys.stderr)
|
||||
return 0
|
||||
|
||||
file_base_name = naming.build_file_base_name(config)
|
||||
|
||||
progress = create_progress()
|
||||
|
||||
with hls.download_inputs(http_session, remote_inputs, progress) as temp_inputs:
|
||||
muxing.mux(temp_inputs, file_base_name, progress)
|
||||
except requests.HTTPError as e:
|
||||
print("Network error.")
|
||||
print(str(e))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
@ -3,27 +3,36 @@
|
|||
|
||||
"""Provide ArteTV JSON API utilities."""
|
||||
|
||||
from . import common
|
||||
|
||||
MIME_TYPE = "application/vnd.api+json; charset=utf-8"
|
||||
|
||||
|
||||
class UnexpectedResponse(common.UnexpectedError):
|
||||
"""Unexpected response from ArteTV."""
|
||||
|
||||
|
||||
class NotFound(common.Error):
|
||||
"""Program not found on ArteTV."""
|
||||
|
||||
|
||||
def _fetch_api_data(http_session, path, object_type):
|
||||
# Fetch an API object.
|
||||
url = "https://api.arte.tv/api/player/v2/" + path
|
||||
|
||||
r = http_session.get(url)
|
||||
if r.status_code == 404:
|
||||
raise ValueError(f"{url}: not found")
|
||||
raise NotFound(url)
|
||||
|
||||
r.raise_for_status()
|
||||
|
||||
if r.headers["content-type"] != MIME_TYPE:
|
||||
raise ValueError("API response not supported")
|
||||
if (_ := r.headers["content-type"]) != MIME_TYPE:
|
||||
raise UnexpectedResponse("MIME_TYPE", path, MIME_TYPE, _)
|
||||
|
||||
obj = r.json()["data"]
|
||||
|
||||
if obj["type"] != object_type:
|
||||
raise ValueError("Invalid API response")
|
||||
if (_ := obj["type"]) != object_type:
|
||||
raise UnexpectedResponse("OBJECT_TYPE", path, object_type, _)
|
||||
|
||||
return obj
|
||||
|
||||
|
@ -35,9 +44,6 @@ def load_config(http_session, lang, program_id):
|
|||
http_session, f"config/{lang}/{program_id}", "ConfigPlayer"
|
||||
)
|
||||
|
||||
if config["attributes"]["metadata"]["providerId"] != program_id:
|
||||
raise ValueError("Invalid API response")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
# License: GNU AGPL v3: http://www.gnu.org/licenses/
|
||||
# This file is part of `delarte` (https://git.afpy.org/fcode/delarte.git)
|
||||
|
||||
"""Provide common utilities."""
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
"""General error."""
|
||||
|
||||
def __str__(self):
|
||||
"""Use the class definition docstring as a string representation."""
|
||||
return self.__doc__
|
||||
|
||||
def __repr__(self):
|
||||
"""Use the class qualified name and constructor arguments."""
|
||||
return f"{self.__class__.__qualname__}{self.args!r}"
|
||||
|
||||
|
||||
class UnexpectedError(Error):
|
||||
"""An error to report to developers."""
|
|
@ -62,11 +62,12 @@ import io
|
|||
import os
|
||||
import re
|
||||
from tempfile import NamedTemporaryFile
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import m3u8
|
||||
import webvtt
|
||||
|
||||
from . import common
|
||||
|
||||
#
|
||||
# WARNING !
|
||||
#
|
||||
|
@ -82,19 +83,15 @@ import webvtt
|
|||
# - Subtitles media playlists have only one segment
|
||||
|
||||
|
||||
class UnexpectedResponse(common.UnexpectedError):
|
||||
"""Unexpected response from ArteTV."""
|
||||
|
||||
|
||||
def _make_resolution_code(variant):
|
||||
# resolution code (1080p, 720p, ...)
|
||||
return f"{variant.stream_info.resolution[1]}p"
|
||||
|
||||
|
||||
def _is_relative_file_path(uri):
|
||||
try:
|
||||
url = urlparse(uri)
|
||||
return url.path == uri and not uri.startswith("/")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _fetch_playlist(http_session, url):
|
||||
# Fetch a M3U8 playlist
|
||||
r = http_session.get(url)
|
||||
|
@ -107,7 +104,7 @@ def load_master_playlist(http_session, url):
|
|||
master_playlist = _fetch_playlist(http_session, url)
|
||||
|
||||
if not master_playlist.playlists:
|
||||
raise ValueError("Unexpected missing playlists")
|
||||
raise UnexpectedResponse("NO_PLAYLISTS", url)
|
||||
|
||||
resolution_codes = set()
|
||||
|
||||
|
@ -115,28 +112,25 @@ def load_master_playlist(http_session, url):
|
|||
resolution_code = _make_resolution_code(variant)
|
||||
|
||||
if resolution_code in resolution_codes:
|
||||
raise ValueError("Unexpected duplicate resolution")
|
||||
raise UnexpectedResponse("DUPLICATE_RESOLUTION_CODE", url, resolution_code)
|
||||
resolution_codes.add(resolution_code)
|
||||
|
||||
audio_media = False
|
||||
subtitles_media = False
|
||||
|
||||
for m in variant.media:
|
||||
if not _is_relative_file_path(m.uri):
|
||||
raise ValueError("Invalid relative file name")
|
||||
|
||||
if m.type == "AUDIO":
|
||||
if audio_media:
|
||||
raise ValueError("Unexpected multiple audio tracks")
|
||||
raise UnexpectedResponse("MULTIPLE_AUDIO_MEDIA", url)
|
||||
audio_media = True
|
||||
|
||||
elif m.type == "SUBTITLES":
|
||||
if subtitles_media:
|
||||
raise ValueError("Unexpected multiple subtitles tracks")
|
||||
raise UnexpectedResponse("MULTIPLE_SUBTITLES_MEDIA", url)
|
||||
subtitles_media = True
|
||||
|
||||
if not audio_media:
|
||||
raise ValueError("Unexpected missing audio track")
|
||||
raise UnexpectedResponse("NO_AUDIO_MEDIA", url)
|
||||
|
||||
return master_playlist
|
||||
|
||||
|
@ -194,18 +188,20 @@ def _load_av_segments(http_session, media_playlist_url):
|
|||
file_name = media_playlist.segment_map[0].uri
|
||||
range_start, range_end = _parse_byterange(media_playlist.segment_map[0])
|
||||
if range_start != 0:
|
||||
raise ValueError("Invalid a/v index: does not start at 0")
|
||||
raise UnexpectedResponse(
|
||||
"INVALID_STREAM_MEDIA_FRAGMENT_START", media_playlist_url
|
||||
)
|
||||
chunks = [(range_start, range_end)]
|
||||
total = range_end + 1
|
||||
|
||||
for segment in media_playlist.segments:
|
||||
if segment.uri != file_name:
|
||||
raise ValueError("Invalid a/v index: multiple file names")
|
||||
raise UnexpectedResponse("MULTIPLE_STREAM_MEDIA_FILES", media_playlist_url)
|
||||
|
||||
range_start, range_end = _parse_byterange(segment)
|
||||
if range_start != total:
|
||||
raise ValueError(
|
||||
f"Invalid a/v index: discontinuous ranges ({range_start} != {total})"
|
||||
raise UnexpectedResponse(
|
||||
"DISCONTINUOUS_STREAM_MEDIA_FRAGMENT", media_playlist_url
|
||||
)
|
||||
|
||||
chunks.append((range_start, range_end))
|
||||
|
@ -235,10 +231,17 @@ def _download_av_stream(http_session, media_playlist_url, progress):
|
|||
r.raise_for_status()
|
||||
|
||||
if r.status_code != 206:
|
||||
raise ValueError(f"Invalid response status {r.status}")
|
||||
raise UnexpectedResponse(
|
||||
"STREAM_MEDIA_HTTP_STATUS",
|
||||
media_playlist_url,
|
||||
r.request.headers,
|
||||
r.status,
|
||||
)
|
||||
|
||||
if len(r.content) != range_end - range_start + 1:
|
||||
raise ValueError("Invalid range length")
|
||||
raise UnexpectedResponse(
|
||||
"INVALID_STREAM_MEDIA_FRAGMENT_LENGTH", media_playlist_url
|
||||
)
|
||||
f.write(r.content)
|
||||
|
||||
progress(range_end, total)
|
||||
|
@ -252,10 +255,10 @@ def _download_subtitles_input(http_session, index_url, progress):
|
|||
urls = [s.absolute_uri for s in subtitles_index.segments]
|
||||
|
||||
if not urls:
|
||||
raise ValueError("No subtitle files")
|
||||
raise UnexpectedResponse("SUBTITLES_MEDIA_NO_FILES", index_url)
|
||||
|
||||
if len(urls) > 1:
|
||||
raise ValueError("Multiple subtitle files")
|
||||
raise UnexpectedResponse("SUBTITLES_MEDIA_MULTIPLE_FILES", index_url)
|
||||
|
||||
progress(0, 2)
|
||||
r = http_session.get(urls[0])
|
||||
|
|
|
@ -4,25 +4,34 @@
|
|||
"""Provide ArteTV website utilities."""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
from . import common
|
||||
|
||||
LANGUAGES = ["fr", "de", "en", "es", "pl", "it"]
|
||||
|
||||
|
||||
class InvalidUrl(common.Error):
|
||||
"""Invalid ArteTV URL."""
|
||||
|
||||
|
||||
def parse_url(program_page_url):
|
||||
"""Parse ArteTV web URL into UI language and program ID."""
|
||||
url = urlparse(program_page_url)
|
||||
try:
|
||||
url = urlparse(program_page_url)
|
||||
except ValueError:
|
||||
raise InvalidUrl("URL_PARSE", program_page_url, url.hostname)
|
||||
|
||||
if url.hostname != "www.arte.tv":
|
||||
raise ValueError("not an ArteTV url")
|
||||
raise InvalidUrl("HOST_NAME", program_page_url, url.hostname)
|
||||
|
||||
program_page_path = url.path.split("/")[1:]
|
||||
|
||||
lang = program_page_path.pop(0)
|
||||
|
||||
if lang not in LANGUAGES:
|
||||
raise ValueError(f"invalid url language code: {lang}")
|
||||
raise InvalidUrl("WWW_LANGUAGE", program_page_url, lang)
|
||||
|
||||
if program_page_path.pop(0) != "videos":
|
||||
raise ValueError("invalid ArteTV url")
|
||||
raise InvalidUrl("PATH", program_page_url, program_page_path)
|
||||
|
||||
program_id = program_page_path.pop(0)
|
||||
|
||||
|
|
Loading…
Reference in New Issue