]> kaliko git repositories - mpd-sima.git/blob - sima/lib/plugin.py
Fixed issues in reorg and album_candidate
[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 alist in order to have first not recently
164         played (or about to be played) artists.
165
166         :param list(str) alist: artist name list (Not an Artist object)
167         """
168         queued_artist = {_.artist for _ in self.player.queue}
169         not_queued_artist = set(alist) - queued_artist
170         duration = self.main_conf.getint('sima', 'history_duration')
171         hist = []
172         for art in self.sdb.get_artists_history(alist,
173                                                 duration=duration):
174             if art not in hist:
175                 if art not in queued_artist:
176                     hist.insert(0, art)
177                 else:
178                     hist.append(art)
179         # Find not recently played (not in history)
180         reorg = [art for art in not_queued_artist if art not in hist]
181         reorg.extend(hist)
182         return reorg
183     # /Query History
184
185     # Find not recently played/unplayed
186     def album_candidate(self, artist, unplayed=True):
187         """Search an album for artist
188
189         :param Artist artist: Artist to fetch an album for
190         :param bool unplayed: Fetch only unplayed album
191         """
192         self.log.info('Searching an album for "%s"...' % artist)
193         albums = self.player.search_albums(artist)
194         if not albums:
195             return None
196         self.log.debug('Albums candidates: %s', albums)
197         albums_hist = self.get_album_history(artist)
198         self.log.trace('Albums history: %s', [a.name for a in albums_hist])
199         albums_not_in_hist = [a for a in albums if a.name not in albums_hist]
200         # Get to next artist if there are no unplayed albums
201         if not albums_not_in_hist:
202             self.log.info('No unplayed album found for "%s"' % artist)
203             if unplayed:
204                 return None
205         random.shuffle(albums_not_in_hist)
206         albums_not_in_hist.extend(albums_hist)
207         self.log.debug('Albums candidate: %s', albums_not_in_hist)
208         album_to_queue = []
209         for album in albums_not_in_hist:
210             # Controls the album found is not already queued
211             if album in {t.album for t in self.player.queue}:
212                 self.log.debug('"%s" already queued, skipping!', album)
213                 continue
214             # In random play mode use complete playlist to filter
215             if self.player.playmode.get('random'):
216                 if album in {t.album for t in self.player.playlist}:
217                     self.log.debug('"%s" already in playlist, skipping!',
218                                    album)
219                     continue
220             album_to_queue = album
221         if not album_to_queue:
222             self.log.info('No album found for "%s"', artist)
223             return None
224         self.log.info('%s album candidate: %s - %s', self.__class__.__name__,
225                       artist, album_to_queue)
226         return album_to_queue
227
228     def filter_track(self, tracks, unplayed=False):
229         """
230         Extract one unplayed track from a Track object list.
231             * not in history
232             * not already in the queue
233         """
234         artist = tracks[0].Artist
235         # In random play mode use complete playlist to filter
236         if self.player.playmode.get('random'):
237             deny_list = self.player.playlist
238         else:
239             deny_list = self.player.queue
240         not_in_hist = list(set(tracks) - set(self.get_history(artist=artist)))
241         if not not_in_hist:
242             self.log.debug('All tracks already played for "%s"', artist)
243             if unplayed:
244                 return None
245         random.shuffle(not_in_hist)
246         candidates = []
247         for trk in [_ for _ in not_in_hist if _ not in deny_list]:
248             # Should use albumartist heuristic as well
249             if self.plugin_conf.getboolean('single_album', False):  # pylint: disable=no-member
250                 if (trk.album == self.player.current.album or
251                         trk.album in [tr.album for tr in deny_list]):
252                     self.log.debug('Found unplayed track ' +
253                                    'but from an album already queued: %s', trk)
254                     continue
255             candidates.append(trk)
256         if not candidates:
257             return None
258         return random.choice(candidates)
259
260 # VIM MODLINE
261 # vim: ai ts=4 sw=4 sts=4 expandtab