Repository: streamlink/streamlink
Pull request #560 (2017-02-10 14:18:20)

stream: make Stream responsible for generating the stream_url
The current method (`streamlink_cli.utils.stream.stream_to_url`) converts a stream to a URL. The responsibility of converting a stream to a URL should be that of the `Stream` class.

A new `to_url` method has been added to the `Stream` class to facilitate this. This makes is much easier for `Stream` subclasses to generate URLs for streams if possible. For example, in the FilmOn specific HLS stream class, `FilmOnHLS` a subclass of `HLSStream`, it is not always possible to generate a suitable URL, as some streams expire almost immediately.


Patch:
diff --git a/src/streamlink/plugins/filmon.py b/src/streamlink/plugins/filmon.py
index faec7498..2c770f19 100644
--- a/src/streamlink/plugins/filmon.py
+++ b/src/streamlink/plugins/filmon.py
@@ -48,6 +48,13 @@ class FilmOnHLS(HLSStream):
         else:
             return self._url
 
+    def to_url(self):
+        url = self.url
+        expires = self.watch_timeout - time.time()
+        if expires < 0:
+            raise TypeError("Stream has expired and cannot be converted to a URL")
+        return url
+
 
 class FilmOnAPI(object):
     channel_url = "http://www.filmon.com/api-v2/channel/{0}?protocol=hls"
diff --git a/src/streamlink/plugins/huomao.py b/src/streamlink/plugins/huomao.py
deleted file mode 100644
index ca5371ff..00000000
--- a/src/streamlink/plugins/huomao.py
+++ /dev/null
@@ -1,97 +0,0 @@
-# NOTE: Since a documented API is nowhere to be found for Huomao; this plugin
-# simply extracts the videos stream_id, stream_url and stream_quality by 
-# scraping the HTML and JS of one of Huomaos mobile webpages. 
-
-# When viewing a stream on huomao.com, the base URL references a room_id. This 
-# room_id is mapped one-to-one to a stream_id which references the actual .flv
-# video. Both stream_id, stream_url and stream_quality can be found in the 
-# HTML and JS source of the mobile_page. Since one stream can occur in many
-# different qualities, we scrape all stream_url and stream_quality occurences
-# and return each option to the user.
-
-import re
-
-from streamlink.plugin import Plugin
-from streamlink.plugin.api import http, validate
-from streamlink.stream import HTTPStream
-
-# URL pattern for recognizing inputed Huomao.tv / Huomao.com URL.
-url_re = re.compile(r"""
-    (http(s)?://)? 
-    (www\.)?
-    huomao
-    (\.tv|\.com)
-    /(?P<room_id>\d+)
-""", re.VERBOSE)
-
-# URL used to retrive the stream_id, stream_url and stream_quality based of
-# a room_id.
-mobile_url = "http://www.huomao.com/mobile/mob_live/{0}"
-
-# Pattern for extracting the stream_id from the mobile_url HTML.
-#
-# Example from HTML:
-#   <input id="html_stream" value="efmrCH" type="hidden">
-stream_id_pattern = re.compile(r'id=\"html_stream\" value=\"(?P<stream_id>\w+)\"')
-
-# Pattern for extracting each stream_url, stream_quality_url and a prettified 
-# stream_quality_name used for quality naming.
-# 
-# Example from HTML: 
-#   "2: 'http://live-ws.huomaotv.cn/live/'+stream+'_720/playlist.m3u8'"
-stream_info_pattern = re.compile(r"""
-    [1-9]:
-    \s+
-    '(?P<stream_url>(?:\w|\.|:|-|/)+)
-    '\+stream\+'
-    (?P<stream_quality_url>_?(?P<stream_quality_name>\d*))
-    /playlist.m3u8'
-""", re.VERBOSE)
-
-# Returns the stream_id contained in the HTML.
-def get_stream_id(self, html):
-    stream_id = stream_id_pattern.search(html)
-    
-    if not stream_id:
-        self.logger.error("Failed to extract stream_id.")
-
-    return stream_id.group("stream_id")
-
-# Returns a list of each stream_url, stream_quality_url and stream_quality_name
-# occurence in the JS.
-def get_stream_info(self, html):
-    stream_info = stream_info_pattern.findall(html.text)
-
-    if not stream_info:
-        self.logger.error("Failed to extract stream_info.")
-
-    # Rename the "" quality to "source" by transforming the tuples to a
-    # list and reassigning.
-    stream_info_list = []
-    for info in stream_info:
-        if not info[2]:
-            stream_info_list.append([info[0], info[1], "source"])
-        else:
-            stream_info_list.append(list(info))
-
-    return stream_info_list
-
-class Huomao(Plugin):
-    @classmethod
-    def can_handle_url(self, url):
-        return url_re.match(url)
-
-    def _get_streams(self):
-        room_id = url_re.search(self.url).group("room_id")
-        html = http.get(mobile_url.format(room_id))
-        stream_id = get_stream_id(self, html.text)
-        stream_info = get_stream_info(self, html)
-
-        streams = {}
-        for info in stream_info:
-            streams[info[2]] = HTTPStream(self.session, 
-                info[0] + stream_id + info[1] + ".flv")
-
-        return streams
-
-__plugin__ = Huomao
diff --git a/src/streamlink/stream/__init__.py b/src/streamlink/stream/__init__.py
index 8502d625..d4498490 100644
--- a/src/streamlink/stream/__init__.py
+++ b/src/streamlink/stream/__init__.py
@@ -1,13 +1,13 @@
 from ..exceptions import StreamError
