File: lyricsource.py

package info (click to toggle)
osdlyrics 0.5.15%2Bdfsg-2
  • links: PTS, VCS
  • area: main
  • in suites: trixie
  • size: 2,616 kB
  • sloc: ansic: 19,458; python: 4,867; sh: 572; makefile: 366; sed: 16
file content (415 lines) | stat: -rw-r--r-- 15,234 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
# -*- coding: utf-8 -*-
#
# Copyright (C) 2012 Tiger Soldier <tigersoldi@gmail.com>
#
# This file is part of OSD Lyrics.
#
# OSD Lyrics is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# OSD Lyrics is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with OSD Lyrics.  If not, see <https://www.gnu.org/licenses/>.
#

import logging
import threading

import dbus

from .app import App
from .config import Config
from .consts import (LYRIC_SOURCE_PLUGIN_INTERFACE,
                     LYRIC_SOURCE_PLUGIN_OBJECT_PATH_PREFIX)
from .dbusext.service import Object as DBusObject, property as dbus_property
from .metadata import Metadata

SEARCH_SUCCEED = 0
SEARCH_CANCELLED = 1
SEARCH_FAILED = 2

DOWNLOAD_SUCCEED = 0
DOWNLOAD_CANCELLED = 1
DOWNLOAD_FAILED = 2


def onmainthread(func):
    def decfunc(self, app, *args, **kwargs):
        def timeout_cb():
            func(self, *args, **kwargs)
            return False
        app.run_on_main_thread(timeout_cb)
    return decfunc


class SearchResult:
    """ Lyrics that match the metadata to be searched.
    """

    def __init__(self, sourceid, downloadinfo, title='', artist='', album='', comment=''):
        """

        Arguments:
        - `title`: The matched lyric title.
        - `artist`: The matched lyric artist.
        - `album`: The matched lyric album.
        - `downloadinfo`: Some additional data that is needed to download the
          lyric. Normally this value is the url or ID of the lyric.
          ``downloadinfo`` MUST be composed with basic types such as numbers,
          lists, dicts or strings so that it can be converted to D-Bus compatible
          dict with `to_dict` method.
        """
        self._title = title
        self._artist = artist
        self._album = album
        self._comment = comment
        self._sourceid = sourceid
        self._downloadinfo = downloadinfo

    def to_dict(self, ):
        """ Convert the result to a dict so that it can be sent with D-Bus.
        """
        return {'title': self._title,
                'artist': self._artist,
                'album': self._album,
                'comment': self._comment,
                'sourceid': self._sourceid,
                'downloadinfo': self._downloadinfo}


class BaseTaskThread(threading.Thread):
    """ Base thread for search or download tasks.

    Plugins MUST provide a callable object as the `target` argument in the
    initializer. The target does the task and returns the results. If task
    fails, an Exception SHOULD be raised in the target.
    """

    def __init__(self, onfinish, onerror, target, args=(), kwargs={}):
        """
        Initialize the thread. The main thread should provide to callbacks
        to notify the main thread that the thread is finished or an error
        occurs. Please note that the two callbacks are run in the new thread,
        not the main thread. So the callback may notify the main thread to
        handle the results with App.run_on_main_thread.

        Arguments:

        - `onfinish`: A callable object to be invoked when `target` finish.
          The callable SHOULD receive the values returned by `do_task`.
        - `onerror`: A callable object to be invoked when `target` raise an
          exception. The callable SHOULD receive the exception raised by
          `do_task`.
        - `target`: The callable object to be invoked by the run() method.
        - `args`: The argument tuple for the target invocation. Defaults to `()`.
        - `kwargs`: A dictionary of keyword arguments for the target invocation.
          Defaults to `{}`.
        """
        super().__init__()
        self._onfinish = onfinish
        self._onerror = onerror
        self._args = args
        self._kwargs = kwargs
        self._target = target

    def run(self):
        """ Runs the task thread. Do NOT override this method. Override
        `do_task` instead.
        """
        try:
            ret = self._target(*self._args, **self._kwargs)
            self._onfinish(ret)
        except Exception as e:
            logging.exception('Got exception in thread')
            self._onerror(e)
        import sys
        sys.stdout.flush()


