diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 6a1d7f6b..6f539707 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -11,7 +11,7 @@ import pykka from mopidy.utils import process -from . import mixers, utils +from . import mixers, playlists, utils from .constants import PlaybackState from .listener import AudioListener @@ -19,6 +19,9 @@ logger = logging.getLogger('mopidy.audio') mixers.register_mixers() +playlists.register_typefinders() +playlists.register_elements() + MB = 1 << 20 diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py new file mode 100644 index 00000000..e3f51e41 --- /dev/null +++ b/mopidy/audio/playlists.py @@ -0,0 +1,412 @@ +from __future__ import unicode_literals + +import pygst +pygst.require('0.10') +import gst +import gobject + +import ConfigParser as configparser +import io + +try: + import xml.etree.cElementTree as elementtree +except ImportError: + import xml.etree.ElementTree as elementtree + + +# TODO: make detect_FOO_header reusable in general mopidy code. +# i.e. give it just a "peek" like function. +def detect_m3u_header(typefind): + return typefind.peek(0, 8) == b'#EXTM3U\n' + + +def detect_pls_header(typefind): + return typefind.peek(0, 11).lower() == b'[playlist]\n' + + +def detect_xspf_header(typefind): + data = typefind.peek(0, 150) + if b'xspf' not in data: + return False + + try: + data = io.BytesIO(data) + for event, element in elementtree.iterparse(data, events=(b'start',)): + return element.tag.lower() == '{http://xspf.org/ns/0/}playlist' + except elementtree.ParseError: + pass + return False + + +def detect_asx_header(typefind): + data = typefind.peek(0, 50) + if b'asx' not in data: + return False + + try: + data = io.BytesIO(data) + for event, element in elementtree.iterparse(data, events=(b'start',)): + return element.tag.lower() == 'asx' + except elementtree.ParseError: + pass + return False + + +def parse_m3u(data): + # TODO: convert non URIs to file URIs. + found_header = False + for line in data.readlines(): + if found_header or line.startswith('#EXTM3U'): + found_header = True + else: + continue + if not line.startswith('#') and line.strip(): + yield line.strip() + + +def parse_pls(data): + # TODO: convert non URIs to file URIs. + try: + cp = configparser.RawConfigParser() + cp.readfp(data) + except configparser.Error: + return + + for section in cp.sections(): + if section.lower() != 'playlist': + continue + for i in xrange(cp.getint(section, 'numberofentries')): + yield cp.get(section, 'file%d' % (i+1)) + + +def parse_xspf(data): + try: + for event, element in elementtree.iterparse(data): + element.tag = element.tag.lower() # normalize + except elementtree.ParseError: + return + + ns = 'http://xspf.org/ns/0/' + for track in element.iterfind('{%s}tracklist/{%s}track' % (ns, ns)): + yield track.findtext('{%s}location' % ns) + + +def parse_asx(data): + try: + for event, element in elementtree.iterparse(data): + element.tag = element.tag.lower() # normalize + except elementtree.ParseError: + return + + for ref in element.findall('entry/ref'): + yield ref.get('href', '').strip() + + +def parse_urilist(data): + for line in data.readlines(): + if not line.startswith('#') and gst.uri_is_valid(line.strip()): + yield line + + +def playlist_typefinder(typefind, func, caps): + if func(typefind): + typefind.suggest(gst.TYPE_FIND_MAXIMUM, caps) + + +def register_typefind(mimetype, func, extensions): + caps = gst.caps_from_string(mimetype) + gst.type_find_register(mimetype, gst.RANK_PRIMARY, playlist_typefinder, + extensions, caps, func, caps) + + +def register_typefinders(): + register_typefind('audio/x-mpegurl', detect_m3u_header, [b'm3u', b'm3u8']) + register_typefind('audio/x-scpls', detect_pls_header, [b'pls']) + register_typefind('application/xspf+xml', detect_xspf_header, [b'xspf']) + # NOTE: seems we can't use video/x-ms-asf which is the correct mime for asx + # as it is shared with asf for streaming videos :/ + register_typefind('audio/x-ms-asx', detect_asx_header, [b'asx']) + + +class BasePlaylistElement(gst.Bin): + """Base class for creating GStreamer elements for playlist support. + + This element performs the following steps: + + 1. Initializes src and sink pads for the element. + 2. Collects data from the sink until EOS is reached. + 3. Passes the collected data to :meth:`convert` to get a list of URIs. + 4. Passes the list of URIs to :meth:`handle`, default handling is to pass + the URIs to the src element as a uri-list. + 5. If handle returned true, the EOS consumed and nothing more happens, if + it is not consumed it flows on to the next element downstream, which is + likely our uri-list consumer which needs the EOS to know we are done + sending URIs. + """ + + sinkpad_template = None + """GStreamer pad template to use for sink, must be overriden.""" + + srcpad_template = None + """GStreamer pad template to use for src, must be overriden.""" + + ghost_srcpad = False + """Indicates if src pad should be ghosted or not.""" + + def __init__(self): + """Sets up src and sink pads plus behaviour.""" + super(BasePlaylistElement, self).__init__() + self._data = io.BytesIO() + self._done = False + + self.sinkpad = gst.Pad(self.sinkpad_template) + self.sinkpad.set_chain_function(self._chain) + self.sinkpad.set_event_function(self._event) + self.add_pad(self.sinkpad) + + if self.ghost_srcpad: + self.srcpad = gst.ghost_pad_new_notarget('src', gst.PAD_SRC) + else: + self.srcpad = gst.Pad(self.srcpad_template) + self.add_pad(self.srcpad) + + def convert(self, data): + """Convert the data we have colleted to URIs. + + :param data: collected data buffer + :type data: :class:`io.BytesIO` + :returns: iterable or generator of URIs + """ + raise NotImplementedError + + def handle(self, uris): + """Do something useful with the URIs. + + :param uris: list of URIs + :type uris: :type:`list` + :returns: boolean indicating if EOS should be consumed + """ + # TODO: handle unicode uris which we can get out of elementtree + self.srcpad.push(gst.Buffer('\n'.join(uris))) + return False + + def _chain(self, pad, buf): + if not self._done: + self._data.write(buf.data) + return gst.FLOW_OK + return gst.FLOW_EOS + + def _event(self, pad, event): + if event.type == gst.EVENT_NEWSEGMENT: + return True + + if event.type == gst.EVENT_EOS: + self._done = True + self._data.seek(0) + if self.handle(list(self.convert(self._data))): + return True + + # Ensure we handle remaining events in a sane way. + return pad.event_default(event) + + +class M3uDecoder(BasePlaylistElement): + __gstdetails__ = ('M3U Decoder', + 'Decoder', + 'Convert .m3u to text/uri-list', + 'Mopidy') + + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, + gst.caps_from_string('audio/x-mpegurl')) + + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + __gsttemplates__ = (sinkpad_template, srcpad_template) + + def convert(self, data): + return parse_m3u(data) + + +class PlsDecoder(BasePlaylistElement): + __gstdetails__ = ('PLS Decoder', + 'Decoder', + 'Convert .pls to text/uri-list', + 'Mopidy') + + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, + gst.caps_from_string('audio/x-scpls')) + + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + __gsttemplates__ = (sinkpad_template, srcpad_template) + + def convert(self, data): + return parse_pls(data) + + +class XspfDecoder(BasePlaylistElement): + __gstdetails__ = ('XSPF Decoder', + 'Decoder', + 'Convert .pls to text/uri-list', + 'Mopidy') + + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, + gst.caps_from_string('application/xspf+xml')) + + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + __gsttemplates__ = (sinkpad_template, srcpad_template) + + def convert(self, data): + return parse_xspf(data) + + +class AsxDecoder(BasePlaylistElement): + __gstdetails__ = ('ASX Decoder', + 'Decoder', + 'Convert .asx to text/uri-list', + 'Mopidy') + + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, + gst.caps_from_string('audio/x-ms-asx')) + + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + __gsttemplates__ = (sinkpad_template, srcpad_template) + + def convert(self, data): + return parse_asx(data) + + +class UriListElement(BasePlaylistElement): + __gstdetails__ = ('URIListDemuxer', + 'Demuxer', + 'Convert a text/uri-list to a stream', + 'Mopidy') + + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, + gst.caps_new_any()) + + ghost_srcpad = True # We need to hook this up to our internal decodebin + + __gsttemplates__ = (sinkpad_template, srcpad_template) + + def __init__(self): + super(UriListElement, self).__init__() + self.uridecodebin = gst.element_factory_make('uridecodebin') + self.uridecodebin.connect('pad-added', self.pad_added) + # Limit to anycaps so we get a single stream out, letting other + # elements downstream figure out actual muxing + self.uridecodebin.set_property('caps', gst.caps_new_any()) + + def pad_added(self, src, pad): + self.srcpad.set_target(pad) + pad.add_event_probe(self.pad_event) + + def pad_event(self, pad, event): + if event.has_name('urilist-played'): + error = gst.GError(gst.RESOURCE_ERROR, gst.RESOURCE_ERROR_FAILED, + b'Nested playlists not supported.') + message = b'Playlists pointing to other playlists is not supported' + self.post_message(gst.message_new_error(self, error, message)) + return 1 # GST_PAD_PROBE_OK + + def handle(self, uris): + struct = gst.Structure('urilist-played') + event = gst.event_new_custom(gst.EVENT_CUSTOM_UPSTREAM, struct) + self.sinkpad.push_event(event) + + # TODO: hookup about to finish and errors to rest of URIs so we + # round robin, only giving up once all have been tried. + # TODO: uris could be empty. + self.add(self.uridecodebin) + self.uridecodebin.set_state(gst.STATE_READY) + self.uridecodebin.set_property('uri', uris[0]) + self.uridecodebin.sync_state_with_parent() + return True # Make sure we consume the EOS that triggered us. + + def convert(self, data): + return parse_urilist(data) + + +class IcySrc(gst.Bin, gst.URIHandler): + __gstdetails__ = ('IcySrc', + 'Src', + 'HTTP src wrapper for icy:// support.', + 'Mopidy') + + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, + gst.caps_new_any()) + + __gsttemplates__ = (srcpad_template,) + + def __init__(self): + super(IcySrc, self).__init__() + self._httpsrc = gst.element_make_from_uri(gst.URI_SRC, 'http://') + try: + self._httpsrc.set_property('iradio-mode', True) + except TypeError: + pass + self.add(self._httpsrc) + + self._srcpad = gst.GhostPad('src', self._httpsrc.get_pad('src')) + self.add_pad(self._srcpad) + + @classmethod + def do_get_type_full(cls): + return gst.URI_SRC + + @classmethod + def do_get_protocols_full(cls): + return [b'icy', b'icyx'] + + def do_set_uri(self, uri): + if uri.startswith('icy://'): + return self._httpsrc.set_uri(b'http://' + uri[len('icy://'):]) + elif uri.startswith('icyx://'): + return self._httpsrc.set_uri(b'https://' + uri[len('icyx://'):]) + else: + return False + + def do_get_uri(self): + uri = self._httpsrc.get_uri() + if uri.startswith('http://'): + return b'icy://' + uri[len('http://'):] + else: + return b'icyx://' + uri[len('https://'):] + + +def register_element(element_class): + gobject.type_register(element_class) + gst.element_register( + element_class, element_class.__name__.lower(), gst.RANK_MARGINAL) + + +def register_elements(): + register_element(M3uDecoder) + register_element(PlsDecoder) + register_element(XspfDecoder) + register_element(AsxDecoder) + register_element(UriListElement) + + # Only register icy if gst install can't handle it on it's own. + if not gst.element_make_from_uri(gst.URI_SRC, 'icy://'): + register_element(IcySrc) diff --git a/tests/audio/playlists_test.py b/tests/audio/playlists_test.py new file mode 100644 index 00000000..0f031736 --- /dev/null +++ b/tests/audio/playlists_test.py @@ -0,0 +1,128 @@ +#encoding: utf-8 + +from __future__ import unicode_literals + +import io +import unittest + +from mopidy.audio import playlists + + +BAD = b'foobarbaz' + +M3U = b"""#EXTM3U +#EXTINF:123, Sample artist - Sample title +file:///tmp/foo +#EXTINF:321,Example Artist - Example title +file:///tmp/bar +#EXTINF:213,Some Artist - Other title +file:///tmp/baz +""" + +PLS = b"""[Playlist] +NumberOfEntries=3 +File1=file:///tmp/foo +Title1=Sample Title +Length1=123 +File2=file:///tmp/bar +Title2=Example title +Length2=321 +File3=file:///tmp/baz +Title3=Other title +Length3=213 +Version=2 +""" + +ASX = b""" + Example + + Sample Title + + + + Example title + + + + Other title + + + +""" + +XSPF = b""" + + + + Sample Title + file:///tmp/foo + + + Example title + file:///tmp/bar + + + Other title + file:///tmp/baz + + + +""" + + +class TypeFind(object): + def __init__(self, data): + self.data = data + + def peek(self, start, end): + return self.data[start:end] + + +class BasePlaylistTest(object): + valid = None + invalid = None + detect = None + parse = None + + def test_detect_valid_header(self): + self.assertTrue(self.detect(TypeFind(self.valid))) + + def test_detect_invalid_header(self): + self.assertFalse(self.detect(TypeFind(self.invalid))) + + def test_parse_valid_playlist(self): + uris = list(self.parse(io.BytesIO(self.valid))) + expected = [b'file:///tmp/foo', b'file:///tmp/bar', b'file:///tmp/baz'] + self.assertEqual(uris, expected) + + def test_parse_invalid_playlist(self): + uris = list(self.parse(io.BytesIO(self.invalid))) + self.assertEqual(uris, []) + + +class M3uPlaylistTest(BasePlaylistTest, unittest.TestCase): + valid = M3U + invalid = BAD + detect = staticmethod(playlists.detect_m3u_header) + parse = staticmethod(playlists.parse_m3u) + + +class PlsPlaylistTest(BasePlaylistTest, unittest.TestCase): + valid = PLS + invalid = BAD + detect = staticmethod(playlists.detect_pls_header) + parse = staticmethod(playlists.parse_pls) + + +class AsxPlsPlaylistTest(BasePlaylistTest, unittest.TestCase): + valid = ASX + invalid = BAD + detect = staticmethod(playlists.detect_asx_header) + parse = staticmethod(playlists.parse_asx) + + +class XspfPlaylistTest(BasePlaylistTest, unittest.TestCase): + valid = XSPF + invalid = BAD + detect = staticmethod(playlists.detect_xspf_header) + parse = staticmethod(playlists.parse_xspf)