-from .stream import Stream
+from streamlink.stream.stream import Stream
 
-from .akamaihd import AkamaiHDStream
-from .hds import HDSStream
-from .hls import HLSStream
-from .http import HTTPStream
-from .rtmpdump import RTMPStream
-from .streamprocess import StreamProcess
-from .wrappers import StreamIOWrapper, StreamIOIterWrapper, StreamIOThreadWrapper
+from streamlink.stream.akamaihd import AkamaiHDStream
+from streamlink.stream.hds import HDSStream
+from streamlink.stream.hls import HLSStream
+from streamlink.stream.http import HTTPStream
+from streamlink.stream.rtmpdump import RTMPStream
+from streamlink.stream.streamprocess import StreamProcess
+from streamlink.stream.wrappers import StreamIOWrapper, StreamIOIterWrapper, StreamIOThreadWrapper
 
-from .flvconcat import extract_flv_header_tags
-from .playlist import Playlist, FLVPlaylist
+from streamlink.stream.flvconcat import extract_flv_header_tags
+from streamlink.stream.playlist import Playlist, FLVPlaylist
diff --git a/src/streamlink/stream/http.py b/src/streamlink/stream/http.py
index 829064d7..09752111 100644
--- a/src/streamlink/stream/http.py
+++ b/src/streamlink/stream/http.py
@@ -78,3 +78,6 @@ class HTTPStream(Stream):
             fd = StreamIOThreadWrapper(self.session, fd, timeout=timeout)
 
         return fd
+
+    def to_url(self):
+        return self.url
diff --git a/src/streamlink/stream/rtmpdump.py b/src/streamlink/stream/rtmpdump.py
index c254173e..2b24a4a6 100644
--- a/src/streamlink/stream/rtmpdump.py
+++ b/src/streamlink/stream/rtmpdump.py
@@ -1,13 +1,12 @@
 import re
 
-from time import sleep
-
 import subprocess
+from operator import itemgetter
 
 from streamlink.stream.streamprocess import StreamProcess
 from streamlink.compat import str, which
 from streamlink.exceptions import StreamError
-from streamlink.utils import rtmpparse
+from streamlink.utils import rtmpparse, escape_librtmp
 
 
 class RTMPStream(StreamProcess):
@@ -127,3 +126,26 @@ class RTMPStream(StreamProcess):
         cmd = session.options.get("rtmp-rtmpdump")
 
         return which(cmd) is not None
