# -*- coding: utf-8 -*-
#
# Copyright (c) 2008 David JL <izimobil@gmail.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the "Software"),
# to deal in the Software without restriction, including without limitation
# the rights to use, copy, modify, merge, publish, distribute, sublicense,
# and/or sell copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.

"""
Jamendo totem plugin (http://www.jamendo.com).

TODO:
- store thumbnails in relevant XDG directories (?)
- cleanup the notebook code
- interface with jamendo write API (not documented yet):
  favorites, comments, etc...
"""

import os
import totem
import gconf
import gobject
import gtk
import pango
import socket
import threading
import time
import urllib
import urllib2
from xml.sax.saxutils import escape
try:
    import json
except ImportError:
    try:
        import simplejson as json
    except ImportError:
        dlg = gtk.MessageDialog(
            type=gtk.MESSAGE_ERROR,
            buttons=gtk.BUTTONS_OK
        )
        dlg.set_markup(_('You need to install the Python simplejson module.'))
        dlg.run()
        dlg.destroy()
        raise

socket.setdefaulttimeout(30)
gobject.threads_init()
gconf_key = '/apps/totem/plugins/jamendo'


class JamendoPlugin(totem.Plugin):
    """
    Jamendo totem plugin GUI.
    """
    SEARCH_CRITERIA = ['artist_name', 'tag_idstr']
    AUDIO_FORMATS   = ['ogg2', 'mp31']
    TAB_RESULTS     = 0
    TAB_POPULAR     = 1
    TAB_LATEST      = 2

    def __init__(self):
        totem.Plugin.__init__(self)
        self.debug = True
        self.gstreamer_plugins_present = True
        self.totem = None
        self.gconf = gconf.client_get_default()
        self.init_settings()

    def activate(self, totem_object):
        """
        Plugin activation.
        """
        # Initialise the interface
        self.builder = self.load_interface("jamendo.ui", True,
            totem_object.get_main_window(), self)
        self.config_dialog = self.builder.get_object('config_dialog')
        self.popup = self.builder.get_object('popup_menu')
        container = self.builder.get_object('container')
        self.notebook = self.builder.get_object('notebook')
        self.search_entry = self.builder.get_object('search_entry')
        self.search_combo = self.builder.get_object('search_combo')
        self.search_combo.set_active(0)
        self.album_button = self.builder.get_object('album_button')
        self.previous_button = self.builder.get_object('previous_button')
        self.next_button = self.builder.get_object('next_button')
        self.progressbars = [
            self.builder.get_object('results_progressbar'),
            self.builder.get_object('popular_progressbar'),
            self.builder.get_object('latest_progressbar'),
        ]
        self.treeviews = [
            self.builder.get_object('results_treeview'),
            self.builder.get_object('popular_treeview'),
            self.builder.get_object('latest_treeview'),
        ]
        self.setup_treeviews()

        # Set up signals
        self.builder.get_object('search_button').connect('clicked', self.on_search_button_clicked)
        self.search_entry.connect('activate', self.on_search_entry_activate)
        self.notebook.connect('switch-page', self.on_notebook_switch_page)
        self.previous_button.connect('clicked', self.on_previous_button_clicked)
        self.next_button.connect('clicked', self.on_next_button_clicked)
        self.album_button.connect('clicked', self.on_album_button_clicked)
        self.builder.get_object('cancel_button').connect('clicked', self.on_cancel_button_clicked)
        self.builder.get_object('ok_button').connect('clicked', self.on_ok_button_clicked)
        self.builder.get_object('add_to_playlist').connect('activate', self.on_add_to_playlist_activate)
        self.builder.get_object('jamendo_album_page').connect('activate', self.on_open_jamendo_album_page_activate)

        self.totem = totem_object
        self.reset()
        container.show_all()
        self.totem.add_sidebar_page("jamendo", _("Jamendo"), container)

    def deactivate(self, totem_object):
        """
        Plugin deactivation.
        """
        totem_object.remove_sidebar_page("jamendo")

    def create_configure_dialog(self, *args):
        """
        Plugin config dialog.
        """
        format = self.gconf.get_string('%s/format' % gconf_key)
        num_per_page = self.gconf.get_int('%s/num_per_page' % gconf_key)
        combo = self.builder.get_object('preferred_format_combo')
        combo.set_active(self.AUDIO_FORMATS.index(format))
        spinbutton = self.builder.get_object('album_num_spinbutton')
        spinbutton.set_value(num_per_page)
        self.config_dialog.set_default_response(gtk.RESPONSE_OK)
        return self.config_dialog

    def reset(self):
        """
        XXX this will be refactored asap.
        """
        self.current_page = {
            self.TAB_RESULTS: 1,
            self.TAB_POPULAR: 1,
            self.TAB_LATEST : 1
        }
        self.running_threads = {
            self.TAB_RESULTS: False,
            self.TAB_POPULAR: False,
            self.TAB_LATEST : False
        }
        self.pages = {
            self.TAB_RESULTS: [],
            self.TAB_POPULAR: [],
            self.TAB_LATEST : []
        }
        self.album_count = [0, 0, 0]
        for tv in self.treeviews:
            tv.get_model().clear()
        self._update_buttons_state()

    def init_settings(self):
        """
        Initialize plugin settings.
        """
        format = self.gconf.get_string('%s/format' % gconf_key)
        if not format:
            format = 'ogg2'
            self.gconf.set_string('%s/format' % gconf_key, format)
        num_per_page = self.gconf.get_int('%s/num_per_page' % gconf_key)
        if not num_per_page:
            num_per_page = 10
            self.gconf.set_int('%s/num_per_page' % gconf_key, num_per_page)
        JamendoService.AUDIO_FORMAT = format
        JamendoService.NUM_PER_PAGE = num_per_page

    def setup_treeviews(self):
        """
        Setup the 3 treeview: result, popular and latest
        """
        self.current_treeview = self.treeviews[0]
        for w in self.treeviews:
            w.get_selection().set_mode(gtk.SELECTION_MULTIPLE)

            # build pixbuf column
            cell = gtk.CellRendererPixbuf()
            col = gtk.TreeViewColumn()
            col.pack_start(cell, True)
            col.set_attributes(cell, pixbuf=1)
            w.append_column(col)

            # build description column
            cell = gtk.CellRendererText()
            cell.set_property('wrap-mode', pango.WRAP_WORD)
            cell.set_property('wrap-width', 30)
            col = gtk.TreeViewColumn()
            col.pack_start(cell, True)
            col.set_attributes(cell, markup=2)
            col.set_expand(True)
            w.append_column(col)
            w.connect_after('size-allocate', self.on_treeview_size_allocate, col, cell)

            # duration column
            cell = gtk.CellRendererText()
            cell.set_property('xalign', 1.0)
            cell.set_property('size-points', 8)
            col = gtk.TreeViewColumn()
            col.pack_start(cell, True)
            col.set_attributes(cell, markup=3)
            col.set_alignment(1.0)
            w.append_column(col)

            # configure the treeview
            w.set_show_expanders(False) # we manage internally expand/collapse
            w.set_tooltip_column(4)     # set the tooltip column

            # Connect signals
            w.connect("button-press-event", self.on_treeview_row_clicked)
            w.connect("row-activated", self.on_treeview_row_activated)


    def add_treeview_item(self, treeview, album):
        if not isinstance(album['image'], gtk.gdk.Pixbuf):
            # album image pixbuf is not yet built
            try:
                pb = gtk.gdk.pixbuf_new_from_file(album['image'])
                os.unlink(album['image'])
                album['image'] = pb
            except:
                # do not fail for this, just display a dummy pixbuf
                album['image'] = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, True,
                    8, 1, 1)
        # format title
        title  = '<b>%s</b>\n' % self._format_str(album['name'])
        title += _('Artist: %s') % self._format_str(album['artist_name'])
        # format duration
        dur = self._format_duration(album['duration'])
        # format tooltip
        try:
            # Translators: this is the release date of an album in Python strptime format
            release = time.strptime(album['dates']['release'][0:10], _('%Y-%m-%d'))
            # Translators: this is the release time of an album in Python strftime format
            release = time.strftime(_('%x'), release)
        except:
            release = ''
        tip = '\n'.join([
            '<b>%s</b>' % self._format_str(album['name']),
            _('Artist: %s') % self._format_str(album['artist_name']),
            _('Genre: %s') % self._format_str(album['genre']),
            _('Released on: %s') % release,
            _('License: %s') % self._format_str(album['license'][0]),
        ])
        # append album row
        parent = treeview.get_model().append(None,
            [album, album['image'], title, dur, tip]
        )
        # append track rows
        icon = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, True, 8, 1, 1)
        for i, track in enumerate(album['tracks']):
            # track title
            # Translators: this is the title of a track in Python format
            # (first argument is the track number, second is the track title)
            tt = ('<small>%s</small>' % _('%02d. %s')) % \
                (i+1, self._format_str(track['name']))
            # track duration
            td = self._format_duration(track['duration'])
            # track tooltip
            tip = '\n'.join([
                '<b>%s</b>' %  self._format_str(track['name']),
                _('Album: %s') % self._format_str(album['name']),
                _('Artist: %s') % self._format_str(album['artist_name']),
                _('Duration: %s') % td,
            ])
            # append track
            treeview.get_model().append(parent, [track, icon, tt, td, tip])
        # update current album count
        pindex = self.treeviews.index(treeview)
        self.album_count[pindex] += 1

    def add_album_to_playlist(self, mode, album):
        """
        Add an album to the playlist, mode can be: replace, enqueue or
        enqueue_and_play.
        """
        for i, track in enumerate(album['tracks']):
            if mode in ('replace', 'enqueue_and_play'):
                if i == 0:
                    # play first track
                    self.add_track_to_playlist(mode, track)
                else:
                    # and enqueue other tracks
                    self.add_track_to_playlist('enqueue', track)
            else:
                self.add_track_to_playlist('enqueue', track)

    def add_track_to_playlist(self, mode, t):
        """
        Add a track to the playlist, mode can be: replace, enqueue or
        enqueue_and_play.
        """
        if mode == 'replace':
            self.totem.action_remote(totem.REMOTE_COMMAND_REPLACE, t['stream'])
        elif mode == 'enqueue':
            self.totem.action_remote(totem.REMOTE_COMMAND_ENQUEUE, t['stream'])

    def fetch_albums(self, pn=1):
        """
        Initialize the fetch thread.
        """
        tab_index = self.treeviews.index(self.current_treeview)
        if tab_index == self.TAB_POPULAR:
            params = {'order': 'rating_desc'}
        elif tab_index == self.TAB_LATEST:
            params = {'order': 'date_desc'}
        else:
            value = self.search_entry.get_text()
            if not value:
                return
            prop = self.SEARCH_CRITERIA[self.search_combo.get_active()]
            params = {'order': 'date_desc', prop: value}
        params['pn'] = pn
        self.current_treeview.get_model().clear()
        self.previous_button.set_sensitive(False)
        self.next_button.set_sensitive(False)
        self.album_button.set_sensitive(False)
        self.progressbars[tab_index].show()
        self.progressbars[tab_index].set_fraction(0.0)
        self.progressbars[tab_index].set_text(
            _('Fetching albums, please wait...')
        )
        lcb = (self.on_fetch_albums_loop, self.current_treeview)
        dcb = (self.on_fetch_albums_done, self.current_treeview)
        ecb = (self.on_fetch_albums_error, self.current_treeview)
        thread = JamendoService(params, lcb, dcb, ecb)
        thread.start()
        self.running_threads[tab_index] = True

    def on_fetch_albums_loop(self, treeview, album):
        """
        Add an album item and its tracks to the current treeview.
        """
        self.add_treeview_item(treeview, album)
        # pulse progressbar
        pindex = self.treeviews.index(treeview)
        self.progressbars[pindex].set_fraction(
            float(self.album_count[pindex]) / float(JamendoService.NUM_PER_PAGE)
        )

    def on_fetch_albums_done(self, treeview, albums, save_state=True):
        """
        Called when the thread finished fetching albums.
        """
        pindex = self.treeviews.index(treeview)
        model = treeview.get_model()
        if save_state and len(albums):
            self.pages[pindex].append(albums)
            self.current_page[pindex] = len(self.pages[pindex])
        self._update_buttons_state()
        self.progressbars[pindex].set_fraction(0.0)
        self.progressbars[pindex].hide()
        self.album_count[pindex] = 0
        self.running_threads[pindex] = False

    def on_fetch_albums_error(self, treeview, exc):
        """
        Called when an error occured in the thread.
        """
        self.reset()
        pindex = self.treeviews.index(treeview)
        self.progressbars[pindex].set_fraction(0.0)
        self.progressbars[pindex].hide()
        self.running_threads[pindex] = False
        dlg = gtk.MessageDialog(
            type=gtk.MESSAGE_ERROR,
            buttons=gtk.BUTTONS_OK
        )
        dlg.set_markup(
            '<b>%s</b>' % _('An error occurred while fetching albums.')
        )
        # managing exceptions with urllib is a real PITA... :(
        if hasattr(exc, 'reason'):
            try:
                reason = exc.reason[1]
            except:
                try:
                    reason = exc.reason[0]
                except:
                    reason = str(exc)
            reason = reason.capitalize()
            msg = _('Failed to connect to Jamendo server.\n%s.') % reason
        elif hasattr(exc, 'code'):
            msg = _('The Jamendo server returned code %s.') % exc.code
        else:
            msg = str(exc)
        dlg.format_secondary_text(msg)
        dlg.run()
        dlg.destroy()

    def on_search_entry_activate(self, *args):
        """
        Called when the user typed <enter> in the search entry.
        """
        return self.on_search_button_clicked()

    def on_search_button_clicked(self, *args):
        """
        Called when the user clicked on the search button.
        """
        if not self.search_entry.get_text():
            return
        if self.current_treeview != self.treeviews[self.TAB_RESULTS]:
            self.current_treeview = self.treeviews[self.TAB_RESULTS]
            self.notebook.set_current_page(self.TAB_RESULTS)
        else:
            self.on_notebook_switch_page(new_search=True)

    def on_notebook_switch_page(self, nb=None, tab=None, tab_num=0,
        new_search=False):
        """
        Called when the changed a notebook page.
        """
        self.current_treeview = self.treeviews[int(tab_num)]
        self._update_buttons_state()
        model = self.current_treeview.get_model()
        # fetch popular and latest albums only once
        if self.running_threads[int(tab_num)] == True or \
           (not new_search and len(model)):
            return
        if new_search:
            self.current_page[self.TAB_RESULTS] = 1
            self.pages[self.TAB_RESULTS] = []
            self.album_count[self.TAB_RESULTS] = 0
            self._update_buttons_state()
        model.clear()
        self.fetch_albums()

    def on_treeview_row_activated(self, tv, path, column):
        """
        Called when the user double-clicked on a treeview element.
        """
        try:
            item = self._get_selection()[0] # first item selected
        except:
            return
        if len(path) == 1:
            self.add_album_to_playlist('replace', item)
        else:
            self.add_track_to_playlist('replace', item)

    def on_treeview_row_clicked(self, tv, evt):
        """
        Called when the user clicked on a treeview element.
        """
        try:
            if evt.button == 3:
                path = tv.get_path_at_pos(int(evt.x), int(evt.y))
                sel  = tv.get_selection()
                rows = sel.get_selected_rows()
                if path[0] not in rows[1]:
                    sel.unselect_all()
                    sel.select_path(path[0])
                tv.grab_focus()
                self.popup.popup(None, None, None, evt.button, evt.time)
                return True
            coords = evt.get_coords()
            path, c, x, y = tv.get_path_at_pos(int(coords[0]), int(coords[1]))
            if (len(path) == 1):
                if tv.row_expanded(path):
                    tv.collapse_row(path)
                else:
                    tv.expand_row(path, False)
            self.album_button.set_sensitive(True)
        except:
            pass

    def on_treeview_size_allocate(self, tv, allocation, col, cell):
        """
        Hack to autowrap text of the title colum.
        """
        cols = (c for c in tv.get_columns() if c != col)
        w = allocation.width - sum(c.get_width() for c in cols)
        if cell.props.wrap_width == w or w <= 0:
            return
        cell.props.wrap_width = w

    def on_previous_button_clicked(self, *args):
        """
        Called when the user clicked the previous button.
        """
        self._update_buttons_state()
        model = self.current_treeview.get_model()
        model.clear()
        pindex = self.treeviews.index(self.current_treeview)
        self.current_page[pindex] -= 1
        albums = self.pages[pindex][self.current_page[pindex]-1]
        for album in albums:
            self.add_treeview_item(self.current_treeview, album)
        self.on_fetch_albums_done(self.current_treeview, albums, False)

    def on_next_button_clicked(self, *args):
        """
        Called when the user clicked the next button.
        """
        self._update_buttons_state()
        model = self.current_treeview.get_model()
        model.clear()
        pindex = self.treeviews.index(self.current_treeview)
        if self.current_page[pindex] == len(self.pages[pindex]):
            self.fetch_albums(self.current_page[pindex]+1)
        else:
            self.current_page[pindex] += 1
            albums = self.pages[pindex][self.current_page[pindex]-1]
            for album in albums:
                self.add_treeview_item(self.current_treeview, album)
            self.on_fetch_albums_done(self.current_treeview, albums, False)

    def on_album_button_clicked(self, *args):
        """
        Called when the user clicked on the album button.
        """
        try:
            url = self._get_selection(True)[0]['url']
            os.spawnlp(os.P_NOWAIT, "xdg-open", "xdg-open", url)
        except:
            pass

    def on_cancel_button_clicked(self, *args):
        """
        Called when the user clicked cancel in the config dialog.
        """
        self.config_dialog.hide()

    def on_ok_button_clicked(self, *args):
        """
        Called when the user clicked ok in the config dialog.
        """
        combo = self.builder.get_object('preferred_format_combo')
        spinbutton = self.builder.get_object('album_num_spinbutton')
        format = self.AUDIO_FORMATS[combo.get_active()]
        self.gconf.set_string('%s/format' % gconf_key, format)
        num_per_page = int(spinbutton.get_value())
        self.gconf.set_int('%s/num_per_page' % gconf_key, num_per_page)
        self.init_settings()
        self.config_dialog.hide()
        try:
            self.reset()
        except:
            pass

    def on_add_to_playlist_activate(self, *args):
        """
        Called when the user clicked on the add to playlist button of the
        popup menu.
        """
        items = self._get_selection()
        for item in items:
            if 'tracks' in item:
                # we have an album
                self.add_album_to_playlist('enqueue', item)
            else:
                # we have a track
                self.add_track_to_playlist('enqueue', item)

    def on_open_jamendo_album_page_activate(self, *args):
        """
        Called when the user clicked on the jamendo album page button of the
        popup menu.
        """
        return self.on_album_button_clicked()

    def _get_selection(self, root=False):
        """
        Shortcut method to retrieve the treeview items selected.
        """
        ret = []
        sel = self.current_treeview.get_selection()
        model, rows = sel.get_selected_rows()
        for row in rows:
            if root:
                it = model.get_iter((row[0],))
            else:
                it = model.get_iter(row)
            elt = model.get(it, 0)[0]
            if elt not in ret:
                ret.append(elt)
        return ret

    def _update_buttons_state(self):
        """
        Update the state of the previous and next buttons.
        """
        sel = self.current_treeview.get_selection()
        model, rows = sel.get_selected_rows()
        try:
            it = model.get_iter(rows[0])
        except:
            it = None
        pindex = self.treeviews.index(self.current_treeview)
        self.previous_button.set_sensitive(self.current_page[pindex] > 1)
        self.next_button.set_sensitive(len(model)==JamendoService.NUM_PER_PAGE)
        self.album_button.set_sensitive(it is not None)


    def _format_str(self, st, truncate=False):
        """
        Escape entities for pango markup and force the string to utf-8.
        """
        if not st:
            return ''
        try:
            return escape(st.encode('utf8'))
        except:
            return st

    def _format_duration(self, secs):
        """
        Format the given number of seconds to a human readable duration.
        """
        try:
            secs = int(secs)
            if secs >= 3600:
                # Translators: time formatting (in Python strftime format) for the Jamendo plugin
                # for times longer than an hour
                return time.strftime(_('%H:%M:%S'), time.gmtime(secs))
            # Translators: time formatting (in Python strftime format) for the Jamendo plugin
            # for times shorter than an hour
            return time.strftime(_('%M:%S'), time.gmtime(secs))
        except:
            return ''


