]> kaliko git repositories - mpd-sima.git/blob - sima/lib/plugin.py
Use set-comprehension in simadb
[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, MetaContainer
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
86     def callback_player(self):
87         """
88         Called on player changes, stopped, paused, skipped
89         """
90
91     def callback_player_database(self):
92         """
93         Called on player music library changes
94         """
95
96     def callback_playlist(self):
97         """
98         Called on playlist changes
99         Not returning data
100         """
101
102     def callback_next_song(self):
103         """
104         Could be use to scrobble, maintain an history…
105         Not returning data,
106         """
107
108     def callback_need_track(self):
109         """
110         Returns a list of Track objects to add
111         """
112
113     def callback_need_track_fb(self):
114         """
115         Called when callback_need_track failled to find tracks to queue
116         Returns a list of Track objects to add
117         """
118
119     def shutdown(self):
120         """Called on application shutdown"""
121
122
123 class AdvancedPlugin(Plugin):
124     """Object to derive from for plugins
125     Exposes advanced music library look up with use of play history
126     """
127
128     # Query History
129     def get_history(self, artist=False):
130         """Constructs Track list of already played artists.
131
132         :param Artist artist: Artist object to look history for
133         """
134         duration = self.main_conf.getint('sima', 'history_duration')
135         name = None
136         if artist:
137             name = artist.name
138         from_db = self.sdb.get_history(duration=duration, artist=name)
139         hist = [Track(artist=tr[0], album=tr[1], title=tr[2],
140                       file=tr[3]) for tr in from_db]
141         return hist
142
143     def get_album_history(self, artist):
144         """Retrieve album history"""
145         hist = []
146         tracks_from_db = self.get_history(artist=artist)
147         for trk in tracks_from_db:
148             if trk.album and trk.album in hist:
149                 continue
150             hist.append(Album(name=trk.album, artist=Artist(trk.artist)))
151         return hist
152
153     def get_reorg_artists_list(self, alist):
154         """
155         Move around items in alist in order to have first not recently
156         played (or about to be played) artists.
157
158         :param {Artist} alist: Artist objects list/container
159         """
160         queued_artist = MetaContainer([Artist(_.artist) for _ in self.player.queue])
161         not_queued_artist = alist - queued_artist
162         duration = self.main_conf.getint('sima', 'history_duration')
163         hist = []
164         for art in self.sdb.get_artists_history(alist, duration=duration):
165             if art not in hist:
166                 if art not in queued_artist:
167                     hist.insert(0, art)
168                 else:
169                     hist.append(art)
170         # Find not recently played (not in history) & not in queue
171         reorg = [art for art in not_queued_artist if art not in hist]
172         reorg.extend(hist)
173         return reorg
174     # /Query History
175
176     # Find not recently played/unplayed
177     def album_candidate(self, artist, unplayed=True):
178         """Search an album for artist
179
180         :param Artist artist: Artist to fetch an album for
181         :param bool unplayed: Fetch only unplayed album
182         """
183         self.log.info('Searching an album for "%s"...' % artist)
184         albums = self.player.search_albums(artist)
185         if not albums:
186             return None
187         self.log.debug('Albums candidates: %s', albums)
188         albums_hist = self.get_album_history(artist)
189         self.log.trace('Albums history: %s', [a.name for a in albums_hist])
190         albums_not_in_hist = [a for a in albums if a.name not in albums_hist]
191         # Get to next artist if there are no unplayed albums
192         if not albums_not_in_hist:
193             self.log.info('No unplayed album found for "%s"' % artist)
194             if unplayed:
195                 return None
196         random.shuffle(albums_not_in_hist)
197         albums_not_in_hist.extend(albums_hist)
198         self.log.debug('Albums candidate: %s', albums_not_in_hist)
199         album_to_queue = []
200         for album in albums_not_in_hist:
201             # Controls the album found is not already queued
202             if album in {t.album for t in self.player.queue}:
203                 self.log.debug('"%s" already queued, skipping!', album)
204                 continue
205             # In random play mode use complete playlist to filter
206             if self.player.playmode.get('random'):
207                 if album in {t.album for t in self.player.playlist}:
208                     self.log.debug('"%s" already in playlist, skipping!',
209                                    album)
210                     continue
211             album_to_queue = album
212         if not album_to_queue:
213             self.log.info('No album found for "%s"', artist)
214             return None
215         self.log.info('%s album candidate: %s - %s', self.__class__.__name__,
216                       artist, album_to_queue)
217         return album_to_queue
218
219     def filter_track(self, tracks, unplayed=False):
220         """
221         Extract one unplayed track from a Track object list.
222             * not in history
223             * not already in the queue
224         """
225         artist = tracks[0].Artist
226         # In random play mode use complete playlist to filter
227         if self.player.playmode.get('random'):
228             deny_list = self.player.playlist
229         else:
230             deny_list = self.player.queue
231         not_in_hist = list(set(tracks) - set(self.get_history(artist=artist)))
232         if not not_in_hist:
233             self.log.debug('All tracks already played for "%s"', artist)
234             if unplayed:
235                 return None
236         random.shuffle(not_in_hist)
237         candidates = []
238         for trk in [_ for _ in not_in_hist if _ not in deny_list]:
239             # Should use albumartist heuristic as well
240             if self.plugin_conf.getboolean('single_album', False):  # pylint: disable=no-member
241                 if (trk.album == self.player.current.album or
242                         trk.album in [tr.album for tr in deny_list]):
243                     self.log.debug('Found unplayed track ' +
244                                    'but from an album already queued: %s', trk)
245                     continue
246             candidates.append(trk)
247         if not candidates:
248             return None
249         return random.choice(candidates)
250
251 # VIM MODLINE
252 # vim: ai ts=4 sw=4 sts=4 expandtab