]> kaliko git repositories - mpd-sima.git/blob - sima/lib/plugin.py
Major refactoring of Plugin class
[mpd-sima.git] / sima / lib / plugin.py
1 # -*- coding: utf-8 -*-
2 # Copyright (c) 2013-2015, 2020 kaliko <kaliko@azylum.org>
3 #
4 #  This file is part of sima
5 #
6 #  sima is free software: you can redistribute it and/or modify
7 #  it under the terms of the GNU General Public License as published by
8 #  the Free Software Foundation, either version 3 of the License, or
9 #  (at your option) any later version.
10 #
11 #  sima is distributed in the hope that it will be useful,
12 #  but WITHOUT ANY WARRANTY; without even the implied warranty of
13 #  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 #  GNU General Public License for more details.
15 #
16 #  You should have received a copy of the GNU General Public License
17 #  along with sima.  If not, see <http://www.gnu.org/licenses/>.
18 #
19 #
20 """
21 Plugin object to derive from
22 """
23
24 import random
25
26 from .track import Track
27 from .meta import Album, Artist
28
29
30 class Plugin:
31     """
32     First non-empty line of the docstring is used as description
33     Rest of the docstring at your convenience.
34
35     The lowercased plugin Name MUST be the same as the module (file name),
36     for instance Plugin → plugin.py
37     It eases plugins discovery and simplifies the code to handle them,
38     IMHO, it's a fair trade-off.
39     """
40
41     @classmethod
42     def info(cls):
43         """self documenting class method
44         """
45         doc = 'Undocumented plugin! Fill "{}" docstring'.format(cls.__name__)
46         if cls.__doc__:
47             doc = cls.__doc__.strip(' \n').splitlines()[0]
48         return {'name': cls.__name__,
49                 'doc': doc,}
50
51     def __init__(self, daemon):
52         self.log = daemon.log
53         self.player = daemon.player
54         self.plugin_conf = None
55         self.main_conf = daemon.config
56         self.sdb = daemon.sdb
57         self.__get_config()
58
59     def __str__(self):
60         return self.__class__.__name__
61
62     def __get_config(self):
63         """Get plugin's specific configuration from global applications's config
64         """
65         conf = self.main_conf
66         for sec in conf.sections():  # Discovering plugin conf
67             if sec == self.__class__.__name__.lower():
68                 self.plugin_conf = conf[sec]
69                 if 'priority' not in self.plugin_conf:
70                     self.plugin_conf['priority'] = '80'
71         if not self.plugin_conf:
72             self.plugin_conf = {'priority': '80'}
73         #if self.plugin_conf:
74         #   self.log.debug('Got config for %s: %s', self, self.plugin_conf)
75
76     @property
77     def priority(self):
78         return self.plugin_conf.get('priority')
79
80     def start(self):
81         """
82         Called when the daemon().run() is called and
83         right after the player has connected successfully.
84         """
85         pass
86
87     def callback_player(self):
88         """
89         Called on player changes, stopped, paused, skipped
90         """
91         pass
92
93     def callback_player_database(self):
94         """
95         Called on player music library changes
96         """
97         pass
98
99     def callback_playlist(self):
100         """
101         Called on playlist changes
102         Not returning data
103         """
104         pass
105
106     def callback_next_song(self):
107         """
108         Could be use to scrobble, maintain an history…
109         Not returning data,
110         """
111         pass
112
113     def callback_need_track(self):
114         """
115         Returns a list of Track objects to add
116         """
117         pass
118
119     def callback_need_track_fb(self):
120         """
121         Called when callback_need_track failled to find tracks to queue
122         Returns a list of Track objects to add
123         """
124         pass
125
126     def shutdown(self):
127         """Called on application shutdown"""
128         pass
129
130
131 class AdvancedPlugin(Plugin):
132     """Object to derive from for plugins
133     Exposes advanced music library look up with use of play history
134     """
135
136     # Query History
137     def get_history(self, artist=False):
138         """Constructs Track list of already played artists.
139
140         :param Artist artist: Artist object to look history for
141         """
142         duration = self.main_conf.getint('sima', 'history_duration')
143         name = None
144         if artist:
145             name = artist.name
146         from_db = self.sdb.get_history(duration=duration, artist=name)
147         hist = [Track(artist=tr[0], album=tr[1], title=tr[2],
148                       file=tr[3]) for tr in from_db]
149         return hist
150
151     def get_album_history(self, artist):
152         """Retrieve album history"""
153         hist = []
154         tracks_from_db = self.get_history(artist=artist)
155         for trk in tracks_from_db:
156             if trk.album and trk.album in hist:
157                 continue
158             hist.append(Album(name=trk.album, artist=Artist(trk.artist)))
159         return hist
160
161     def get_reorg_artists_list(self, alist):
162         """
163         Move around items in artists_list in order to play first not recently
164         played artists
165
166         :param list(str) alist:
167         """
168         hist = list()
169         duration = self.main_conf.getint('sima', 'history_duration')
170         for art in self.sdb.get_artists_history(alist, duration=duration):
171             if art not in hist:
172                 hist.insert(0, art)
173         reorg = [art for art in alist if art not in hist]
174         reorg.extend(hist)
175         return reorg
176     # /Query History
177
178     # Find not recently played/unplayed
179     def album_candidate(self, artist, unplayed=True):
180         """Search an album for artist
181
182         :param Artist artist: Artist to fetch an album for
183         :param bool unplayed: Fetch only unplayed album
184         """
185         self.log.info('Searching an album for "%s"...' % artist)
186         albums = self.player.search_albums(artist)
187         if not albums:
188             return []
189         self.log.debug('Albums candidate: %s', albums)
190         albums_hist = self.get_album_history(artist)
191         self.log.debug('Albums history: %s', [a.name for a in albums_hist])
192         albums_not_in_hist = [a for a in albums if a.name not in albums_hist]
193         # Get to next artist if there are no unplayed albums
194         if not albums_not_in_hist:
195             self.log.info('No unplayed album found for "%s"' % artist)
196             if unplayed:
197                 return []
198         random.shuffle(albums_not_in_hist)
199         albums_not_in_hist.extend(albums_hist)
200         album_to_queue = []
201         for album in albums_not_in_hist:
202             # Controls the album found is not already queued
203             if album in {t.album for t in self.player.queue}:
204                 self.log.debug('"%s" already queued, skipping!', album)
205                 return []
206             # In random play mode use complete playlist to filter
207             if self.player.playmode.get('random'):
208                 if album in {t.album for t in self.player.playlist}:
209                     self.log.debug('"%s" already in playlist, skipping!',
210                                    album)
211                     return []
212             album_to_queue = album
213         if not album_to_queue:
214             self.log.info('No album found for "%s"', artist)
215             return []
216         self.log.info('%s album candidate: %s - %s', self.__class__.__name__,
217                       artist, album_to_queue)
218         return album_to_queue
219
220     def filter_track(self, tracks, unplayed=False):
221         """
222         Extract one unplayed track from a Track object list.
223             * not in history
224             * not already in the queue
225         """
226         artist = tracks[0].Artist
227         # In random play mode use complete playlist to filter
228         if self.player.playmode.get('random'):
229             deny_list = self.player.playlist
230         else:
231             deny_list = self.player.queue
232         not_in_hist = list(set(tracks) - set(self.get_history(artist=artist)))
233         if not not_in_hist:
234             self.log.debug('All tracks already played for "%s"', artist)
235             if unplayed:
236                 return None
237         random.shuffle(not_in_hist)
238         candidates = []
239         for trk in [_ for _ in not_in_hist if _ not in deny_list]:
240             # Should use albumartist heuristic as well
241             if self.plugin_conf.getboolean('single_album', False):  # pylint: disable=no-member
242                 if (trk.album == self.player.current.album or
243                         trk.album in [tr.album for tr in deny_list]):
244                     self.log.debug('Found unplayed track ' +
245                                    'but from an album already queued: %s', trk)
246                     continue
247             candidates.append(trk)
248         if not candidates:
249             return None
250         return random.choice(candidates)
251
252 # VIM MODLINE
253 # vim: ai ts=4 sw=4 sts=4 expandtab