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