+
+    def to_url(self):
+        stream_params = dict(self.params)
+        params = [stream_params.pop("rtmp", "")]
+
+        if "swfVfy" in self.params:
+            stream_params["swfUrl"] = self.params["swfVfy"]
+            stream_params["swfVfy"] = True
+
+        if "swfhash" in self.params:
+            stream_params["swfVfy"] = True
+            stream_params.pop("swfhash", None)
+            stream_params.pop("swfsize", None)
+
+        # sort the keys for stability of output
+        for key, value in sorted(stream_params.items(), key=itemgetter(0)):
+            if isinstance(value, list):
+                for svalue in value:
+                    params.append("{0}={1}".format(key, escape_librtmp(svalue)))
+            else:
+                params.append("{0}={1}".format(key, escape_librtmp(value)))
+
+        return " ".join(params)
diff --git a/src/streamlink/stream/stream.py b/src/streamlink/stream/stream.py
index 81132c2f..60acebe4 100644
--- a/src/streamlink/stream/stream.py
+++ b/src/streamlink/stream/stream.py
@@ -37,6 +37,9 @@ class Stream(object):
     def shortname(cls):
         return cls.__shortname__
 
+    def to_url(self):
+        raise TypeError("{0} cannot be converted to a URL".format(self.shortname()))
+
 
 class StreamIO(io.IOBase):
     pass
diff --git a/src/streamlink/utils/__init__.py b/src/streamlink/utils/__init__.py
index 774bad79..12d4195c 100644
--- a/src/streamlink/utils/__init__.py
+++ b/src/streamlink/utils/__init__.py
@@ -235,6 +235,19 @@ def swfverify(url):  # pragma: no cover
     return h.hexdigest(), len(swf)
 
 
+def escape_librtmp(value):
+    if isinstance(value, bool):
+        value = "1" if value else "0"
+    if isinstance(value, int):
+        value = str(value)
+
+    # librtmp expects some characters to be escaped
+    value = value.replace("\\", "\\5c")
+    value = value.replace(" ", "\\20")
+    value = value.replace('"', "\\22")
+    return value
+
 __all__ = ["urlopen", "urlget", "urlresolve", "swfdecompress", "swfverify",
            "verifyjson", "absolute_url", "parse_qsd", "parse_json", "res_json",
-           "parse_xml", "res_xml", "rtmpparse", "prepend_www", "NamedPipe"]
+           "parse_xml", "res_xml", "rtmpparse", "prepend_www", "NamedPipe",
+           "escape_librtmp"]
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index a506189c..029d741d 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -349,10 +349,9 @@ def handle_stream(plugin, streams, stream_name):
         console.msg_json(stream)
 
     elif args.stream_url:
-        url = stream_to_url(stream)
-        if url:
-            console.msg("{0}", url)
-        else:
+        try:
+            console.msg("{0}", stream.to_url())
+        except TypeError:
             console.exit("The stream specified cannot be translated to a URL")
 
     # Output the stream
diff --git a/src/streamlink_cli/utils/stream.py b/src/streamlink_cli/utils/stream.py
index aa27fbbc..f65305c9 100644
--- a/src/streamlink_cli/utils/stream.py
+++ b/src/streamlink_cli/utils/stream.py
@@ -1,45 +1,5 @@
-def escape_librtmp(value):
-    if isinstance(value, bool):
-        value = "1" if value else "0"
-    if isinstance(value, int):
-        value = str(value)
-
-    # librtmp expects some characters to be escaped
-    value = value.replace("\\", "\\5c")
-    value = value.replace(" ", "\\20")
-    value = value.replace('"', "\\22")
-    return value
-
-
 def stream_to_url(stream):
-    stream_type = type(stream).shortname()
-
-    if stream_type in ("hls", "http"):
-        url = stream.url
-
-    elif stream_type == "rtmp":
-        params = [stream.params.pop("rtmp", "")]
-        stream_params = dict(stream.params)
-
-        if "swfVfy" in stream.params:
-            stream_params["swfUrl"] = stream.params["swfVfy"]
-            stream_params["swfVfy"] = True
-
-        if "swfhash" in stream.params:
-            stream_params["swfVfy"] = True
-            stream_params.pop("swfhash", None)
-            stream_params.pop("swfsize", None)
-
-        for key, value in stream_params.items():
-            if isinstance(value, list):
-                for svalue in value:
-                    params.append("{0}={1}".format(key, escape_librtmp(svalue)))
-            else:
-                params.append("{0}={1}".format(key, escape_librtmp(value)))
-
-        url = " ".join(params)
-
-    else:
-        url = None
-
-    return url
+    try:
+        return stream.to_url()
+    except TypeError:
+        return None


