diff --git a/src/delarte/__main__.py b/src/delarte/__main__.py index 1228803..f0e615e 100644 --- a/src/delarte/__main__.py +++ b/src/delarte/__main__.py @@ -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__": diff --git a/src/delarte/api.py b/src/delarte/api.py index 199ddab..2c5fb11 100644 --- a/src/delarte/api.py +++ b/src/delarte/api.py @@ -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 diff --git a/src/delarte/common.py b/src/delarte/common.py new file mode 100644 index 0000000..9849fa5 --- /dev/null +++ b/src/delarte/common.py @@ -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.""" diff --git a/src/delarte/hls.py b/src/delarte/hls.py index 8af0881..8c565dc 100644 --- a/src/delarte/hls.py +++ b/src/delarte/hls.py @@ -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]) diff --git a/src/delarte/www.py b/src/delarte/www.py index f45c663..67a3273 100644 --- a/src/delarte/www.py +++ b/src/delarte/www.py @@ -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)