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