class BaseLyricSourcePlugin(DBusObject):
    """ Base class for implementing a lyric source plugin
    """

    def __init__(self, id, name=None, watch_daemon=True):
        """
        Create a new lyric source instance.

        Arguments:

        - `id`: The unique ID of the lyric source plugin. The full bus
          name of the plugin will be `org.osdlyrics.LyricSourcePlugin.id`
        - `name`: (optional) The name of the plugin, which should be properly
          localized. If `name` is missing, the plugin will take `id` as its
          name.
        - `watch_daemon`: Whether to watch daemon bus.
        """
        self._id = id
        self._app = App('LyricSourcePlugin.' + id,
                        watch_daemon=watch_daemon)
        super().__init__(conn=self._app.connection,
                         object_path=LYRIC_SOURCE_PLUGIN_OBJECT_PATH_PREFIX + self._id)
        self._search_count = 0
        self._download_count = 0
        self._search_tasks = {}
        self._download_tasks = {}
        self._name = name if name is not None else id
        self._config = None

    def do_search(self, metadata):
        """
        Do the real search work by plugins. All plugins MUST implement this method.

        This method runs in a seperate thread, so don't worry about block IO.

        Parameters:

        - `metadata`: The metadata of the track to search. The type of `metadata`
          is osdlyrics.metadata.Metadata

        Returns: A list of SearchResult objects
        """
        raise NotImplementedError()

    @onmainthread
    def do_searchsuccess(self, ticket, results):
        if ticket in self._search_tasks:
            del self._search_tasks[ticket]
            dbusresults = [result.to_dict() for result in results]
            self.SearchComplete(ticket, SEARCH_SUCCEED, dbusresults)

    @onmainthread
    def do_searchfailure(self, ticket, e):
        if ticket in self._search_tasks:
            del self._search_tasks[ticket]
            logging.info('Search fail, %s', e)
            self.SearchComplete(ticket, SEARCH_FAILED, [])

    @dbus.service.method(dbus_interface=LYRIC_SOURCE_PLUGIN_INTERFACE,
                         in_signature='a{sv}',
                         out_signature='i')
    def Search(self, metadata):
        ticket = self._search_count
        self._search_count = self._search_count + 1
        thread = BaseTaskThread(onfinish=lambda result: self.do_searchsuccess(self._app, ticket, result),
                                onerror=lambda e: self.do_searchfailure(self._app, ticket, e),
                                target=self.do_search,
                                kwargs={'metadata': Metadata.from_dict(metadata)})
        self._search_tasks[ticket] = thread
        thread.start()
        return ticket

    @dbus.service.method(dbus_interface=LYRIC_SOURCE_PLUGIN_INTERFACE,
                         in_signature='i',
                         out_signature='')
    def CancelSearch(self, ticket):
        if ticket in self._search_tasks:
            del self._search_tasks[ticket]
            self.SearchComplete(ticket, SEARCH_CANCELLED, [])

    def do_download(self, downloadinfo):
        """
        Do the real download work by plugins. All plugins MUST implement this
        method.

        This method runs in a seperate thread, so don't worry about block IO.

        Parameters:

        - `downloadinfo`: The additional info taken from `downloadinfo` field in
          SearchResult objects.

        Returns: A string of the lyric content
        """
        raise NotImplementedError()

    @onmainthread
    def do_downloadsuccess(self, ticket, content):
        if ticket in self._download_tasks:
            del self._download_tasks[ticket]
            self.DownloadComplete(ticket, DOWNLOAD_SUCCEED, content)

    @onmainthread
    def do_downloadfailure(self, ticket, e):
        if ticket in self._download_tasks:
            del self._download_tasks[ticket]
            self.DownloadComplete(ticket, DOWNLOAD_FAILED, str(e).encode('utf-8'))

    @dbus.service.method(dbus_interface=LYRIC_SOURCE_PLUGIN_INTERFACE,
                         in_signature='v',
                         out_signature='i')
    def Download(self, downloadinfo):
        ticket = self._download_count
        self._download_count = self._download_count + 1
        thread = BaseTaskThread(onfinish=lambda content: self.do_downloadsuccess(self._app, ticket, content),
                                onerror=lambda e: self.do_downloadfailure(self._app, ticket, e),
                                target=self.do_download,
                                kwargs={'downloadinfo': downloadinfo})
        self._download_tasks[ticket] = thread
        thread.start()
        return ticket

    @dbus.service.method(dbus_interface=LYRIC_SOURCE_PLUGIN_INTERFACE,
                         in_signature='i',
                         out_signature='')
    def CancelDownload(self, ticket):
        if ticket in self._download_tasks:
            del self._download_tasks[ticket]
            self.DownloadComplete(ticket, DOWNLOAD_CANCELLED, '')

    @dbus_property(dbus_interface=LYRIC_SOURCE_PLUGIN_INTERFACE,
                   type_signature='s')
    def Name(self):
        return self._name

    @dbus.service.signal(dbus_interface=LYRIC_SOURCE_PLUGIN_INTERFACE,
                         signature='iiaa{sv}')
    def SearchComplete(self, ticket, status, results):
        logging.debug('search complete: ticket: %d, status: %d', ticket, status)
        pass

    @dbus.service.signal(dbus_interface=LYRIC_SOURCE_PLUGIN_INTERFACE,
                         signature='iiay')
    def DownloadComplete(self, ticket, status, result):
        logging.debug('download complete: ticket: %d, status: %d%s', ticket, status, '' if status == DOWNLOAD_SUCCEED else ', result: %s' % result)
        pass

    def run(self):
        """
        Run the plugin as a standalone application
        """
        self._app.run()

    @property
    def app(self):
        """
        Return the App object that the plugin uses.
        """
        return self._app

    @property
    def id(self):
        """
        Return the ID of the lyric source
        """
        return self._id

    @property
    def config_proxy(self):
        if self._config is None:
            self._config = Config(self._app.connection)
        return self._config


