]> kaliko git repositories - mpd-sima.git/blob - sima/lib/plugin.py
1afafe5455297e5643fb0f977297bf5c3c0cf29c
[mpd-sima.git] / sima / lib / plugin.py
1 # -*- coding: utf-8 -*-
2 # Copyright (c) 2013-2015, 2020-2021 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):
130         """Returns a Track list of already played artists."""
131         duration = self.main_conf.getint('sima', 'history_duration')
132         return self.sdb.fetch_history(duration=duration)
133
134     def get_album_history(self, artist):
135         """Retrieve album history"""
136         duration = self.main_conf.getint('sima', 'history_duration')
137         return self.sdb.fetch_albums_history(needle=artist, duration=duration)
138
139     def get_reorg_artists_list(self, alist):
140         """
141         Move around items in alist in order to have first not recently
142         played (or about to be played) artists.
143
144         :param {Artist} alist: Artist objects list/container
145         """
146         queued_artist = MetaContainer([Artist(_.artist) for _ in
147                                        self.player.queue if _.artist])
148         not_queued_artist = alist - queued_artist
149         duration = self.main_conf.getint('sima', 'history_duration')
150         hist = []
151         for art in self.sdb.fetch_artists_history(alist, duration=duration):
152             if art not in hist:
153                 if art not in queued_artist:
154                     hist.insert(0, art)
155                 else:
156                     hist.append(art)
157         # Find not recently played (not in history) & not in queue
158         reorg = [art for art in not_queued_artist if art not in hist]
159         reorg.extend(hist)
160         return reorg
161     # /Query History
162
163     # Find not recently played/unplayed
164     def album_candidate(self, artist, unplayed=True):
165         """Search an album for artist
166
167         :param Artist artist: Artist to fetch an album for
168         :param bool unplayed: Fetch only unplayed album
169         """
170         self.log.info('Searching an album for "%s"...' % artist)
171         albums = self.player.search_albums(artist)
172         if not albums:
173             return None
174         self.log.debug('Albums candidates: %s', albums)
175         albums_hist = self.get_album_history(artist)
176         self.log.trace('Albums history: %s', [a.name for a in albums_hist])
177         albums_not_in_hist = [a for a in albums if a.name not in albums_hist]
178         # Get to next artist if there are no unplayed albums
179         if not albums_not_in_hist:
180             self.log.info('No unplayed album found for "%s"' % artist)
181             if unplayed:
182                 return None
183         random.shuffle(albums_not_in_hist)
184         albums_not_in_hist.extend(albums_hist)
185         self.log.debug('Albums candidate: %s', albums_not_in_hist)
186         album_to_queue = []
187         for album in albums_not_in_hist:
188             # Controls the album found is not already queued
189             if album in {t.album for t in self.player.queue}:
190                 self.log.debug('"%s" already queued, skipping!', album)
191                 continue
192             # In random play mode use complete playlist to filter
193             if self.player.playmode.get('random'):
194                 if album in {t.album for t in self.player.playlist}:
195                     self.log.debug('"%s" already in playlist, skipping!',
196                                    album)
197                     continue
198             album_to_queue = album
199             break
200         if not album_to_queue:
201             self.log.info('No album found for "%s"', artist)
202             return None
203         self.log.info('%s plugin chose album: %s - %s',
204                       self.__class__.__name__, artist, album_to_queue)
205         return album_to_queue
206
207     def filter_track(self, tracks, unplayed=False):
208         """
209         Extract one unplayed track from a Track object list.
210             * not in history
211             * not already in the queue
212         """
213         artist = tracks[0].Artist
214         # In random play mode use complete playlist to filter
215         if self.player.playmode.get('random'):
216             deny_list = self.player.playlist
217         else:
218             deny_list = self.player.queue
219         not_in_hist = list(set(tracks) - set(self.sdb.fetch_history(artist=artist)))
220         if not not_in_hist:
221             self.log.debug('All tracks already played for "%s"', artist)
222             if unplayed:
223                 return None
224         random.shuffle(not_in_hist)
225         candidates = []
226         for trk in [_ for _ in not_in_hist if _ not in deny_list]:
227             # Should use albumartist heuristic as well
228             if self.plugin_conf.getboolean('single_album', False):  # pylint: disable=no-member
229                 if (trk.album == self.player.current.album or
230                         trk.album in [tr.album for tr in deny_list]):
231                     self.log.debug('Found unplayed track ' +
232                                    'but from an album already queued: %s', trk)
233                     continue
234             candidates.append(trk)
235         if not candidates:
236             return None
237         return random.choice(candidates)
238
239 # VIM MODLINE
240 # vim: ai ts=4 sw=4 sts=4 expandtab