From 7909377651f8d85c76d9016b4c7e61d17a7a6d75 Mon Sep 17 00:00:00 2001 From: Christopher Rosell Date: Fri, 21 Sep 2012 03:05:52 +0200 Subject: Add support for Apple HLS streams. --- src/livestreamer/plugins/svtplay.py | 54 ++++----- src/livestreamer/stream/__init__.py | 1 + src/livestreamer/stream/hls.py | 219 ++++++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 30 deletions(-) create mode 100644 src/livestreamer/stream/hls.py diff --git a/src/livestreamer/plugins/svtplay.py b/src/livestreamer/plugins/svtplay.py index a3bfa5a..d802b47 100644 --- a/src/livestreamer/plugins/svtplay.py +++ b/src/livestreamer/plugins/svtplay.py @@ -1,12 +1,11 @@ from livestreamer.compat import str from livestreamer.plugins import Plugin, PluginError, NoStreamsError -from livestreamer.stream import RTMPStream +from livestreamer.stream import RTMPStream, HLSStream from livestreamer.utils import urlget, swfverify, verifyjson import re class SVTPlay(Plugin): - JSONURL = "http://svtplay.se/live/{0}" SWFURL = "http://www.svtplay.se/public/swf/video/svtplayer-2012.15.swf" PageURL = "http://www.svtplay.se" @@ -14,24 +13,9 @@ class SVTPlay(Plugin): def can_handle_url(self, url): return "svtplay.se" in url - def _get_channel_id(self, url): - self.logger.debug("Fetching channel id") - - res = urlget(url) - - - match = re.search('data-json-href="/live/(\d+)"', res.text) - if match: - return int(match.group(1)) - def _get_streams(self): - channelid = self._get_channel_id(self.url) - - if not channelid: - raise NoStreamsError(self.url) - self.logger.debug("Fetching stream info") - res = urlget(self.JSONURL.format(channelid), params=dict(output="json")) + res = urlget(self.url, params=dict(output="json")) if res.json is None: raise PluginError("No JSON data in stream info") @@ -39,22 +23,32 @@ class SVTPlay(Plugin): streams = {} video = verifyjson(res.json, "video") videos = verifyjson(video, "videoReferences") - - self.logger.debug("Verifying SWF: {0}", self.SWFURL) - swfhash, swfsize = swfverify(self.SWFURL) + swfhash, swfsize = (None, None) for video in videos: - if not ("url" in video and "playerType" in video and video["playerType"] == "flash"): + if not ("url" in video and "playerType" in video): continue - stream = RTMPStream(self.session, { - "rtmp": video["url"], - "pageUrl": self.PageURL, - "swfhash": swfhash, - "swfsize": swfsize, - "live": True - }) - streams[str(video["bitrate"]) + "k"] = stream + if video["playerType"] == "flash": + if video["url"].startswith("rtmp"): + if not swfhash: + self.logger.debug("Verifying SWF: {0}", self.SWFURL) + swfhash, swfsize = swfverify(self.SWFURL) + + stream = RTMPStream(self.session, { + "rtmp": video["url"], + "pageUrl": self.PageURL, + "swfhash": swfhash, + "swfsize": swfsize, + "live": True + }) + streams[str(video["bitrate"]) + "k"] = stream + elif video["playerType"] == "ios": + try: + hlsstreams = HLSStream.parse_variant_playlist(self.session, video["url"]) + streams.update(hlsstreams) + except IOError as err: + self.logger.warning("Failed to get variant playlist: {0}", err) return streams diff --git a/src/livestreamer/stream/__init__.py b/src/livestreamer/stream/__init__.py index 235d2f7..24eaa7b 100644 --- a/src/livestreamer/stream/__init__.py +++ b/src/livestreamer/stream/__init__.py @@ -58,6 +58,7 @@ class StreamProcess(Stream): return stream.process.stdout +from .hls import HLSStream from .http import HTTPStream from .rtmpdump import RTMPStream diff --git a/src/livestreamer/stream/hls.py b/src/livestreamer/stream/hls.py new file mode 100644 index 0000000..cc646f1 --- /dev/null +++ b/src/livestreamer/stream/hls.py @@ -0,0 +1,219 @@ +from . import Stream, StreamError +from ..utils import urlget + +from time import time, sleep + +import re + +try: + from Crypto.Cipher import AES + import struct + + def num_to_iv(n): + return struct.pack(">8xq", n) + + CAN_DECRYPT = True + +except ImportError: + CAN_DECRYPT = False + + +def parse_m3u_attributes(data): + attr = re.findall("([A-Z\-]+)=(\d+\.\d+|0x[0-9A-z]+|\d+x\d+|\d+|\"(.+)\"|[0-9A-z\-]+)", data) + rval = {} + + for key, val, strval in attr: + if len(strval) > 0: + rval[key] = strval + else: + rval[key] = val + + if len(rval) > 0: + return rval + + return data + +def parse_m3u_tag(data): + key = data[1:] + value = None + valpos = data.find(":") + + if valpos > 0: + key = data[1:valpos] + value = data[valpos+1:] + + return (key, value) + +def parse_m3u(data): + lines = [line for line in data.splitlines() if len(line) > 0] + tags = {} + entries = [] + + lasttag = None + + for i, line in enumerate(lines): + if line.startswith("#EXT"): + (key, value) = parse_m3u_tag(line) + + if value is not None: + if key == "EXTINF": + duration, title = value.split(",") + value = (float(duration), title) + else: + value = parse_m3u_attributes(value) + + tags[key] = value + lasttag = (key, value) + else: + entry = { "url": line, "tag": lasttag } + entries.append(entry) + + return (tags, entries) + +class HLSStream(Stream): + def __init__(self, session, url): + Stream.__init__(self, session) + + self.url = url + self.playlist = {} + self.playlist_reload_time = 0 + self.playlist_minimal_reload_time = 15 + self.playlist_end = False + self.entry = None + self.decryptor = None + self.decryptor_key = None + self.decryptor_iv = None + self.fd = None + self.sequence = 0 + self.logger = session.logger.new_module("stream.hls") + + def open(self): + return self + + def read(self, size=-1): + if self.entry is None: + try: + self._next_entry() + except IOError: + return b"" + + data = self.fd.read(size) + + if len(data) == 0: + self._next_entry() + return self.read(size) + + if self.decryptor: + data = self.decryptor.decrypt(data) + + return data + + def _next_entry(self): + if len(self.playlist) == 0: + self._reload_playlist() + + # Periodic reload is not fatal if it fails + elapsed = time() - self.playlist_reload_time + if elapsed > self.playlist_minimal_reload_time: + try: + self._reload_playlist() + except IOError: + pass + + if not self.sequence in self.playlist: + if self.playlist_end: + # Last playlist is over + raise IOError("End of stream") + else: + self.logger.debug("Next sequence not available yet") + sleep(1) + return self._next_entry() + + self.entry = self.playlist[self.sequence] + + self.logger.debug("Next entry: {0}", self.entry) + + res = urlget(self.entry["url"], prefetch=False, + exception=IOError) + + self.playlist[self.sequence] = None + + if self.decryptor_key: + if not self.decryptor_iv: + iv = num_to_iv(self.sequence) + else: + iv = num_to_iv(self.decryptor_iv) + + self.decryptor = AES.new(self.decryptor_key, AES.MODE_CBC, iv) + + self.fd = res.raw + self.sequence += 1 + + def _reload_playlist(self): + if self.playlist_end: + return + + self.logger.debug("Reloading playlist") + + res = urlget(self.url, exception=IOError) + + (tags, entries) = parse_m3u(res.text) + + if "EXT-X-ENDLIST" in tags: + self.playlist_end = True + + if "EXT-X-MEDIA-SEQUENCE" in tags: + sequence = int(tags["EXT-X-MEDIA-SEQUENCE"]) + else: + sequence = 0 + + if "EXT-X-KEY" in tags and tags["EXT-X-KEY"]["METHOD"] != "NONE": + if not CAN_DECRYPT: + self.logger.error("Need pyCrypto installed to decrypt data") + raise IOError + + if tags["EXT-X-KEY"]["METHOD"] != "AES-128": + self.logger.error("Unable to decrypt cipher {0}", tags["EXT-X-KEY"]["METHOD"]) + raise IOError + + if not "URI" in tags["EXT-X-KEY"]: + self.logger.error("Missing URI to decryption key") + raise IOError + + res = urlget(tags["EXT-X-KEY"]["URI"], exception=IOError) + self.decryptor_key = res.content + + for i, entry in enumerate(entries): + self.playlist[sequence + i] = entry + + if entry["tag"][0] == "EXTINF": + duration = entry["tag"][1][0] + self.playlist_minimal_reload_time = duration + + if self.sequence == 0: + self.sequence = sequence + + self.playlist_reload_time = time() + + @classmethod + def parse_variant_playlist(cls, session, url): + res = urlget(url, exception=IOError) + streams = {} + + (tags, entries) = parse_m3u(res.text) + + for entry in entries: + (tag, value) = entry["tag"] + + if tag != "EXT-X-STREAM-INF": + continue + + if not "RESOLUTION" in value: + continue + + quality = value["RESOLUTION"].split("x")[1] + "p" + stream = HLSStream(session, entry["url"]) + + streams[quality] = stream + + return streams -- cgit v1.2.3