class JamendoService(threading.Thread):
    """
    Class that requests the jamendo REST service.
    """

    API_URL = 'http://api.jamendo.com/get2'
    AUDIO_FORMAT = 'ogg2'
    NUM_PER_PAGE = 10

    def __init__(self, params, loop_cb, done_cb, error_cb):
        self.params = params
        self.loop_cb = loop_cb
        self.done_cb = done_cb
        self.error_cb = error_cb
        self.lock = threading.Lock()
        threading.Thread.__init__(self)

    def run(self):
        url = '%s/id+name+duration+image+genre+dates+url+artist_id+' \
              'artist_name+artist_url/album/json/?n=%s&imagesize=50' % \
              (self.API_URL, self.NUM_PER_PAGE)
        if len(self.params):
            url += '&%s' % urllib.urlencode(self.params)
        try:
            self.lock.acquire()
            albums = json.loads(self._request(url))
            ret = []
            for i, album in enumerate(albums):
                fname, headers = urllib.urlretrieve(album['image'])
                album['image'] = fname
                album['tracks'] = json.loads(self._request(
                    '%s/id+name+duration+stream/track/json/?album_id=%s'\
                    '&order=numalbum_asc' % (self.API_URL, album['id'])
                ))
                album['license'] = json.loads(self._request(
                    '%s/name/license/json/album_license/?album_id=%s'\
                    % (self.API_URL, album['id'])
                ))
                # Translators: If Jamendo supports your language, replace "en" with the language code, enclosed
                # in slashes, used to view pages in your language on the Jamendo website. e.g. For French, "en"
                # would be translated to "fr", as Jamendo uses that in its URLs:
                #  http://www.jamendo.com/fr/album/4818
                # Compared to:
                #  http://www.jamendo.com/en/album/4818
                # If Jamendo doesn't support your language, *do not translate this string*!
                album['url'] = album['url'].replace('/en/', '/' + _('en') + '/')
                gobject.idle_add(self.loop_cb[0], self.loop_cb[1], album)
            gobject.idle_add(self.done_cb[0], self.done_cb[1], albums)
        except Exception, exc:
            gobject.idle_add(self.error_cb[0], self.error_cb[1], exc)
        finally:
            self.lock.release()

    def _request(self, url):
        opener = urllib2.build_opener()
        opener.addheaders = [('User-agent', 'Totem Jamendo plugin')]
        handle = opener.open(url)
        data = handle.read()
        handle.close()
        return data

