comparison sat/plugins/plugin_misc_radiocol.py @ 2562:26edcf3a30eb

core, setup: huge cleaning: - moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention - move twisted directory to root - removed all hacks from setup.py, and added missing dependencies, it is now clean - use https URL for website in setup.py - removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed - renamed sat.sh to sat and fixed its installation - added python_requires to specify Python version needed - replaced glib2reactor which use deprecated code by gtk3reactor sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author Goffi <goffi@goffi.org>
date Mon, 02 Apr 2018 19:44:50 +0200
parents src/plugins/plugin_misc_radiocol.py@0046283a285d
children 56f94936df1e
comparison
equal deleted inserted replaced
2561:bd30dc3ffe5a 2562:26edcf3a30eb
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # SAT plugin for managing Radiocol
5 # Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 from sat.core.i18n import _, D_
21 from sat.core.constants import Const as C
22 from sat.core.log import getLogger
23 log = getLogger(__name__)
24 from twisted.words.xish import domish
25 from twisted.internet import reactor
26 from twisted.words.protocols.jabber import jid
27 from twisted.internet import defer
28 from sat.core import exceptions
29 import os.path
30 import copy
31 import time
32 from os import unlink
33 try:
34 from mutagen.oggvorbis import OggVorbis, OggVorbisHeaderError
35 from mutagen.mp3 import MP3, HeaderNotFoundError
36 from mutagen.easyid3 import EasyID3
37 from mutagen.id3 import ID3NoHeaderError
38 except ImportError:
39 raise exceptions.MissingModule(u"Missing module Mutagen, please download/install from https://bitbucket.org/lazka/mutagen")
40
41
42 NC_RADIOCOL = 'http://www.goffi.org/protocol/radiocol'
43 RADIOC_TAG = 'radiocol'
44
45 PLUGIN_INFO = {
46 C.PI_NAME: "Radio collective plugin",
47 C.PI_IMPORT_NAME: "Radiocol",
48 C.PI_TYPE: "Exp",
49 C.PI_PROTOCOLS: [],
50 C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0249", "ROOM-GAME"],
51 C.PI_MAIN: "Radiocol",
52 C.PI_HANDLER: "yes",
53 C.PI_DESCRIPTION: _("""Implementation of radio collective""")
54 }
55
56
57 # Number of songs needed in the queue before we start playing
58 QUEUE_TO_START = 2
59 # Maximum number of songs in the queue (the song being currently played doesn't count)
60 QUEUE_LIMIT = 2
61
62
63 class Radiocol(object):
64
65 def inheritFromRoomGame(self, host):
66 global RoomGame
67 RoomGame = host.plugins["ROOM-GAME"].__class__
68 self.__class__ = type(self.__class__.__name__, (self.__class__, RoomGame, object), {})
69
70 def __init__(self, host):
71 log.info(_("Radio collective initialization"))
72 self.inheritFromRoomGame(host)
73 RoomGame._init_(self, host, PLUGIN_INFO, (NC_RADIOCOL, RADIOC_TAG),
74 game_init={'queue': [], 'upload': True, 'playing': None, 'playing_time': 0, 'to_delete': {}})
75 self.host = host
76 host.bridge.addMethod("radiocolLaunch", ".plugin", in_sign='asss', out_sign='', method=self._prepareRoom, async=True)
77 host.bridge.addMethod("radiocolCreate", ".plugin", in_sign='sass', out_sign='', method=self._createGame)
78 host.bridge.addMethod("radiocolSongAdded", ".plugin", in_sign='sss', out_sign='', method=self._radiocolSongAdded, async=True)
79 host.bridge.addSignal("radiocolPlayers", ".plugin", signature='ssass') # room_jid, referee, players, profile
80 host.bridge.addSignal("radiocolStarted", ".plugin", signature='ssasais') # room_jid, referee, players, [QUEUE_TO_START, QUEUE_LIMIT], profile
81 host.bridge.addSignal("radiocolSongRejected", ".plugin", signature='sss') # room_jid, reason, profile
82 host.bridge.addSignal("radiocolPreload", ".plugin", signature='ssssssss') # room_jid, timestamp, filename, title, artist, album, profile
83 host.bridge.addSignal("radiocolPlay", ".plugin", signature='sss') # room_jid, filename, profile
84 host.bridge.addSignal("radiocolNoUpload", ".plugin", signature='ss') # room_jid, profile
85 host.bridge.addSignal("radiocolUploadOk", ".plugin", signature='ss') # room_jid, profile
86
87 def __create_preload_elt(self, sender, song_added_elt):
88 preload_elt = copy.deepcopy(song_added_elt)
89 preload_elt.name = 'preload'
90 preload_elt['sender'] = sender
91 preload_elt['timestamp'] = str(time.time())
92 # attributes filename, title, artist, album, length have been copied
93 # XXX: the frontend should know the temporary directory where file is put
94 return preload_elt
95
96 def _radiocolSongAdded(self, referee_s, song_path, profile):
97 return self.radiocolSongAdded(jid.JID(referee_s), song_path, profile)
98
99 def radiocolSongAdded(self, referee, song_path, profile):
100 """This method is called by libervia when a song has been uploaded
101 @param referee (jid.JID): JID of the referee in the room (room userhost + '/' + nick)
102 @param song_path (unicode): absolute path of the song added
103 @param profile_key (unicode): %(doc_profile_key)s
104 @return: a Deferred instance
105 """
106 # XXX: this is a Q&D way for the proof of concept. In the future, the song should
107 # be streamed to the backend using XMPP file copy
108 # Here we cheat because we know we are on the same host, and we don't
109 # check data. Referee will have to parse the song himself to check it
110 try:
111 if song_path.lower().endswith('.mp3'):
112 actual_song = MP3(song_path)
113 try:
114 song = EasyID3(song_path)
115
116 class Info(object):
117 def __init__(self, length):
118 self.length = length
119 song.info = Info(actual_song.info.length)
120 except ID3NoHeaderError:
121 song = actual_song
122 else:
123 song = OggVorbis(song_path)
124 except (OggVorbisHeaderError, HeaderNotFoundError):
125 # this file is not ogg vorbis nor mp3, we reject it
126 self.deleteFile(song_path) # FIXME: same host trick (see note above)
127 return defer.fail(exceptions.DataError(D_("The uploaded file has been rejected, only Ogg Vorbis and MP3 songs are accepted.")))
128
129 attrs = {'filename': os.path.basename(song_path),
130 'title': song.get("title", ["Unknown"])[0],
131 'artist': song.get("artist", ["Unknown"])[0],
132 'album': song.get("album", ["Unknown"])[0],
133 'length': str(song.info.length)
134 }
135 radio_data = self.games[referee.userhostJID()] # FIXME: referee comes from Libervia's client side, it's unsecure
136 radio_data['to_delete'][attrs['filename']] = song_path # FIXME: works only because of the same host trick, see the note under the docstring
137 return self.send(referee, ('', 'song_added'), attrs, profile=profile)
138
139 def playNext(self, room_jid, profile):
140 """"Play next song in queue if exists, and put a timer
141 which trigger after the song has been played to play next one"""
142 # TODO: songs need to be erased once played or found invalids
143 # ==> unlink done the Q&D way with the same host trick (see above)
144 radio_data = self.games[room_jid]
145 if len(radio_data['players']) == 0:
146 log.debug(_(u'No more participants in the radiocol: cleaning data'))
147 radio_data['queue'] = []
148 for filename in radio_data['to_delete']:
149 self.deleteFile(filename, radio_data)
150 radio_data['to_delete'] = {}
151 queue = radio_data['queue']
152 if not queue:
153 # nothing left to play, we need to wait for uploads
154 radio_data['playing'] = None
155 return
156 song = queue.pop(0)
157 filename, length = song['filename'], float(song['length'])
158 self.send(room_jid, ('', 'play'), {'filename': filename}, profile=profile)
159 radio_data['playing'] = song
160 radio_data['playing_time'] = time.time()
161
162 if not radio_data['upload'] and len(queue) < QUEUE_LIMIT:
163 # upload is blocked and we now have resources to get more, we reactivate it
164 self.send(room_jid, ('', 'upload_ok'), profile=profile)
165 radio_data['upload'] = True
166
167 reactor.callLater(length, self.playNext, room_jid, profile)
168 # we wait more than the song length to delete the file, to manage poorly reactive networks/clients
169 reactor.callLater(length + 90, self.deleteFile, filename, radio_data) # FIXME: same host trick (see above)
170
171 def deleteFile(self, filename, radio_data=None):
172 """
173 Delete a previously uploaded file.
174 @param filename: filename to delete, or full filepath if radio_data is None
175 @param radio_data: current game data
176 @return: True if the file has been deleted
177 """
178 if radio_data:
179 try:
180 file_to_delete = radio_data['to_delete'][filename]
181 except KeyError:
182 log.error(_(u"INTERNAL ERROR: can't find full path of the song to delete"))
183 return False
184 else:
185 file_to_delete = filename
186 try:
187 unlink(file_to_delete)
188 except OSError:
189 log.error(_(u"INTERNAL ERROR: can't find %s on the file system" % file_to_delete))
190 return False
191 return True
192
193 def room_game_cmd(self, mess_elt, profile):
194 from_jid = jid.JID(mess_elt['from'])
195 room_jid = from_jid.userhostJID()
196 nick = self.host.plugins["XEP-0045"].getRoomNick(room_jid, profile)
197
198 radio_elt = mess_elt.firstChildElement()
199 radio_data = self.games[room_jid]
200 if 'queue' in radio_data:
201 queue = radio_data['queue']
202
203 from_referee = self.isReferee(room_jid, from_jid.resource)
204 to_referee = self.isReferee(room_jid, jid.JID(mess_elt['to']).user)
205 is_player = self.isPlayer(room_jid, nick)
206 for elt in radio_elt.elements():
207 if not from_referee and not (to_referee and elt.name == 'song_added'):
208 continue # sender must be referee, expect when a song is submitted
209 if not is_player and (elt.name not in ('started', 'players')):
210 continue # user is in the room but not playing
211
212 if elt.name in ('started', 'players'): # new game created and/or players list updated
213 players = []
214 for player in elt.elements():
215 players.append(unicode(player))
216 signal = self.host.bridge.radiocolStarted if elt.name == 'started' else self.host.bridge.radiocolPlayers
217 signal(room_jid.userhost(), from_jid.full(), players, [QUEUE_TO_START, QUEUE_LIMIT], profile)
218 elif elt.name == 'preload': # a song is in queue and must be preloaded
219 self.host.bridge.radiocolPreload(room_jid.userhost(), elt['timestamp'], elt['filename'], elt['title'], elt['artist'], elt['album'], elt['sender'], profile)
220 elif elt.name == 'play':
221 self.host.bridge.radiocolPlay(room_jid.userhost(), elt['filename'], profile)
222 elif elt.name == 'song_rejected': # a song has been refused
223 self.host.bridge.radiocolSongRejected(room_jid.userhost(), elt['reason'], profile)
224 elif elt.name == 'no_upload':
225 self.host.bridge.radiocolNoUpload(room_jid.userhost(), profile)
226 elif elt.name == 'upload_ok':
227 self.host.bridge.radiocolUploadOk(room_jid.userhost(), profile)
228 elif elt.name == 'song_added': # a song has been added
229 # FIXME: we are KISS for the proof of concept: every song is added, to a limit of 3 in queue.
230 # Need to manage some sort of rules to allow peoples to send songs
231 if len(queue) >= QUEUE_LIMIT:
232 # there are already too many songs in queue, we reject this one
233 # FIXME: add an error code
234 self.send(from_jid, ('', 'song_rejected'), {'reason': "Too many songs in queue"}, profile=profile)
235 return
236
237 # The song is accepted and added in queue
238 preload_elt = self.__create_preload_elt(from_jid.resource, elt)
239 queue.append(preload_elt)
240
241 if len(queue) >= QUEUE_LIMIT:
242 # We are at the limit, we refuse new upload until next play
243 self.send(room_jid, ('', 'no_upload'), profile=profile)
244 radio_data['upload'] = False
245
246 self.send(room_jid, preload_elt, profile=profile)
247 if not radio_data['playing'] and len(queue) == QUEUE_TO_START:
248 # We have not started playing yet, and we have QUEUE_TO_START
249 # songs in queue. We can now start the party :)
250 self.playNext(room_jid, profile)
251 else:
252 log.error(_(u'Unmanaged game element: %s') % elt.name)
253
254 def getSyncDataForPlayer(self, room_jid, nick):
255 game_data = self.games[room_jid]
256 elements = []
257 if game_data['playing']:
258 preload = copy.deepcopy(game_data['playing'])
259 current_time = game_data['playing_time'] + 1 if self.testing else time.time()
260 preload['filename'] += '#t=%.2f' % (current_time - game_data['playing_time'])
261 elements.append(preload)
262 play = domish.Element(('', 'play'))
263 play['filename'] = preload['filename']
264 elements.append(play)
265 if len(game_data['queue']) > 0:
266 elements.extend(copy.deepcopy(game_data['queue']))
267 if len(game_data['queue']) == QUEUE_LIMIT:
268 elements.append(domish.Element(('', 'no_upload')))
269 return elements