]> kaliko git repositories - mpd-sima.git/blob - sima/lib/plugin.py
2cc284ff1eb043e1a6a0e58b74bb940ffee14d88
[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 = 'Undocumented plugin! Fill "{}" docstring'.format(cls.__name__)
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 callback_need_track_fb(self):
113         """
114         Called when callback_need_track failled to find tracks to queue
115         Returns a list of Track objects to add
116         """
117
118     def shutdown(self):
119         """Called on application shutdown"""
120
121
122 class AdvancedPlugin(Plugin):
123     """Object to derive from for plugins
124     Exposes advanced music library look up with use of play history
125     """
126
127     # Query History
128     def get_history(self):
129         """Returns a Track list of already played artists."""
130         duration = self.main_conf.getint('sima', 'history_duration')
131         return self.sdb.fetch_history(duration=duration)
132
133     def get_album_history(self, artist):
134         """Retrieve album history"""
135         duration = self.main_conf.getint('sima', 'history_duration')
136         return self.sdb.fetch_albums_history(needle=artist, duration=duration)
137
138     def get_reorg_artists_list(self, alist):
139         """
140         Move around items in alist in order to have first not recently
141         played (or about to be played) artists.
142
143         :param {Artist} alist: Artist objects list/container
144         """
145         queued_artist = MetaContainer([Artist(_.artist) for _ in
146                                        self.player.queue if _.artist])
147         not_queued_artist = alist - queued_artist
148         duration = self.main_conf.getint('sima', 'history_duration')
149         hist = []
150         for art in self.sdb.fetch_artists_history(alist, duration=duration):
151             if art not in hist:
152                 if art not in queued_artist:
153                     hist.insert(0, art)
154                 else:
155                     hist.append(art)
156         # Find not recently played (not in history) & not in queue
157         reorg = [art for art in not_queued_artist if art not in hist]
158         reorg.extend(hist)
159         return reorg
160     # /Query History
161
162     # Find not recently played/unplayed
163     def album_candidate(self, artist, unplayed=True):
164         """Search an album for artist
165
166         :param Artist artist: Artist to fetch an album for
167         :param bool unplayed: Fetch only unplayed album
168         """
169         self.log.info('Searching an album for "%s"...' % artist)
170         albums = self.player.search_albums(artist)
171         if not albums:
172             return None
173         self.log.debug('Albums to choose from: %s', albums)
174         albums_hist = self.get_album_history(artist)
175         self.log.trace('Albums history: %s', [a.name for a in albums_hist])
176         albums_not_in_hist = [a for a in albums if a.name not in albums_hist]
177         # Get to next artist if there are no unplayed albums
178         if not albums_not_in_hist:
179             self.log.info('No unplayed album found for "%s"' % artist)
180             if unplayed:
181                 return None
182         random.shuffle(albums_not_in_hist)
183         albums_not_in_hist.extend(albums_hist)
184         self.log.trace('Album candidates: %s', albums_not_in_hist)
185         album_to_queue = []
186         for album in albums_not_in_hist:
187             # Controls the album found is not already queued
188             if album in {t.Album.name for t in self.player.queue}:
189                 self.log.debug('"%s" already queued, skipping!', album)
190                 continue
191             # In random play mode use complete playlist to filter
192             # Yes indeed, some users play in random with album mode :|
193             if self.player.playmode.get('random'):
194                 if album in {t.Album.name 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, chosen=None, 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         :param list(Track) tracks: List of tracks to chose from
214         :param list(Track) chosen: List of tracks previously chosen
215         :param bool unplayed: chose only unplayed (honoring history duration setting)
216         :return: A Track
217         :rtype: Track
218         """
219         artist = tracks[0].Artist
220         # In random play mode use complete playlist to filter
221         if self.player.playmode.get('random'):
222             deny_list = self.player.playlist
223         else:
224             deny_list = self.player.queue
225         not_in_hist = list(set(tracks) - set(self.sdb.fetch_history(artist=artist)))
226         if not not_in_hist:
227             self.log.debug('All tracks already played for "%s"', artist)
228             if unplayed:
229                 return None
230         random.shuffle(not_in_hist)
231         candidates = []
232         for trk in [_ for _ in not_in_hist if _ not in deny_list]:
233             # Should use albumartist heuristic as well
234             if self.plugin_conf.getboolean('single_album', False):  # pylint: disable=no-member
235                 albums = [tr.Album for tr in deny_list]
236                 albums += [tr.Album for tr in chosen]
237                 if (trk.Album == self.player.current.Album or
238                         trk.Album in albums):
239                     self.log.debug('Found unplayed track ' +
240                                    'but from an album already queued: %s', trk)
241                     continue
242             candidates.append(trk)
243         if not candidates:
244             return None
245         return random.choice(candidates)
246
247 # VIM MODLINE
248 # vim: ai ts=4 sw=4 sts=4 expandtab