comparison libervia/backend/plugins/plugin_misc_radiocol.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/plugins/plugin_misc_radiocol.py@524856bd7b19
children 0d7bb4df2343
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3
4 # SAT plugin for managing Radiocol
5 # Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _, D_
21 from libervia.backend.core.constants import Const as C
22 from libervia.backend.core.log import getLogger
23
24 log = getLogger(__name__)
25 from twisted.words.xish import domish
26 from twisted.internet import reactor
27 from twisted.words.protocols.jabber import jid
28 from twisted.internet import defer
29 from libervia.backend.core import exceptions
30 import os.path
31 import copy
32 import time
33 from os import unlink
34
35 try:
36 from mutagen.oggvorbis import OggVorbis, OggVorbisHeaderError
37 from mutagen.mp3 import MP3, HeaderNotFoundError
38 from mutagen.easyid3 import EasyID3
39 from mutagen.id3 import ID3NoHeaderError
40 except ImportError:
41 raise exceptions.MissingModule(
42 "Missing module Mutagen, please download/install from https://bitbucket.org/lazka/mutagen"
43 )
44
45
46 NC_RADIOCOL = "http://www.goffi.org/protocol/radiocol"
47 RADIOC_TAG = "radiocol"
48
49 PLUGIN_INFO = {
50 C.PI_NAME: "Radio collective plugin",
51 C.PI_IMPORT_NAME: "Radiocol",
52 C.PI_TYPE: "Exp",
53 C.PI_PROTOCOLS: [],
54 C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0249", "ROOM-GAME"],
55 C.PI_MAIN: "Radiocol",
56 C.PI_HANDLER: "yes",
57 C.PI_DESCRIPTION: _("""Implementation of radio collective"""),
58 }
59
60
61 # Number of songs needed in the queue before we start playing
62 QUEUE_TO_START = 2
63 # Maximum number of songs in the queue (the song being currently played doesn't count)
64 QUEUE_LIMIT = 2
65
66
67 class Radiocol(object):
68 def inherit_from_room_game(self, host):
69 global RoomGame
70 RoomGame = host.plugins["ROOM-GAME"].__class__
71 self.__class__ = type(
72 self.__class__.__name__, (self.__class__, RoomGame, object), {}
73 )
74
75 def __init__(self, host):
76 log.info(_("Radio collective initialization"))
77 self.inherit_from_room_game(host)
78 RoomGame._init_(
79 self,
80 host,
81 PLUGIN_INFO,
82 (NC_RADIOCOL, RADIOC_TAG),
83 game_init={
84 "queue": [],
85 "upload": True,
86 "playing": None,
87 "playing_time": 0,
88 "to_delete": {},
89 },
90 )
91 self.host = host
92 host.bridge.add_method(
93 "radiocol_launch",
94 ".plugin",
95 in_sign="asss",
96 out_sign="",
97 method=self._prepare_room,
98 async_=True,
99 )
100 host.bridge.add_method(
101 "radiocol_create",
102 ".plugin",
103 in_sign="sass",
104 out_sign="",
105 method=self._create_game,
106 )
107 host.bridge.add_method(
108 "radiocol_song_added",
109 ".plugin",
110 in_sign="sss",
111 out_sign="",
112 method=self._radiocol_song_added,
113 async_=True,
114 )
115 host.bridge.add_signal(
116 "radiocol_players", ".plugin", signature="ssass"
117 ) # room_jid, referee, players, profile
118 host.bridge.add_signal(
119 "radiocol_started", ".plugin", signature="ssasais"
120 ) # room_jid, referee, players, [QUEUE_TO_START, QUEUE_LIMIT], profile
121 host.bridge.add_signal(
122 "radiocol_song_rejected", ".plugin", signature="sss"
123 ) # room_jid, reason, profile
124 host.bridge.add_signal(
125 "radiocol_preload", ".plugin", signature="ssssssss"
126 ) # room_jid, timestamp, filename, title, artist, album, profile
127 host.bridge.add_signal(
128 "radiocol_play", ".plugin", signature="sss"
129 ) # room_jid, filename, profile
130 host.bridge.add_signal(
131 "radiocol_no_upload", ".plugin", signature="ss"
132 ) # room_jid, profile
133 host.bridge.add_signal(
134 "radiocol_upload_ok", ".plugin", signature="ss"
135 ) # room_jid, profile
136
137 def __create_preload_elt(self, sender, song_added_elt):
138 preload_elt = copy.deepcopy(song_added_elt)
139 preload_elt.name = "preload"
140 preload_elt["sender"] = sender
141 preload_elt["timestamp"] = str(time.time())
142 # attributes filename, title, artist, album, length have been copied
143 # XXX: the frontend should know the temporary directory where file is put
144 return preload_elt
145
146 def _radiocol_song_added(self, referee_s, song_path, profile):
147 return self.radiocol_song_added(jid.JID(referee_s), song_path, profile)
148
149 def radiocol_song_added(self, referee, song_path, profile):
150 """This method is called by libervia when a song has been uploaded
151 @param referee (jid.JID): JID of the referee in the room (room userhost + '/' + nick)
152 @param song_path (unicode): absolute path of the song added
153 @param profile_key (unicode): %(doc_profile_key)s
154 @return: a Deferred instance
155 """
156 # XXX: this is a Q&D way for the proof of concept. In the future, the song should
157 # be streamed to the backend using XMPP file copy
158 # Here we cheat because we know we are on the same host, and we don't
159 # check data. Referee will have to parse the song himself to check it
160 try:
161 if song_path.lower().endswith(".mp3"):
162 actual_song = MP3(song_path)
163 try:
164 song = EasyID3(song_path)
165
166 class Info(object):
167 def __init__(self, length):
168 self.length = length
169
170 song.info = Info(actual_song.info.length)
171 except ID3NoHeaderError:
172 song = actual_song
173 else:
174 song = OggVorbis(song_path)
175 except (OggVorbisHeaderError, HeaderNotFoundError):
176 # this file is not ogg vorbis nor mp3, we reject it
177 self.delete_file(song_path) # FIXME: same host trick (see note above)
178 return defer.fail(
179 exceptions.DataError(
180 D_(
181 "The uploaded file has been rejected, only Ogg Vorbis and MP3 songs are accepted."
182 )
183 )
184 )
185
186 attrs = {
187 "filename": os.path.basename(song_path),
188 "title": song.get("title", ["Unknown"])[0],
189 "artist": song.get("artist", ["Unknown"])[0],
190 "album": song.get("album", ["Unknown"])[0],
191 "length": str(song.info.length),
192 }
193 radio_data = self.games[
194 referee.userhostJID()
195 ] # FIXME: referee comes from Libervia's client side, it's unsecure
196 radio_data["to_delete"][
197 attrs["filename"]
198 ] = (
199 song_path
200 ) # FIXME: works only because of the same host trick, see the note under the docstring
201 return self.send(referee, ("", "song_added"), attrs, profile=profile)
202
203 def play_next(self, room_jid, profile):
204 """"Play next song in queue if exists, and put a timer
205 which trigger after the song has been played to play next one"""
206 # TODO: songs need to be erased once played or found invalids
207 # ==> unlink done the Q&D way with the same host trick (see above)
208 radio_data = self.games[room_jid]
209 if len(radio_data["players"]) == 0:
210 log.debug(_("No more participants in the radiocol: cleaning data"))
211 radio_data["queue"] = []
212 for filename in radio_data["to_delete"]:
213 self.delete_file(filename, radio_data)
214 radio_data["to_delete"] = {}
215 queue = radio_data["queue"]
216 if not queue:
217 # nothing left to play, we need to wait for uploads
218 radio_data["playing"] = None
219 return
220 song = queue.pop(0)
221 filename, length = song["filename"], float(song["length"])
222 self.send(room_jid, ("", "play"), {"filename": filename}, profile=profile)
223 radio_data["playing"] = song
224 radio_data["playing_time"] = time.time()
225
226 if not radio_data["upload"] and len(queue) < QUEUE_LIMIT:
227 # upload is blocked and we now have resources to get more, we reactivate it
228 self.send(room_jid, ("", "upload_ok"), profile=profile)
229 radio_data["upload"] = True
230
231 reactor.callLater(length, self.play_next, room_jid, profile)
232 # we wait more than the song length to delete the file, to manage poorly reactive networks/clients
233 reactor.callLater(
234 length + 90, self.delete_file, filename, radio_data
235 ) # FIXME: same host trick (see above)
236
237 def delete_file(self, filename, radio_data=None):
238 """
239 Delete a previously uploaded file.
240 @param filename: filename to delete, or full filepath if radio_data is None
241 @param radio_data: current game data
242 @return: True if the file has been deleted
243 """
244 if radio_data:
245 try:
246 file_to_delete = radio_data["to_delete"][filename]
247 except KeyError:
248 log.error(
249 _("INTERNAL ERROR: can't find full path of the song to delete")
250 )
251 return False
252 else:
253 file_to_delete = filename
254 try:
255 unlink(file_to_delete)
256 except OSError:
257 log.error(
258 _("INTERNAL ERROR: can't find %s on the file system" % file_to_delete)
259 )
260 return False
261 return True
262
263 def room_game_cmd(self, mess_elt, profile):
264 from_jid = jid.JID(mess_elt["from"])
265 room_jid = from_jid.userhostJID()
266 nick = self.host.plugins["XEP-0045"].get_room_nick(room_jid, profile)
267
268 radio_elt = mess_elt.firstChildElement()
269 radio_data = self.games[room_jid]
270 if "queue" in radio_data:
271 queue = radio_data["queue"]
272
273 from_referee = self.is_referee(room_jid, from_jid.resource)
274 to_referee = self.is_referee(room_jid, jid.JID(mess_elt["to"]).user)
275 is_player = self.is_player(room_jid, nick)
276 for elt in radio_elt.elements():
277 if not from_referee and not (to_referee and elt.name == "song_added"):
278 continue # sender must be referee, expect when a song is submitted
279 if not is_player and (elt.name not in ("started", "players")):
280 continue # user is in the room but not playing
281
282 if elt.name in (
283 "started",
284 "players",
285 ): # new game created and/or players list updated
286 players = []
287 for player in elt.elements():
288 players.append(str(player))
289 signal = (
290 self.host.bridge.radiocol_started
291 if elt.name == "started"
292 else self.host.bridge.radiocol_players
293 )
294 signal(
295 room_jid.userhost(),
296 from_jid.full(),
297 players,
298 [QUEUE_TO_START, QUEUE_LIMIT],
299 profile,
300 )
301 elif elt.name == "preload": # a song is in queue and must be preloaded
302 self.host.bridge.radiocol_preload(
303 room_jid.userhost(),
304 elt["timestamp"],
305 elt["filename"],
306 elt["title"],
307 elt["artist"],
308 elt["album"],
309 elt["sender"],
310 profile,
311 )
312 elif elt.name == "play":
313 self.host.bridge.radiocol_play(
314 room_jid.userhost(), elt["filename"], profile
315 )
316 elif elt.name == "song_rejected": # a song has been refused
317 self.host.bridge.radiocol_song_rejected(
318 room_jid.userhost(), elt["reason"], profile
319 )
320 elif elt.name == "no_upload":
321 self.host.bridge.radiocol_no_upload(room_jid.userhost(), profile)
322 elif elt.name == "upload_ok":
323 self.host.bridge.radiocol_upload_ok(room_jid.userhost(), profile)
324 elif elt.name == "song_added": # a song has been added
325 # FIXME: we are KISS for the proof of concept: every song is added, to a limit of 3 in queue.
326 # Need to manage some sort of rules to allow peoples to send songs
327 if len(queue) >= QUEUE_LIMIT:
328 # there are already too many songs in queue, we reject this one
329 # FIXME: add an error code
330 self.send(
331 from_jid,
332 ("", "song_rejected"),
333 {"reason": "Too many songs in queue"},
334 profile=profile,
335 )
336 return
337
338 # The song is accepted and added in queue
339 preload_elt = self.__create_preload_elt(from_jid.resource, elt)
340 queue.append(preload_elt)
341
342 if len(queue) >= QUEUE_LIMIT:
343 # We are at the limit, we refuse new upload until next play
344 self.send(room_jid, ("", "no_upload"), profile=profile)
345 radio_data["upload"] = False
346
347 self.send(room_jid, preload_elt, profile=profile)
348 if not radio_data["playing"] and len(queue) == QUEUE_TO_START:
349 # We have not started playing yet, and we have QUEUE_TO_START
350 # songs in queue. We can now start the party :)
351 self.play_next(room_jid, profile)
352 else:
353 log.error(_("Unmanaged game element: %s") % elt.name)
354
355 def get_sync_data_for_player(self, room_jid, nick):
356 game_data = self.games[room_jid]
357 elements = []
358 if game_data["playing"]:
359 preload = copy.deepcopy(game_data["playing"])
360 current_time = game_data["playing_time"] + 1 if self.testing else time.time()
361 preload["filename"] += "#t=%.2f" % (current_time - game_data["playing_time"])
362 elements.append(preload)
363 play = domish.Element(("", "play"))
364 play["filename"] = preload["filename"]
365 elements.append(play)
366 if len(game_data["queue"]) > 0:
367 elements.extend(copy.deepcopy(game_data["queue"]))
368 if len(game_data["queue"]) == QUEUE_LIMIT:
369 elements.append(domish.Element(("", "no_upload")))
370 return elements