/
playlist.py
359 lines (277 loc) · 11.3 KB
/
playlist.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
import datetime
import json
import logging
import threading
import uuid
from google.appengine.api import background_thread
from google.appengine.api import channel
from google.appengine.api import memcache
from google.appengine.api import urlfetch
from google.appengine.ext import db
CROSSFADE_DURATION_SECONDS = 1
class PlaylistError(Exception):
pass
class PlaylistEmptyError(PlaylistError):
def __init__(self, playlist_id):
super(PlaylistEmptyError, self).__init__(
'Playlist "%s" has no videos.' % playlist_id)
class AlreadyPlayingError(PlaylistError):
def __init__(self, playlist_id):
super(AlreadyPlayingError, self).__init__(
'Playlist "%s" is already playing.' % playlist_id)
class NotPlayingError(PlaylistError):
def __init__(self, playlist_id):
super(NotPlayingError, self).__init__(
'Playlist "%s" is not playing.' % playlist_id)
class NonexistentEntryError(PlaylistError):
def __init__(self, entry_id):
super(NonexistentEntryError, self).__init__(
'Playlist entry "%s" does not exist.' % entry_id)
class AlreadyVotedError(PlaylistError):
def __init__(self, entry_id):
super(AlreadyVotedError, self).__init__(
'Already voted for entry "%s".' % entry_id)
class WrongEntryError(PlaylistError):
def __init__(self, entry_id):
super(WrongEntryError, self).__init__(
'Playlist entry "%s" is not currently skippable.' % entry_id)
class NonexistentVideoError(PlaylistError):
def __init__(self, video_id):
super(NonexistentVideoError, self).__init__(
'Video "%s" does not exist.' % video_id)
class DuplicateVideoError(PlaylistError):
def __init__(self, video_id):
super(DuplicateVideoError, self).__init__(
'Video "%s" is already in the playlist.' % video_id)
class Status(object):
Stopped, Playing, Paused = range(3)
class PlaylistEntry(db.Model):
video_id = db.StringProperty(required=True)
title = db.StringProperty(required=True)
duration = db.IntegerProperty(required=True)
vote_count = db.IntegerProperty(default=1, required=True)
voters = db.StringListProperty()
@classmethod
def create(cls, video_id, title, duration, session_id):
entry = cls(video_id=video_id, title=title, duration=duration, voters=[session_id])
entry.put()
return entry
@staticmethod
def key_comparator(key1, key2):
return int(PlaylistEntry.get(key2).vote_count -
PlaylistEntry.get(key1).vote_count)
def voted(self, session_id):
return session_id in self.voters
def get_info(self, session_id):
return {
'id': self.key().id(),
'videoId': self.video_id,
'title': self.title,
'duration': self.duration,
'voteCount': self.vote_count,
'voted': self.voted(session_id),
}
def upvote(self, session_id):
self.vote_count += 1
self.voters.append(session_id)
self.put()
def reset_votes(self):
self.vote_count = 0
self.voters = []
self.put()
def get(playlist_id):
playlist = memcache.get(playlist_id)
if not playlist:
playlist = Playlist.get_or_insert(playlist_id)
playlist._clients = {}
memcache.add(playlist_id, playlist)
return playlist
def _get_video_info(video_id):
youtube_video_url = 'http://gdata.youtube.com/feeds/api/videos/%s?alt=json' % video_id
response = urlfetch.fetch(youtube_video_url)
if response.status_code == 400 and response.content == 'Invalid id':
raise NonexistentVideoError(video_id)
if response.status_code != 200:
raise Exception('YouTube returned %s\n%s' % (response.status_code, response.content))
try:
video_data = json.loads(response.content)
except ValueError:
raise Exception('YouTube returned %s\n%s' % (response.status_code, response.content))
info = {}
info['title'] = video_data['entry']['media$group']['media$title']['$t']
info['duration'] = int(video_data['entry']['media$group']['yt$duration']['seconds'])
return info
_play_timers = {}
class Playlist(db.Model):
_playlists = {}
seek_time = db.FloatProperty(default=0., required=True)
start_time = db.DateTimeProperty()
status = db.IntegerProperty(default=Status.Stopped, required=True)
playing = db.ReferenceProperty(PlaylistEntry)
entries = db.ListProperty(db.Key)
def create_channel(self, session_id):
client_id = '.'.join([self.key().name(), session_id, uuid.uuid4().hex])
channel_token = channel.create_channel(client_id)
return channel_token
def add_client(self, client_id, session_id):
self._clients[client_id] = session_id
memcache.set(self.key().name(), self)
self._send_message(
client_id,
{'event': 'refresh',
'playlistInfo': self.get_info(session_id)})
def remove_client(self, client_id):
if client_id in self._clients:
del self._clients[client_id]
memcache.set(self.key().name(), self)
def get_info(self, session_id):
def get_entry_info(entry_key):
return PlaylistEntry.get(entry_key).get_info(session_id)
return {'id': self.key().name(),
'status': self._get_status_string(),
'seekTime': self._get_seek_time(),
'entries': map(get_entry_info, self.entries),
}
def add(self, video_id, allow_duplicates, session_id):
def video_in_playlist():
for entry_key in self.entries:
if video_id == PlaylistEntry.get(entry_key).video_id:
return True
return False
if not allow_duplicates and video_in_playlist():
raise DuplicateVideoError(video_id)
video_info = _get_video_info(video_id)
entry = PlaylistEntry.create(
video_id, video_info['title'], video_info['duration'], session_id)
self.entries.append(entry.key())
self.entries.sort(PlaylistEntry.key_comparator)
memcache.set(self.key().name(), self)
self.put()
for client, session_id in self._clients.iteritems():
self._send_message(
client,
{'event': 'add',
'entryInfo': entry.get_info(session_id),
'rank': self.entries.index(entry.key())})
def upvote(self, entry_id, session_id):
entry = PlaylistEntry.get_by_id(entry_id)
if not (entry and entry.key() in self.entries):
raise NonexistentEntryError(entry_id)
if session_id in entry.voters:
raise AlreadyVotedError(entry_id)
entry.upvote(session_id)
old_rank = self.entries.index(entry.key())
self.entries.remove(entry.key())
self.entries.insert(0, entry.key())
self.entries.sort(PlaylistEntry.key_comparator)
memcache.set(self.key().name(), self)
self.put()
new_rank = self.entries.index(entry.key())
for client, session_id in self._clients.iteritems():
self._send_message(
client,
{'event': 'upvote',
'entryId': entry_id,
'voteCount': entry.vote_count,
'voted': entry.voted(session_id),
'oldRank': old_rank,
'newRank': new_rank})
def play(self):
if self._is_empty():
raise PlaylistEmptyError(self.key().name())
if self.status == Status.Playing:
raise AlreadyPlayingError(self.key().name())
self.start_time = datetime.datetime.now()
self.status = Status.Playing
memcache.set(self.key().name(), self)
self.put()
self._start_next_timer()
self._broadcast({'event': 'play',
'seekTime': self._get_seek_time()})
def pause(self):
if self._is_empty():
raise PlaylistEmptyError(self.key().name())
if self.status != Status.Playing:
raise NotPlayingError(self.key().name())
self.seek_time = self._get_seek_time()
self.start_time = datetime.datetime.now()
self.status = Status.Paused
memcache.set(self.key().name(), self)
self.put()
self._cancel_next_timer()
self._broadcast({'event': 'pause',
'seekTime': self._get_seek_time()})
def _seek(self, seek_time):
self.seek_time = seek_time
self.start_time = datetime.datetime.now()
def seek(self, seek_time):
if self._is_empty():
raise PlaylistEmptyError(self.key().name())
if self.status == Status.Stopped:
raise NotPlayingError(self.key().name())
self._seek(seek_time)
memcache.set(self.key().name(), self)
self.put()
self._cancel_next_timer()
self._start_next_timer()
self._broadcast({'event': 'seek',
'seekTime': self._get_seek_time()})
def stop(self):
if self._is_empty():
raise PlaylistEmptyError(self.key().name())
if self.status == Status.Stopped:
raise NotPlayingError(self.key().name())
self.seek_time = 0.
self.start_time = None
self.status = Status.Stopped
memcache.set(self.key().name(), self)
self.put()
self._cancel_next_timer()
self._broadcast({'event': 'stop'})
def skip(self, entry_id):
if self._is_empty():
raise PlaylistEmptyError(self.key().name())
entry = PlaylistEntry.get_by_id(entry_id)
if not (entry and entry.key() in self.entries):
raise NonexistentEntryError(entry_id)
if entry.key() != self.entries[0]:
raise WrongEntryError(entry_id)
self._next()
def _next(self):
PlaylistEntry.get(self.entries[0]).reset_votes()
self._seek(0.)
self.entries.append(self.entries.pop(0))
memcache.set(self.key().name(), self)
self.put()
self._start_next_timer()
self._broadcast({'event': 'next',
'crossfadeDuration': CROSSFADE_DURATION_SECONDS})
def _start_next_timer(self):
time_left = (PlaylistEntry.get(self.entries[0]).duration -
self._get_seek_time() - CROSSFADE_DURATION_SECONDS)
play_timer = threading.Timer(time_left, self._next)
_play_timers[self.key().name()] = play_timer
background_thread.start_new_background_thread(play_timer.start, [])
def _cancel_next_timer(self):
_play_timers[self.key().name()].cancel()
del _play_timers[self.key().name()]
def _is_empty(self):
return not self.entries
def _get_seek_time(self):
if self.status == Status.Playing:
elapsed_seconds = (datetime.datetime.now() - self.start_time).total_seconds()
else:
elapsed_seconds = 0
return self.seek_time + elapsed_seconds
def _get_status_string(self):
return {
Status.Stopped: 'stopped',
Status.Playing: 'playing',
Status.Paused: 'paused',
}[self.status]
@staticmethod
def _send_message(client, message):
channel.send_message(client, json.dumps(message))
def _broadcast(self, message):
for client in self._clients:
self._send_message(client, message)