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