Commit | Line | Data |
---|---|---|
51a600c2 CE |
1 | # Kodi PartyParty Web Thing Library |
2 | # Part of Simple PartyMode Web Console | |
3 | # Copyright (c) 2015 Clinton Ebadi <clinton@unknownlamer.org> | |
4 | # This program is free software: you can redistribute it and/or modify | |
5 | # it under the terms of the GNU General Public License as published by | |
6 | # the Free Software Foundation, either version 3 of the License, or | |
7 | # (at your option) any later version. | |
8 | ||
9 | # This program is distributed in the hope that it will be useful, | |
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | # GNU General Public License for more details. | |
13 | ||
14 | # You should have received a copy of the GNU General Public License | |
15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | # Copyright (c) 2015 Clinton Ebadi <clinton@unknownlamer.org> | |
17 | ||
18 | import cgi, cgitb | |
19 | from datetime import datetime | |
20 | import hashlib | |
b61367d1 | 21 | import html |
cfa37db5 | 22 | import itertools |
51a600c2 CE |
23 | import numbers |
24 | import os | |
25 | import random | |
cfa37db5 | 26 | import re |
51a600c2 | 27 | import subprocess |
51a600c2 | 28 | import urllib |
b61367d1 | 29 | from kodijson import Kodi |
51a600c2 | 30 | from yattag import Doc |
b1ddeadc | 31 | import youtube_dl |
51a600c2 | 32 | |
b61367d1 | 33 | kodi = None |
51a600c2 | 34 | |
b61367d1 CE |
35 | def connect (_kodi): |
36 | global kodi | |
37 | kodi = _kodi | |
38 | return kodi | |
51a600c2 | 39 | |
60f1621b | 40 | SONG_PROPERTIES = ['album', 'artist', 'albumartist', 'title', 'dateadded', 'userrating', 'displayartist'] |
51a600c2 CE |
41 | PAGE_SELF = os.environ['SCRIPT_NAME'] if 'SCRIPT_NAME' in os.environ else '' |
42 | ||
43 | class Song: | |
44 | def __init__ (self, song): | |
45 | self._song = song | |
46 | if 'artist' in song and len(song['artist']) > 0: | |
47 | self.artist = song['artist'][0] | |
48 | if 'displayartist' in song: | |
49 | self.artist = song['displayartist'] | |
50 | elif 'albumartist' in song and len(song['albumartist']) > 0: | |
51 | self.artist = song['albumartist'][0] | |
52 | else: | |
53 | self.artist = 'who fucking knows' | |
54 | ||
55 | if 'album' in song and len(song['album']) > 0: | |
56 | self.album = song['album'] | |
57 | else: | |
58 | self.album = 'album is for losers' | |
59 | ||
60 | if 'id' in song: | |
61 | # item from playlist | |
b61367d1 | 62 | self.key = hashlib.sha256(str(song['id']).encode('utf-8')).hexdigest() |
51a600c2 CE |
63 | self.kodi_id = song['id'] |
64 | # the playlist will not update things like ratings if we | |
65 | # update via RPC. Just grab it from the library instead. | |
4da3fd24 | 66 | if 'userrating' in song: |
b61367d1 | 67 | libsong = kodi.AudioLibrary.GetSongDetails (songid = song['id'], properties = ['userrating']) |
51a600c2 CE |
68 | #print (libsong) |
69 | if 'result' in libsong and 'songdetails' in libsong['result']: | |
4da3fd24 | 70 | song['userrating'] = libsong['result']['songdetails']['userrating'] |
51a600c2 CE |
71 | elif 'songid' in song: |
72 | # search results | |
b61367d1 | 73 | self.key = hashlib.sha256(str(song['songid']).encode('utf-8')).hexdigest() |
51a600c2 CE |
74 | self.kodi_id = song['songid'] |
75 | else: | |
b61367d1 | 76 | self.key = hashlib.sha256((song['label'] + self.artist).encode('utf-8')).hexdigest() |
51a600c2 CE |
77 | self.kodi_id = 0 |
78 | ||
79 | # videos can still be labeled as songs, but the rating will be a | |
80 | # float... | |
4da3fd24 CE |
81 | if 'userrating' in song and isinstance (song['userrating'], numbers.Integral): |
82 | self.rating = song['userrating'] | |
51a600c2 CE |
83 | else: |
84 | self.rating = -1 # might be better to use None here | |
85 | ||
60f1621b CE |
86 | if 'title' in song and len(song['title']) > 0: |
87 | self.label = song['title'] | |
88 | else: | |
89 | self.label = song['label'] | |
51a600c2 CE |
90 | |
91 | def songs(items): | |
92 | '''Convert list of Kodi Items into Song instances''' | |
93 | return [Song(item) for item in items] | |
94 | ||
95 | def get_playlist (playlistid=0): | |
b61367d1 | 96 | return songs (kodi.Playlist.GetItems (playlistid=playlistid, properties=SONG_PROPERTIES)['result']['items']) |
51a600c2 CE |
97 | |
98 | class SongControls: | |
99 | aactions = {'songup': 'up', 'songdown': 'down', 'songtop': 'next!', 'songbottom': 'banish!', | |
100 | 'songdel': 'del', 'randomqueue': 'yeh', 'songrate': 'rate'} | |
101 | ||
102 | def __init__ (self, song, actions = ['songup', 'songdown', 'songdel']): | |
103 | self.song = song | |
104 | self.actions = actions | |
105 | ||
106 | def controls (self): | |
107 | doc, tag, text = Doc().tagtext() | |
108 | with tag ('form', method = 'post', action = PAGE_SELF, klass = 'song_controls'): #, style = 'display: inline-block'): | |
109 | for action in self.actions: | |
110 | with tag ('button', name = action, value = self.song.key): | |
111 | text (self.aactions[action]) | |
112 | doc.asis (self.extra_elements (action)) | |
113 | return doc.getvalue() | |
114 | ||
115 | def extra_elements (self, action): | |
116 | doc, tag, text = Doc().tagtext() | |
117 | if action == 'randomqueue': | |
118 | doc.stag ('input', type = 'hidden', name = 'songkodiid', value = self.song.kodi_id) | |
119 | elif action == 'songrate': | |
120 | if self.song.rating > -1: | |
121 | doc.defaults = {'songrating': self.song.rating} | |
122 | with doc.select (name = 'songrating'): | |
123 | with doc.option (value = 0): | |
124 | text ('na') | |
125 | for i in range (1,6): | |
4da3fd24 | 126 | with doc.option (value = i*2): |
51a600c2 CE |
127 | text (str (i)) |
128 | doc.stag ('input', type = 'hidden', name = 'songkodiid', value = self.song.kodi_id) | |
129 | ||
130 | return doc.getvalue () | |
131 | ||
132 | class Search: | |
cfa37db5 CE |
133 | ANY_SEARCH_PROPERTIES = [ 'title', 'album', 'artist' ] |
134 | ||
51a600c2 CE |
135 | def __init__ (self, term = '', prop = 'title'): |
136 | self.term = term | |
137 | self.prop = prop | |
138 | if (term != ''): | |
cfa37db5 CE |
139 | self.results = self.get_search_results () |
140 | ||
141 | def get_search_results (self): | |
142 | if (self.term == ''): | |
143 | return {} | |
144 | ||
145 | if (self.prop != 'any'): | |
b61367d1 | 146 | res = kodi.AudioLibrary.GetSongs (filter={'operator': "contains", 'field': self.prop, 'value': self.term}, properties=SONG_PROPERTIES, sort={'order': 'ascending', 'method': 'artist'})['result'] |
cfa37db5 CE |
147 | if 'songs' in res: |
148 | return songs(res['songs']) | |
149 | else: | |
150 | return [] | |
151 | else: | |
b61367d1 | 152 | all_songs = [kodi.AudioLibrary.GetSongs (filter={'operator': "contains", 'field': p, 'value': self.term}, properties=SONG_PROPERTIES, sort={'order': 'ascending', 'method': 'artist'})['result'] |
cfa37db5 CE |
153 | for p |
154 | in self.ANY_SEARCH_PROPERTIES] | |
155 | # does not remove duplicates... | |
156 | return list (itertools.chain.from_iterable ([res['songs'] for res in all_songs if 'songs' in res])) | |
51a600c2 CE |
157 | |
158 | def show_quick_search (self, thereal=False): | |
159 | doc, tag, text = Doc(defaults = {'searchfield': self.prop}).tagtext() | |
160 | with tag ('form', method = 'get', action = PAGE_SELF, style = 'display: inline-block'): | |
161 | if thereal: | |
162 | doc.stag ('input', type = 'text', name = 'searchterm', value = self.term, id = 'quicksearch') | |
163 | else: | |
164 | doc.stag ('input', type = 'text', name = 'searchterm', value = self.term) | |
165 | with doc.select (name = 'searchfield'): | |
cfa37db5 | 166 | for prop in ['title', 'artist', 'album', 'any']: |
51a600c2 CE |
167 | with doc.option (value = prop): |
168 | text (prop) | |
169 | with tag ('button', type = 'submit', name = 'searchgo', value = '1'): | |
170 | text ('Search') | |
b61367d1 | 171 | print (doc.getvalue ()) |
51a600c2 CE |
172 | |
173 | def show_search_results (self): | |
174 | doc, tag, text = Doc().tagtext() | |
175 | with tag ('h1'): | |
176 | text ('Results') | |
177 | if len (self.results) > 0: | |
178 | doc.asis (Playlist (self.results).show (['randomqueue'])) | |
179 | else: | |
180 | with tag ('p'): | |
181 | text ('You are unworthy. No results.') | |
182 | ||
183 | print (doc.getvalue ()) | |
184 | ||
185 | class Playlist: | |
186 | default_controls = ['songup', 'songdown', 'songdel', 'songrate', 'songtop', 'songbottom'] | |
187 | def __init__ (self, kodi_playlist = None): | |
188 | if kodi_playlist is None: | |
189 | self.playlist = self.get_playlist () | |
190 | elif (all (isinstance (s, Song) for s in kodi_playlist)): | |
191 | self.playlist = kodi_playlist | |
192 | else: | |
193 | self.playlist = songs (kodi_playlist) | |
194 | ||
195 | def show (self, controls = default_controls): | |
196 | doc, tag, text = Doc().tagtext() | |
197 | with tag ('ol', klass = 'flex_list'): | |
198 | for song in self.playlist: | |
199 | # text ("{}".format (song._song)) | |
200 | with tag ('li'): | |
201 | with tag ('div', klass = 'flex_row'): | |
202 | with tag ('p'): | |
203 | with tag ('a', href = '{1}?searchgo=1&searchterm={0};searchfield=artist'.format(song.artist, PAGE_SELF)): | |
204 | text (song.artist) | |
205 | text (' ({}) {}'.format(song.album, song.label)) | |
206 | doc.asis (SongControls (song, controls).controls()) | |
207 | return doc.getvalue () | |
208 | ||
209 | def get_playlist (self, playlistid=0): | |
b61367d1 | 210 | return songs (kodi.Playlist.GetItems (playlistid=playlistid, properties=SONG_PROPERTIES)['result']['items']) |
51a600c2 CE |
211 | |
212 | class Upload: | |
213 | upload_dir = '/srv/archive/incoming/stolen-moosic' | |
214 | ||
215 | def __init__ (self, form, field): | |
216 | self.fileitem = form[field] | |
217 | self.filename = '{}/{}'.format (self.upload_dir, self.fileitem.filename) | |
218 | ||
219 | # Evil: just run replaygain/mp3gain/metaflac on the file and hope one | |
220 | # works instead of dealing with MIME. For now. | |
221 | def attempt_rpgain (self): | |
222 | subprocess.call (["/usr/bin/vorbisgain", "-q", self.filename]) | |
b61367d1 | 223 | subprocess.call (["/usr/local/bin/mp3gain", "-q", "-s", "i", self.filename]) |
51a600c2 CE |
224 | subprocess.call (["/usr/bin/aacgain", "-q", "-s", "i", self.filename]) |
225 | subprocess.call (["/usr/bin/metaflac", "--add-replay-gain", self.filename]) | |
226 | ||
227 | def save (self): | |
b61367d1 | 228 | fout = open (os.path.join(self.upload_dir, self.fileitem.filename), 'wb') |
51a600c2 CE |
229 | fout.write (self.fileitem.value) |
230 | fout.close() | |
231 | self.attempt_rpgain () | |
433e6204 | 232 | return { 'file': self.filename } |
51a600c2 | 233 | |
b1ddeadc CE |
234 | class Youtube: |
235 | upload_dir = '/srv/archive/incoming/youtube-moosic' | |
236 | ydl_opts = { | |
237 | 'format': 'bestaudio/best', | |
433e6204 | 238 | 'outtmpl': upload_dir + '/%(title)s-%(id)s.%(ext)s', |
b1ddeadc CE |
239 | 'quiet': True, |
240 | 'postprocessors': [ | |
241 | { | |
242 | 'key': 'FFmpegMetadata', | |
243 | }, | |
244 | { | |
245 | 'key': 'FFmpegExtractAudio', | |
246 | 'preferredcodec': 'vorbis', | |
247 | }], | |
248 | ||
249 | } | |
250 | ||
251 | def __init__ (self, form, field): | |
252 | self.ydl = youtube_dl.YoutubeDL(self.ydl_opts) | |
253 | self.url = form.getvalue (field) | |
254 | ||
255 | def save (self): | |
256 | info = self.ydl.extract_info (self.url, download=True) | |
433e6204 | 257 | filename = re.sub ('\..{3,4}$', '.ogg', self.ydl.prepare_filename (info)) |
b1ddeadc | 258 | subprocess.call (["/usr/bin/vorbisgain", "-q", filename]) |
433e6204 | 259 | return { 'file': filename } |
b1ddeadc | 260 | |
51a600c2 CE |
261 | |
262 | def css (): | |
263 | doc, tag, text = Doc ().tagtext () | |
264 | with tag ('style'): | |
265 | text (''' | |
266 | input, select, button { font-size: 200%; margin: 0.1em; } | |
267 | .horiz-menu li { display: inline; padding-right: 0.5em; font-size: 1.75rem; } | |
268 | body { /* background-image: url("fire-under-construction-animation.gif"); */ | |
269 | color: white; | |
270 | background-color: black; | |
271 | font-family: sans-serif; | |
272 | } | |
273 | a { color: #5dfc0a} | |
274 | button[name=songdel] { margin-left: 1em; margin-right: 1em; } | |
275 | .flex_row { | |
276 | display: flex; | |
277 | flex-flow: row nowrap; | |
278 | justify-content: space-between; | |
279 | align-items: center; | |
280 | } | |
281 | ||
282 | .flex_row p { font-size: 175%; max-width: 50%; min-width: 50% } | |
283 | ||
284 | ol li:nth-child(even) { background-color: #202020 } | |
285 | ''') | |
286 | return doc.getvalue () | |
287 | ||
288 | def print_escaped (item): | |
b61367d1 | 289 | print (u"<p>{}</p>".format (html.escape (u"{}".format (item)))) |
51a600c2 CE |
290 | |
291 | # This is awful | |
292 | class PartyManager: | |
293 | DEFAULT_QUEUE_DIVISOR = 3 | |
294 | ||
295 | def __init__ (self, form): | |
296 | self.form = form | |
297 | ||
298 | def randomqueue (self, item, divisor=None): | |
299 | # randomly queue song somewhere in first (playlist.length / | |
300 | # divisor) songs | |
301 | if divisor is None: | |
302 | divisor = self.DEFAULT_QUEUE_DIVISOR | |
b61367d1 | 303 | totalitems = kodi.Playlist.GetItems (playlistid=0)['result']['limits']['total'] |
51a600c2 | 304 | playpos = random.randint (1, totalitems / divisor + 1) |
b61367d1 CE |
305 | print_escaped (kodi.Playlist.Insert (playlistid=0, item=item, position=playpos)) |
306 | print ('<p style="font-size: x-large">Your song is number {0} in the queue ({1} songs in playlist).</p>'.format (playpos, totalitems+1)) | |
51a600c2 CE |
307 | return (playpos, totalitems+1) |
308 | ||
309 | def process (self): | |
310 | form = self.form | |
311 | if 'songdel' in form: | |
312 | songid = form['songdel'].value | |
313 | print (u"<p>{}</p>".format (songid)) | |
314 | (pos,song) = next ((i,s) for i,s in enumerate(get_playlist ()) if s.key == songid) | |
b61367d1 CE |
315 | print (u'<p>Deleted {}</p>'.format(html.escape (song.label))) |
316 | print_escaped (kodi.Playlist.Remove (playlistid=0, position=pos)) | |
51a600c2 CE |
317 | elif 'songup' in form: |
318 | songid = form['songup'].value | |
319 | print (u"<p>{}</p>".format (songid)) | |
320 | (pos,song) = next ((i,s) for i,s in enumerate(get_playlist ()) if s.key == songid) | |
b61367d1 CE |
321 | print (u"<p>Promoted {}</p>".format(html.escape(song.label))) |
322 | print_escaped (kodi.Playlist.Swap (playlistid=0, position1=pos, position2=pos-1)) | |
51a600c2 CE |
323 | elif 'songdown' in form: |
324 | songid = form['songdown'].value | |
325 | print (u"<p>{}</p>".format (songid)) | |
326 | (pos,song) = next ((i,s) for i,s in enumerate(get_playlist ()) if s.key == songid) | |
b61367d1 CE |
327 | print (u"<p>Demoted {}</p>".format(html.escape(song.label))) |
328 | print_escaped (kodi.Playlist.Swap (playlistid=0, position1=pos, position2=pos+1)) | |
51a600c2 CE |
329 | elif 'songtop' in form: |
330 | songid = form['songtop'].value | |
331 | print (u"<p>{}</p>".format (songid)) | |
332 | (pos,song) = next ((i,s) for i,s in enumerate(get_playlist ()) if s.key == songid) | |
b61367d1 | 333 | print (u"<p>Bumped Up {}</p>".format(html.escape(song.label))) |
51a600c2 | 334 | for i in range (pos, 1, -1): |
b61367d1 | 335 | print_escaped (kodi.Playlist.Swap (playlistid=0, position1=i, position2=i-1)) |
51a600c2 CE |
336 | elif 'songbottom' in form: |
337 | songid = form['songbottom'].value | |
338 | print (u"<p>{}</p>".format (songid)) | |
339 | playlist = get_playlist () | |
340 | (pos,song) = next ((i,s) for i,s in enumerate(playlist) if s.key == songid) | |
b61367d1 | 341 | print (u"<p>Banished {}</p>".format(html.escape(song.label))) |
51a600c2 | 342 | for i in range (pos, len (playlist), 1): |
b61367d1 | 343 | print_escaped (kodi.Playlist.Swap (playlistid=0, position1=i, position2=i+1)) |
51a600c2 | 344 | elif 'volchange' in form: |
b61367d1 | 345 | curvolume = kodi.Application.GetProperties (properties=['volume'])['result']['volume'] |
51a600c2 | 346 | newvolume = max (0, min (int (form['volchange'].value) + curvolume, 100)) |
b61367d1 | 347 | print_escaped (kodi.Application.SetVolume (volume=newvolume)) |
51a600c2 | 348 | elif 'volmute' in form: |
b61367d1 | 349 | print_escaped (kodi.Application.SetMute (mute="toggle")) |
51a600c2 CE |
350 | elif 'navigate' in form: |
351 | action = form['navigate'].value | |
352 | if action == 'prev': | |
b61367d1 | 353 | print_escaped (kodi.Player.GoTo (to="previous", playerid=0)) |
51a600c2 | 354 | elif action == 'next': |
b61367d1 | 355 | print_escaped (kodi.Player.GoTo (to="next", playerid=0)) |
51a600c2 | 356 | elif action == 'playpause': |
b61367d1 | 357 | print_escaped (kodi.Player.PlayPause (play="toggle", playerid=0)) |
51a600c2 CE |
358 | elif 'searchgo' in form: |
359 | term = form['searchterm'].value | |
360 | field = form['searchfield'].value | |
361 | search = Search (term, field) | |
362 | search.show_quick_search () | |
363 | search.show_search_results () | |
364 | elif 'randomqueue' in form: | |
365 | songid = int(form['songkodiid'].value) | |
366 | self.randomqueue ({"songid": songid}) | |
367 | elif 'songrate' in form: | |
368 | songid = int(form['songkodiid'].value) | |
369 | newrating = int(form['songrating'].value) | |
370 | print (songid) | |
371 | print (newrating) | |
b61367d1 | 372 | print_escaped (kodi.AudioLibrary.SetSongDetails (songid = songid, userrating = newrating)) |
51a600c2 CE |
373 | print_escaped (u'Rating Changed') |
374 | elif 'browseartists' in form: | |
b61367d1 | 375 | artists = kodi.AudioLibrary.GetArtists (sort={'order': 'ascending', 'method': 'artist'})['result']['artists'] |
51a600c2 CE |
376 | doc, tag, text = Doc().tagtext() |
377 | with tag ('ol', klass='flex_list'): | |
378 | for artist in artists: | |
379 | with tag ('li', style='padding: 1rem; font-size: x-large'): | |
380 | with tag ('a', href='{}?searchgo=1&searchterm={}&searchfield=artist'.format (PAGE_SELF, urllib.quote_plus (artist['artist'].encode ('utf-8')).decode ('utf-8'))): | |
381 | text (artist['label']) | |
382 | print (doc.getvalue ()) | |
383 | elif 'uploadgo' in form: | |
384 | upload = Upload (form, 'song') | |
433e6204 CE |
385 | item = upload.save () |
386 | self.randomqueue (item, 1 if 'asap' not in form else 3) | |
b1ddeadc CE |
387 | elif 'youtubego' in form: |
388 | youtube = Youtube (form, 'youtubeurl') | |
433e6204 CE |
389 | item = youtube.save () |
390 | self.randomqueue (item, 1 if 'asap' not in form else 3) | |
51a600c2 | 391 | elif 'partyon' in form: |
b61367d1 CE |
392 | if 'error' in kodi.Player.SetPartymode (partymode=True, playerid=0): |
393 | kodi.Player.Open (item={"partymode": "music"}) | |
51a600c2 CE |
394 | elif 'lockon' in form: |
395 | subprocess.call (['/usr/bin/xscreensaver-command', 'lock']) | |
396 | elif 'lights' in form: | |
397 | subprocess.call (['/usr/bin/br', '-N' if form['lights'].value == 'on' else '-F']) |