def test():
    class DummyLyricSourcePlugin(BaseLyricSourcePlugin):
        def __init__(self):
            super().__init__(id='dummy', watch_daemon=False)

        def do_search(self, metadata):
            if metadata.title:
                logging.info('title: %s', metadata.title)
                results = [SearchResult(title=metadata.title + str(i),
                                        artist=metadata.artist + str(i),
                                        album=metadata.album + str(i),
                                        sourceid=i,
                                        downloadinfo='\n'.join((metadata.title,
                                                                metadata.artist,
                                                                metadata.album)))
                           for i in range(10)]
                return results

            raise Exception('Title must not be empty')

        def do_download(self, downloadinfo):
            return b'dummy-downloaded-content'

    search_tickets = {}
    download_tickets = {}

    def search_reply(ticket, expect_status):
        if ticket in search_tickets:
            logging.warning('Error: search ticket %d exists', ticket)
        else:
            search_tickets[ticket] = expect_status

    def search_complete_cb(ticket, status, results):
        if ticket not in search_tickets:
            logging.warning('Error! search ticket not exists')
            return

        if search_tickets[ticket] != status:
            logging.warning('Error! expect search %d with status %d but %d got',
                            ticket, search_tickets[ticket], status)
            return
        logging.debug('Search #%d with status %d', ticket, status)
        if status == 0:
            downloadinfo = results[0]['downloadinfo']
            source.Download(downloadinfo,
                            reply_handler=lambda t: download_reply(t, 0),
                            error_handler=dummy_error)
        del search_tickets[ticket]
        check_quit()

    def download_reply(ticket, expect_status):
        if ticket in download_tickets:
            logging.warning('Error: download ticket %d already exists', ticket)
        else:
            download_tickets[ticket] = expect_status

    def download_complete_cb(ticket, status, content):
        if ticket not in download_tickets:
            logging.warning('Error! download ticket not exists')
            return

        if download_tickets[ticket] != status:
            logging.warning('Error! expect download status %d but %d got', download_tickets[ticket], status)
            return
        if status == 0:
            logging.debug('Download #%d success', ticket)
            logging.debug('Downloaded content: \n%s', ''.join([chr(b) for b in content]))
        else:
            logging.warning('Download #%d fail, msg: %s', ticket, ''.join([chr(b) for b in content]))
        del download_tickets[ticket]
        check_quit()

    wait_count = [4]

    def check_quit():
        wait_count[0] -= 1
        if wait_count[0] == 0:
            app.quit()

    def dummy_error(e):
        logging.warning('Error: ' + e)

    dummysource = DummyLyricSourcePlugin()
    app = dummysource.app
    conn = app.connection
    source = conn.get_object('org.osdlyrics.LyricSourcePlugin.dummy',
                             '/org/osdlyrics/LyricSourcePlugin/dummy')
    source.connect_to_signal('SearchComplete',
                             search_complete_cb)
    source.connect_to_signal('DownloadComplete',
                             download_complete_cb)
    source.Search({'title': 'dummytitle',
                   'artist': 'dummyartist',
                   'album': 'dummyalbum'},
                  reply_handler=lambda t: search_reply(t, 0),
                  error_handler=dummy_error)
    source.Search({'foo': 'bar'},
                  reply_handler=lambda t: search_reply(t, 2),
                  error_handler=dummy_error)
    source.Download(123,
                    reply_handler=lambda t: download_reply(t, 2),
                    error_handler=dummy_error)
    app.run()


if __name__ == '__main__':
    test()