aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorGravatar Christopher Rosell <chrippa@tanuki.se>2012-09-21 03:05:52 +0200
committerGravatar Christopher Rosell <chrippa@tanuki.se>2012-09-21 03:06:24 +0200
commit7909377651f8d85c76d9016b4c7e61d17a7a6d75 (patch)
tree5f60942e386d782ef4df72efe50565324ddbcd63
parentced31eaf2e2b6f5c4fe82ef40aa238dbaac8056a (diff)
Add support for Apple HLS streams.
-rw-r--r--src/livestreamer/plugins/svtplay.py54
-rw-r--r--src/livestreamer/stream/__init__.py1
-rw-r--r--src/livestreamer/stream/hls.py219
3 files changed, 244 insertions, 30 deletions
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