]> kaliko git repositories - mpd-sima.git/blob - sima/lib/plugin.py
Cleanup plugin class
[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 .meta import Artist, MetaContainer
27
28
29 class Plugin:
30     """
31     First non-empty line of the docstring is used as description
32     Rest of the docstring at your convenience.
33
34     The lowercased plugin Name MUST be the same as the module (file name),
35     for instance Plugin → plugin.py
36     It eases plugins discovery and simplifies the code to handle them,
37     IMHO, it's a fair trade-off.
38     """
39
40     @classmethod
41     def info(cls):
42         """self documenting class method
43         """
44         doc = f'Undocumented plugin! Fill "{cls.__name__}" docstring'
45         if cls.__doc__:
46             doc = cls.__doc__.strip(' \n').splitlines()[0]
47         return {'name': cls.__name__,
48                 'doc': doc}
49
50     def __init__(self, daemon):
51         self.log = daemon.log
52         self.player = daemon.player
53         self.plugin_conf = None
54         self.main_conf = daemon.config
55         self.sdb = daemon.sdb
56         self.__get_config()
57
58     def __str__(self):
59         return self.__class__.__name__
60
61     def __get_config(self):
62         """Get plugin's specific configuration from global applications's config
63         """
64         conf = self.main_conf
65         for sec in conf.sections():  # Discovering plugin conf
66             if sec == self.__class__.__name__.lower():
67                 self.plugin_conf = conf[sec]
68                 if 'priority' not in self.plugin_conf:
69                     self.plugin_conf['priority'] = '80'
70         if not self.plugin_conf:
71             self.plugin_conf = {'priority': '80'}
72         #if self.plugin_conf:
73         #   self.log.debug('Got config for %s: %s', self, self.plugin_conf)
74
75     @property
76     def priority(self):
77         return self.plugin_conf.get('priority')
78
79     def start(self):
80         """
81         Called when the daemon().run() is called and
82         right after the player has connected successfully.
83         """
84
85     def callback_player(self):
86         """
87         Called on player changes, stopped, paused, skipped
88         """
89
90     def callback_player_database(self):
91         """
92         Called on player music library changes
93         """
94
95     def callback_playlist(self):
96         """
97         Called on playlist changes
98         Not returning data
99         """
100
101     def callback_next_song(self):
102         """
103         Could be use to scrobble, maintain an history…
104         Not returning data,
105         """
106
107     def callback_need_track(self):
108         """
109         Returns a list of Track objects to add
110         """
111
112     def shutdown(self):
113         """Called on application shutdown"""
114
115
116 class AdvancedPlugin(Plugin):
117     """Object to derive from for plugins
118     Exposes advanced music library look up with use of play history
119     """
120
121     # Query History
122     def get_history(self):
123         """Returns a Track list of already played artists."""
124         duration = self.main_conf.getint('sima', 'history_duration')
125         return self.sdb.fetch_history(duration=duration)
126
127     def get_album_history(self, artist):
128         """Retrieve album history"""
129         duration = self.main_conf.getint('sima', 'history_duration')
130         return self.sdb.fetch_albums_history(needle=artist, duration=duration)
131
132     def get_reorg_artists_list(self, alist):
133         """
134         Move around items in alist in order to have first not recently
135         played (or about to be played) artists.
136
137         :param {Artist} alist: Artist objects list/container
138         """
139         queued_artist = MetaContainer([Artist(_.artist) for _ in
140                                        self.player.queue if _.artist])
141         not_queued_artist = alist - queued_artist
142         duration = self.main_conf.getint('sima', 'history_duration')
143         hist = []
144         for art in self.sdb.fetch_artists_history(alist, duration=duration):
145             if art not in hist:
146                 if art not in queued_artist:
147                     hist.insert(0, art)
148                 else:
149                     hist.append(art)
150         # Find not recently played (not in history) & not in queue
151         reorg = [art for art in not_queued_artist if art not in hist]
152         reorg.extend(hist)
153         return reorg
154     # /Query History
155
156     # Find not recently played/unplayed
157     def album_candidate(self, artist, unplayed=True):
158         """Search an album for artist
159
160         :param Artist artist: Artist to fetch an album for
161         :param bool unplayed: Fetch only unplayed album
162         """
163         self.log.info('Searching an album for "%s"...', artist)
164         albums = self.player.search_albums(artist)
165         if not albums:
166             return None
167         self.log.debug('Albums to choose from: %s', albums)
168         albums_hist = self.get_album_history(artist)
169         self.log.trace('Albums history: %s', [a.name for a in albums_hist])
170         albums_not_in_hist = [a for a in albums if a.name not in albums_hist]
171         # Get to next artist if there are no unplayed albums
172         if not albums_not_in_hist:
173             self.log.info('No unplayed album found for "%s"', artist)
174             if unplayed:
175                 return None
176         random.shuffle(albums_not_in_hist)
177         albums_not_in_hist.extend(albums_hist)
178         self.log.trace('Album candidates: %s', albums_not_in_hist)
179         album_to_queue = []
180         for album in albums_not_in_hist:
181             # Controls the album found is not already queued
182             if album in {t.Album.name for t in self.player.queue}:
183                 self.log.debug('"%s" already queued, skipping!', album)
184                 continue
185             # In random play mode use complete playlist to filter
186             # Yes indeed, some users play in random with album mode :|
187             if self.player.playmode.get('random'):
188                 if album in {t.Album.name for t in self.player.playlist}:
189                     self.log.debug('"%s" already in playlist, skipping!',
190                                    album)
191                     continue
192             album_to_queue = album
193             break
194         if not album_to_queue:
195             self.log.info('No album found for "%s"', artist)
196             return None
197         self.log.info('%s plugin chose album: %s - %s',
198                       self.__class__.__name__, artist, album_to_queue)
199         return album_to_queue
200
201     def filter_track(self, tracks, chosen=None, unplayed=False):
202         """
203         Extract one unplayed track from a Track object list.
204             * not in history
205             * not already in the queue
206
207         :param list(Track) tracks: List of tracks to chose from
208         :param list(Track) chosen: List of tracks previously chosen
209         :param bool unplayed: chose only unplayed (honoring history duration setting)
210         :return: A Track
211         :rtype: Track
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                 albums = [tr.Album for tr in deny_list]
230                 albums += [tr.Album for tr in chosen]
231                 if (trk.Album == self.player.current.Album or
232                         trk.Album in albums):
233                     self.log.debug('Found unplayed track ' +
234                                    'but from an album already queued: %s', trk)
235                     continue
236             candidates.append(trk)
237         if not candidates:
238             return None
239         return random.choice(candidates)
240
241 # VIM MODLINE
242 # vim: ai ts=4 sw=4 sts=4 expandtab