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:
Barbagus 2022-12-22 17:43:42 +01:00
parent 88ffe31a94
commit 4c518993ef
5 changed files with 133 additions and 84 deletions

View File

@ -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__":

View File

@ -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

20
src/delarte/common.py Normal file
View File

@ -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."""

View File

@ -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])

View File

@ -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)