Test patch:
diff --git a/tests/test_stream_to_url.py b/tests/test_stream_to_url.py
new file mode 100644
index 00000000..5e9712ed
--- /dev/null
+++ b/tests/test_stream_to_url.py
@@ -0,0 +1,81 @@
+import unittest
+
+try:
+    from unittest.mock import MagicMock, patch, PropertyMock
+except ImportError:
+    from mock import MagicMock, patch, PropertyMock
+
+from streamlink import Streamlink
+from streamlink.plugins.filmon import FilmOnHLS
+from streamlink.stream import AkamaiHDStream
+from streamlink.stream import HDSStream
+from streamlink.stream import HLSStream
+from streamlink.stream import HTTPStream
+from streamlink.stream import RTMPStream
+from streamlink.stream import Stream
+from streamlink_cli.utils import stream_to_url
+
+
+class TestStreamToURL(unittest.TestCase):
+    def setUp(self):
+        self.session = Streamlink()
+
+    def test_base_stream(self):
+        stream = Stream(self.session)
+        self.assertEqual(None, stream_to_url(stream))
+        self.assertRaises(TypeError, stream.to_url)
+
+    def test_http_stream(self):
+        expected = "http://test.se/stream"
+        stream = HTTPStream(self.session, expected)
+        self.assertEqual(expected, stream_to_url(stream))
+        self.assertEqual(expected, stream.to_url())
+
+    def test_hls_stream(self):
+        expected = "http://test.se/stream.m3u8"
+        stream = HLSStream(self.session, expected)
+        self.assertEqual(expected, stream_to_url(stream))
+        self.assertEqual(expected, stream.to_url())
+
+    def test_hds_stream(self):
+        stream = HDSStream(self.session, "http://test.se/", "http://test.se/stream.f4m", "http://test.se/stream/1.bootstrap")
+        self.assertEqual(None, stream_to_url(stream))
+        self.assertRaises(TypeError, stream.to_url)
+
+    def test_akamai_stream(self):
+        stream = AkamaiHDStream(self.session, "http://akamai.test.se/stream")
+        self.assertEqual(None, stream_to_url(stream))
+        self.assertRaises(TypeError, stream.to_url)
+
+    def test_rtmp_stream(self):
+        stream = RTMPStream(self.session, {"rtmp": "rtmp://test.se/app/play_path",
+                                           "swfVfy": "http://test.se/player.swf",
+                                           "swfhash": "test",
+                                           "swfsize": 123456,
+                                           "playPath": "play_path"})
+        expected = "rtmp://test.se/app/play_path playPath=play_path swfUrl=http://test.se/player.swf swfVfy=1"
+        self.assertEqual(expected, stream_to_url(stream))
+        self.assertEqual(expected, stream.to_url())
+
+    @patch("time.time")
+    @patch("streamlink.plugins.filmon.FilmOnHLS.url", new_callable=PropertyMock)
+    def test_filmon_stream(self, url, time):
+        stream = FilmOnHLS(self.session, channel="test")
+        url.return_value = "http://filmon.test.se/test.m3u8"
+        stream.watch_timeout = 10
+        time.return_value = 1
+        expected = "http://filmon.test.se/test.m3u8"
+
+        self.assertEqual(expected, stream_to_url(stream))
+        self.assertEqual(expected, stream.to_url())
+
+    @patch("time.time")
+    @patch("streamlink.plugins.filmon.FilmOnHLS.url", new_callable=PropertyMock)
+    def test_filmon_expired_stream(self, url, time):
+        stream = FilmOnHLS(self.session, channel="test")
+        url.return_value = "http://filmon.test.se/test.m3u8"
+        stream.watch_timeout = 0
+        time.return_value = 1
+
+        self.assertEqual(None, stream_to_url(stream))
+        self.assertRaises(TypeError, stream.to_url)