party-upload: use song.title if it exists
[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 from kodijson import Kodi
30 from yattag import Doc
31 import youtube_dl
32
33 kodi = None
34
35 def connect (_kodi):
36 global kodi
37 kodi = _kodi
38 return kodi
39
40 SONG_PROPERTIES = ['album', 'artist', 'albumartist', 'title', 'dateadded', 'userrating', 'displayartist']
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
62 self.key = hashlib.sha256(str(song['id']).encode('utf-8')).hexdigest()
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.
66 if 'userrating' in song:
67 libsong = kodi.AudioLibrary.GetSongDetails (songid = song['id'], properties = ['userrating'])
68 #print (libsong)
69 if 'result' in libsong and 'songdetails' in libsong['result']:
70 song['userrating'] = libsong['result']['songdetails']['userrating']
71 elif 'songid' in song:
72 # search results
73 self.key = hashlib.sha256(str(song['songid']).encode('utf-8')).hexdigest()
74 self.kodi_id = song['songid']
75 else:
76 self.key = hashlib.sha256((song['label'] + self.artist).encode('utf-8')).hexdigest()
77 self.kodi_id = 0
78
79 # videos can still be labeled as songs, but the rating will be a
80 # float...
81 if 'userrating' in song and isinstance (song['userrating'], numbers.Integral):
82 self.rating = song['userrating']
83 else:
84 self.rating = -1 # might be better to use None here
85
86 if 'title' in song and len(song['title']) > 0:
87 self.label = song['title']
88 else:
89 self.label = song['label']
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):
96 return songs (kodi.Playlist.GetItems (playlistid=playlistid, properties=SONG_PROPERTIES)['result']['items'])
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):
126 with doc.option (value = i*2):
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:
133 ANY_SEARCH_PROPERTIES = [ 'title', 'album', 'artist' ]
134
135 def __init__ (self, term = '', prop = 'title'):
136 self.term = term
137 self.prop = prop
138 if (term != ''):
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'):
146 res = kodi.AudioLibrary.GetSongs (filter={'operator': "contains", 'field': self.prop, 'value': self.term}, properties=SONG_PROPERTIES, sort={'order': 'ascending', 'method': 'artist'})['result']
147 if 'songs' in res:
148 return songs(res['songs'])
149 else:
150 return []
151 else:
152 all_songs = [kodi.AudioLibrary.GetSongs (filter={'operator': "contains", 'field': p, 'value': self.term}, properties=SONG_PROPERTIES, sort={'order': 'ascending', 'method': 'artist'})['result']
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]))
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'):
166 for prop in ['title', 'artist', 'album', 'any']:
167 with doc.option (value = prop):
168 text (prop)
169 with tag ('button', type = 'submit', name = 'searchgo', value = '1'):
170 text ('Search')
171 print (doc.getvalue ())
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&amp;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):
210 return songs (kodi.Playlist.GetItems (playlistid=playlistid, properties=SONG_PROPERTIES)['result']['items'])
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])
223 subprocess.call (["/usr/local/bin/mp3gain", "-q", "-s", "i", self.filename])
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):
228 fout = open (os.path.join(self.upload_dir, self.fileitem.filename), 'wb')
229 fout.write (self.fileitem.value)
230 fout.close()
231 self.attempt_rpgain ()
232 return { 'file': self.filename }
233
234 class Youtube:
235 upload_dir = '/srv/archive/incoming/youtube-moosic'
236 ydl_opts = {
237 'format': 'bestaudio/best',
238 'outtmpl': upload_dir + '/%(title)s-%(id)s.%(ext)s',
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)
257 filename = re.sub ('\..{3,4}$', '.ogg', self.ydl.prepare_filename (info))
258 subprocess.call (["/usr/bin/vorbisgain", "-q", filename])
259 return { 'file': filename }
260
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):
289 print (u"<p>{}</p>".format (html.escape (u"{}".format (item))))
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
303 totalitems = kodi.Playlist.GetItems (playlistid=0)['result']['limits']['total']
304 playpos = random.randint (1, totalitems / divisor + 1)
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))
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)
315 print (u'<p>Deleted {}</p>'.format(html.escape (song.label)))
316 print_escaped (kodi.Playlist.Remove (playlistid=0, position=pos))
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)
321 print (u"<p>Promoted {}</p>".format(html.escape(song.label)))
322 print_escaped (kodi.Playlist.Swap (playlistid=0, position1=pos, position2=pos-1))
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)
327 print (u"<p>Demoted {}</p>".format(html.escape(song.label)))
328 print_escaped (kodi.Playlist.Swap (playlistid=0, position1=pos, position2=pos+1))
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)
333 print (u"<p>Bumped Up {}</p>".format(html.escape(song.label)))
334 for i in range (pos, 1, -1):
335 print_escaped (kodi.Playlist.Swap (playlistid=0, position1=i, position2=i-1))
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)
341 print (u"<p>Banished {}</p>".format(html.escape(song.label)))
342 for i in range (pos, len (playlist), 1):
343 print_escaped (kodi.Playlist.Swap (playlistid=0, position1=i, position2=i+1))
344 elif 'volchange' in form:
345 curvolume = kodi.Application.GetProperties (properties=['volume'])['result']['volume']
346 newvolume = max (0, min (int (form['volchange'].value) + curvolume, 100))
347 print_escaped (kodi.Application.SetVolume (volume=newvolume))
348 elif 'volmute' in form:
349 print_escaped (kodi.Application.SetMute (mute="toggle"))
350 elif 'navigate' in form:
351 action = form['navigate'].value
352 if action == 'prev':
353 print_escaped (kodi.Player.GoTo (to="previous", playerid=0))
354 elif action == 'next':
355 print_escaped (kodi.Player.GoTo (to="next", playerid=0))
356 elif action == 'playpause':
357 print_escaped (kodi.Player.PlayPause (play="toggle", playerid=0))
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)
372 print_escaped (kodi.AudioLibrary.SetSongDetails (songid = songid, userrating = newrating))
373 print_escaped (u'Rating Changed')
374 elif 'browseartists' in form:
375 artists = kodi.AudioLibrary.GetArtists (sort={'order': 'ascending', 'method': 'artist'})['result']['artists']
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')
385 item = upload.save ()
386 self.randomqueue (item, 1 if 'asap' not in form else 3)
387 elif 'youtubego' in form:
388 youtube = Youtube (form, 'youtubeurl')
389 item = youtube.save ()
390 self.randomqueue (item, 1 if 'asap' not in form else 3)
391 elif 'partyon' in form:
392 if 'error' in kodi.Player.SetPartymode (partymode=True, playerid=0):
393 kodi.Player.Open (item={"partymode": "music"})
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'])