#!/usr/bin/env python3

import abc
import gi
import os
import signal
import urllib.parse

gi.require_version('Soup', '2.4')
gi.require_version('GLib', '2.0')
gi.require_version('Grl',  '0.3')

from gi.repository import Soup
from gi.repository import GLib
from gi.repository import Grl

PORT = 8080

cache = {}

class CacheItem:

    '''Store a cache item

    A CacheItem object stores the Grilo sources and (optional) media
    associated with a path.

    '''

    __metaclass__ = abc.ABCMeta

    @abc.abstractmethod
    def __getitem__(self, key):
        pass

    def __contains__(self, key):
        try:
            found = self[key] != None
        except KeyError:
            found = False
        return found

    @abc.abstractmethod
    def html(self):
        pass

class CacheItemSource(CacheItem):

    '''Store a source cache item

    A CacheItemSource object stores any number of Grilo sources and the
    HTML associated with a path.

    '''

    def __init__(self, sources):
        self.sources = sources

    def __getitem__(self, key):
        found = False
        media = None

        for source in self.sources:
            if source.get_name() == key:
                found = True
                break

        if found:
            return source, media
        else:
            raise KeyError

    def html(self, dir):
        str = ''

        for source in self.sources:
            name = source.get_name()
            str += '<a href="/{}/">{}</a><br/>'.format(name, name)

        return str

class CacheItemMedia(CacheItem):

    '''Store a media cache item

    A CacheItemMedia object stores a single Grilo source, any number
    of media records, and the HTML associated with a path.

    '''

    def __init__(self, source, media):
        self.source = source
        self.media  = media

    def __getitem__(self, key):
        found  = False
        media  = None

        for media in self.media:
            if media.get_title() == key:
                found = True
                break

        if found:
            return self.source, media
        else:
            raise KeyError

    def html(self, dir):
        str = ''

        for media in self.media:
            name = media.get_title()
            url  = media.get_url()

            if media.is_video():
                str += '''<video src="{}" controls></video>
                           <a href="{}">Download {}</a><br/>'''.format(url, url, name)
            elif media.is_audio():
                str += '''<audio src="{}" controls></audio>
                           <a href="{}">Download {}</a><br/>'''.format(url, url, name)
            elif media.is_image():
                str += '''<img src="{}" width="64"></img>
                           <a href="{}">Download {}</a><br/>'''.format(url, url, name)
            elif media.is_container():
                str += '<a href="{}/{}/">{}</a><br/>'.format(dir, name, name)
            else:
                # Invalid media type.
                assert(False)

        return str

def handle_next_path_item(path, server, msg):
    '''

    Given a path, walk it, ensuring each sub-path exists in the
    cache. Request that Grilo load the media which corresponds to the
    first sub-path not already cached. Register a callback which will add the
    result to the cache and either (1) respond to the HTTP request or (2) call
    handle_next_path_item again to continue.

    '''
    nodes = path.split('/')
    for i in range(len(nodes)):
        path = '/' + os.path.join(*nodes[:i + 1])
        rest = os.path.join(*nodes[i + 1:]) if i + 1 < len(nodes) else None
        if not path in cache:
            head = os.path.dirname(path)
            tail = os.path.basename(path)

            if tail in cache[head]:
                source, media = cache[head][tail]
            else:
                # Likely a request not available by Grilo, such as favicon.ico.
                msg.set_status(Soup.Status.NOT_FOUND)
                msg.set_response('text/html', Soup.MemoryUse.COPY,
                                 (tail + ' not found').encode())
                return

            keys = [ Grl.METADATA_KEY_ALBUM,
                     Grl.METADATA_KEY_ARTIST,
                     Grl.METADATA_KEY_TITLE,
                     Grl.METADATA_KEY_TRACK_NUMBER,
                     Grl.METADATA_KEY_URL,
                     Grl.METADATA_KEY_AUDIO_TRACK ]
            caps = source.get_caps(Grl.SupportedOps.BROWSE)
            # FIXME: What is wrong with this?
            # options = Grl.OperationOptions(caps)
            options = Grl.OperationOptions()

            # Create callback for additional Grilo data.
            source.browse(media, keys, options, grl_source_result_callback,
                          path, rest, server, msg)
            server.pause_message(msg)

            return

def grl_source_added_callback(registry, source):
    '''

    Callback invoked by Grilo when a new source becomes available.
    Either append the source to existing cache entry, or create new cache
    entry. Since this deals with top-level sources, the associated path
    (key) is always '/'.

    '''
    path = '/'

    if source.supported_operations() & Grl.SupportedOps.BROWSE == 0:
        return

    if path in cache:
        cache[path] = CacheItemSource(cache[path].sources + [source])
    else:
        cache[path] = CacheItemSource([source])

def grl_source_result_callback(source, operation_id, media, remaining, path,
                               rest, server, msg, user_data):
    '''

    Callback invoked by Grilo when a new media element becomes avaialable.
    Either append the media to an existing cache entry, or create a new
    cache entry. If Grilo indicated that this is the last media element,
    then respond to HTTP request.  Otherwise, if there remain sub-paths
    to process, then call handle_next_path_item.

    '''

    if path in cache:
        cache[path] = CacheItemMedia(source, cache[path].media + [media])
    else:
        cache[path] = CacheItemMedia(source, [media])

    if rest == None:
        if remaining == 0:
            html = cache[path].html(path)
            msg.set_status(Soup.Status.OK)
            msg.set_response('text/html', Soup.MemoryUse.COPY, html.encode())
            server.unpause_message(msg)
        # else Grilo will invoke this callback again for this request.
    else:
        handle_next_path_item(os.path.join(path, rest), server, msg)

def soup_callback(server, msg, path, query, client, user_data):
    '''

    Callback invoked by SOUP upon receiving an HTTP request.  Immediately
    respond if the requested path exists in the cache; otherwise, call
    handle_next_path_item to use Grilo to obtain data necessary for
    a response.

    '''
    path = os.path.normpath(urllib.parse.unquote_plus(path))
    if path in cache:
        html = cache[path].html(path)
        msg.set_status(Soup.Status.OK)
        msg.set_response('text/html', Soup.MemoryUse.COPY, html.encode())
        server.unpause_message(msg)
    else:
        handle_next_path_item(path, server, msg)

def main():
    signal.signal(signal.SIGINT, signal.SIG_DFL)

    Grl.init([])
    registry = Grl.Registry.get_default()
    registry.load_all_plugins(True)
    registry.connect('source-added', grl_source_added_callback)

    server = Soup.Server(port=PORT)
    server.add_handler('/', soup_callback, None)
    server.run()

if __name__== "__main__":
    main()
