diff --git a/docs/syncplay-server.1 b/docs/syncplay-server.1 index 32b779ef..e9b91e8e 100644 --- a/docs/syncplay-server.1 +++ b/docs/syncplay-server.1 @@ -77,6 +77,14 @@ Random string used to generate managed room passwords. .B \-\-motd\-file [file] Path to a file from which motd (Message Of The Day) will be read. +.TP +.B \-\-rooms\-dir [directory] +Path to a directory from where room data will be written to and read from. This will enable rooms to persist without watchers and through restarts. Will not work if using \fB\-\-isolate\-rooms\fP. + +.TP +.B \-\-rooms\-timer [directory] +Requires \fB\-\-rooms\-timer\fP. Time in seconds that rooms will persist without users. \fB0\fP disables the timer, meaning rooms persist permanently. + .TP .B \-\-max\-chat\-message\-length [maxChatMessageLength] Maximum number of characters in one chat message (default is 150). diff --git a/syncplay/ep_server.py b/syncplay/ep_server.py index 22aec440..d2e4aefa 100644 --- a/syncplay/ep_server.py +++ b/syncplay/ep_server.py @@ -34,6 +34,8 @@ def main(): args.port, args.password, args.motd_file, + args.rooms_dir, + args.rooms_timer, args.isolate_rooms, args.salt, args.disable_ready, diff --git a/syncplay/messages_de.py b/syncplay/messages_de.py index 632293e7..595ea50e 100755 --- a/syncplay/messages_de.py +++ b/syncplay/messages_de.py @@ -474,6 +474,8 @@ "server-salt-argument": "zufällige Zeichenkette, die zur Erstellung von Passwörtern verwendet wird", "server-disable-ready-argument": "Bereitschaftsfeature deaktivieren", "server-motd-argument": "Pfad zur Datei, von der die Nachricht des Tages geladen wird", + "server-rooms-argument": "path to directory to store/fetch room data. Enables rooms to persist without watchers and through restarts", # TODO: Translate + "server-timer-argument": "time in seconds before a persistent room with no watchers is pruned. 0 disables pruning", # TODO: Translate "server-chat-argument": "Soll Chat deaktiviert werden?", "server-chat-maxchars-argument": "Maximale Zeichenzahl in einer Chatnachricht (Standard ist {})", "server-maxusernamelength-argument": "Maximale Zeichenzahl in einem Benutzernamen (Standard ist {})", diff --git a/syncplay/messages_en.py b/syncplay/messages_en.py index 8ad9d054..ac7b25b9 100755 --- a/syncplay/messages_en.py +++ b/syncplay/messages_en.py @@ -475,6 +475,8 @@ "server-salt-argument": "random string used to generate managed room passwords", "server-disable-ready-argument": "disable readiness feature", "server-motd-argument": "path to file from which motd will be fetched", + "server-rooms-argument": "path to directory to store/fetch room data. Enables rooms to persist without watchers and through restarts", + "server-timer-argument": "time in seconds before a persistent room with no watchers is pruned. 0 disables pruning", "server-chat-argument": "Should chat be disabled?", "server-chat-maxchars-argument": "Maximum number of characters in a chat message (default is {})", # Default number of characters "server-maxusernamelength-argument": "Maximum number of characters in a username (default is {})", diff --git a/syncplay/messages_es.py b/syncplay/messages_es.py index b45a7d1b..cf4cd57f 100644 --- a/syncplay/messages_es.py +++ b/syncplay/messages_es.py @@ -474,6 +474,8 @@ "server-salt-argument": "cadena aleatoria utilizada para generar contraseñas de salas administradas", "server-disable-ready-argument": "deshabilitar la función de preparación", "server-motd-argument": "ruta al archivo del cual se obtendrá el texto motd", + "server-rooms-argument": "path to directory to store/fetch room data. Enables rooms to persist without watchers and through restarts", # TODO: Translate + "server-timer-argument": "time in seconds before a persistent room with no watchers is pruned. 0 disables pruning", # TODO: Translate "server-chat-argument": "¿Debería deshabilitarse el chat?", "server-chat-maxchars-argument": "Número máximo de caracteres en un mensaje de chat (el valor predeterminado es {})", # Default number of characters "server-maxusernamelength-argument": "Número máximo de caracteres para el nombre de usuario (el valor predeterminado es {})", diff --git a/syncplay/messages_it.py b/syncplay/messages_it.py index 72429a24..44dd2a4f 100755 --- a/syncplay/messages_it.py +++ b/syncplay/messages_it.py @@ -474,6 +474,8 @@ "server-salt-argument": "usare stringhe casuali per generare le password delle stanze gestite", "server-disable-ready-argument": "disabilita la funzionalità \"pronto\"", "server-motd-argument": "percorso del file da cui verrà letto il messaggio del giorno", + "server-rooms-argument": "path to directory to store/fetch room data. Enables rooms to persist without watchers and through restarts", # TODO: Translate + "server-timer-argument": "time in seconds before a persistent room with no watchers is pruned. 0 disables pruning", # TODO: Translate "server-chat-argument": "abilita o disabilita la chat", "server-chat-maxchars-argument": "Numero massimo di caratteri in un messaggio di chat (default è {})", # Default number of characters "server-maxusernamelength-argument": "Numero massimo di caratteri in un nome utente (default è {})", diff --git a/syncplay/messages_pt_BR.py b/syncplay/messages_pt_BR.py index 05c4060d..49407d50 100644 --- a/syncplay/messages_pt_BR.py +++ b/syncplay/messages_pt_BR.py @@ -475,6 +475,8 @@ "server-salt-argument": "string aleatória utilizada para gerar senhas de salas gerenciadas", "server-disable-ready-argument": "desativar recurso de prontidão", "server-motd-argument": "caminho para o arquivo o qual o motd será obtido", + "server-rooms-argument": "path to directory to store/fetch room data. Enables rooms to persist without watchers and through restarts", # TODO: Translate + "server-timer-argument": "time in seconds before a persistent room with no watchers is pruned. 0 disables pruning", # TODO: Translate "server-chat-argument": "O chat deve ser desativado?", "server-chat-maxchars-argument": "Número máximo de caracteres numa mensagem do chat (o padrão é {})", # Default number of characters "server-maxusernamelength-argument": "Número máximos de caracteres num nome de usuário (o padrão é {})", diff --git a/syncplay/messages_pt_PT.py b/syncplay/messages_pt_PT.py index a451390a..4cdbedb6 100644 --- a/syncplay/messages_pt_PT.py +++ b/syncplay/messages_pt_PT.py @@ -474,6 +474,8 @@ "server-salt-argument": "string aleatória utilizada para gerar senhas de salas gerenciadas", "server-disable-ready-argument": "desativar recurso de prontidão", "server-motd-argument": "caminho para o arquivo o qual o motd será obtido", + "server-rooms-argument": "path to directory to store/fetch room data. Enables rooms to persist without watchers and through restarts", # TODO: Translate + "server-timer-argument": "time in seconds before a persistent room with no watchers is pruned. 0 disables pruning", # TODO: Translate "server-chat-argument": "O chat deve ser desativado?", "server-chat-maxchars-argument": "Número máximo de caracteres numa mensagem do chat (o padrão é {})", # Default number of characters "server-maxusernamelength-argument": "Número máximos de caracteres num nome de utilizador (o padrão é {})", diff --git a/syncplay/messages_ru.py b/syncplay/messages_ru.py index c9cda466..b9a3e4a6 100755 --- a/syncplay/messages_ru.py +++ b/syncplay/messages_ru.py @@ -471,6 +471,8 @@ "server-salt-argument": "генерировать пароли к управляемым комнатам на основании указанной строки (соли)", "server-disable-ready-argument": "отключить статусы готов/не готов", "server-motd-argument": "путь к файлу, из которого будет извлекаться MOTD-сообщение", + "server-rooms-argument": "path to directory to store/fetch room data. Enables rooms to persist without watchers and through restarts", # TODO: Translate + "server-timer-argument": "time in seconds before a persistent room with no watchers is pruned. 0 disables pruning", # TODO: Translate "server-chat-argument": "Должен ли чат быть отключён?", "server-chat-maxchars-argument": "Максимальное число символов в сообщениях в чате (по умолчанию {})", "server-maxusernamelength-argument": "Максимальное число символов в именах пользователей (по умолчанию {})", diff --git a/syncplay/messages_tr.py b/syncplay/messages_tr.py index efb045aa..ad602b47 100644 --- a/syncplay/messages_tr.py +++ b/syncplay/messages_tr.py @@ -475,6 +475,8 @@ "server-salt-argument": "yönetilen oda şifreleri oluşturmak için kullanılan rastgele dize", "server-disable-ready-argument": "hazır olma özelliğini devre dışı bırak", "server-motd-argument": "motd alınacak dosyanın yolu", + "server-rooms-argument": "path to directory to store/fetch room data. Enables rooms to persist without watchers and through restarts", # TODO: Translate + "server-timer-argument": "time in seconds before a persistent room with no watchers is pruned. 0 disables pruning", # TODO: Translate "server-chat-argument": "Sohbet devre dışı bırakılmalı mı?", "server-chat-maxchars-argument": "Bir sohbet mesajındaki maksimum karakter sayısı (varsayılan: {})", # Default number of characters "server-maxusernamelength-argument": "Bir kullanıcı adındaki maksimum karakter sayısı (varsayılan {})", diff --git a/syncplay/server.py b/syncplay/server.py index 21de91b9..58b7c8d0 100755 --- a/syncplay/server.py +++ b/syncplay/server.py @@ -4,6 +4,7 @@ import os import random import time +import json from string import Template from twisted.enterprise import adbapi @@ -25,7 +26,7 @@ class SyncFactory(Factory): - def __init__(self, port='', password='', motdFilePath=None, isolateRooms=False, salt=None, + def __init__(self, port='', password='', motdFilePath=None, roomsDirPath=None, roomsTimer=31558149, isolateRooms=False, salt=None, disableReady=False, disableChat=False, maxChatMessageLength=constants.MAX_CHAT_MESSAGE_LENGTH, maxUsernameLength=constants.MAX_USERNAME_LENGTH, statsDbFile=None, tlsCertPath=None): self.isolateRooms = isolateRooms @@ -40,12 +41,14 @@ def __init__(self, port='', password='', motdFilePath=None, isolateRooms=False, print(getMessage("no-salt-notification").format(salt)) self._salt = salt self._motdFilePath = motdFilePath + self._roomsDirPath = roomsDirPath if roomsDirPath is not None and os.path.isdir(roomsDirPath) else None + self._roomsTimer = roomsTimer if roomsDirPath is not None and isinstance(roomsTimer, int) and roomsTimer > 0 else 0 self.disableReady = disableReady self.disableChat = disableChat self.maxChatMessageLength = maxChatMessageLength if maxChatMessageLength is not None else constants.MAX_CHAT_MESSAGE_LENGTH self.maxUsernameLength = maxUsernameLength if maxUsernameLength is not None else constants.MAX_USERNAME_LENGTH if not isolateRooms: - self._roomManager = RoomManager() + self._roomManager = RoomManager(self._roomsDirPath, self._roomsTimer) else: self._roomManager = PublicRoomManager() if statsDbFile is not None: @@ -311,8 +314,22 @@ def addVersionLog(self, timestamp, version): class RoomManager(object): - def __init__(self): + def __init__(self, roomsDir=None, timer=0): + self._roomsDir = roomsDir + self._timer = timer self._rooms = {} + if self._roomsDir is not None: + for root, dirs, files in os.walk(self._roomsDir): + for file in files: + if file.endswith(".room"): + room = Room('', self._roomsDir) + room.loadFromFile(os.path.join(root, file)) + roomName = truncateText(room.getName(), constants.MAX_ROOM_NAME_LENGTH) + if len(room.getPlaylist()) == 0 or room.isStale(self._timer): + os.remove(os.path.join(root, file)) + del room + else: + self._rooms[roomName] = room def broadcastRoom(self, sender, whatLambda): room = sender.getRoom() @@ -342,16 +359,19 @@ def removeWatcher(self, watcher): oldRoom = watcher.getRoom() if oldRoom: oldRoom.removeWatcher(watcher) - self._deleteRoomIfEmpty(oldRoom) + if self._roomsDir is None or oldRoom.isStale(self._timer): + self._deleteRoomIfEmpty(oldRoom) def _getRoom(self, roomName): - if roomName in self._rooms: + if roomName in self._rooms and not self._rooms[roomName].isStale(self._timer): return self._rooms[roomName] else: if RoomPasswordProvider.isControlledRoom(roomName): room = ControlledRoom(roomName) else: - room = Room(roomName) + if roomName in self._rooms: + self._deleteRoomIfEmpty(self._rooms[roomName]) + room = Room(roomName, self._roomsDir) self._rooms[roomName] = room return room @@ -392,19 +412,63 @@ class Room(object): STATE_PAUSED = 0 STATE_PLAYING = 1 - def __init__(self, name): + def __init__(self, name, _roomsDir=None): self._name = name + self._roomsDir = _roomsDir self._watchers = {} self._playState = self.STATE_PAUSED self._setBy = None self._playlist = [] self._playlistIndex = None self._lastUpdate = time.time() + self._lastSavedUpdate = 0 self._position = 0 def __str__(self, *args, **kwargs): return self.getName() + def roomsCanPersist(self): + return self._roomsDir is not None + + def isPermanent(self): + return self.roomsCanPersist() + + def sanitizeFilename(self, filename, blacklist="<>:/\\|?*\"", placeholder="_"): + return ''.join([c if c not in blacklist and ord(c) >= 32 else placeholder for c in filename]) + + def writeToFile(self): + if not self.isPermanent(): + return + filename = os.path.join(self._roomsDir, self.sanitizeFilename(self._name)+'.room') + if len(self._playlist) == 0: + try: + os.remove(filename) + except Exception: + pass + return + data = {} + data['name'] = self._name + data['playlist'] = self._playlist + data['playlistIndex'] = self._playlistIndex + data['position'] = self._position + data['lastSavedUpdate'] = self._lastSavedUpdate + with open(filename, "w") as outfile: + json.dump(data, outfile) + + def loadFromFile(self, filename): + with open(filename) as json_file: + data = json.load(json_file) + self._name = truncateText(data['name'], constants.MAX_ROOM_NAME_LENGTH) + self._playlist = data['playlist'] + self._playlistIndex = data['playlistIndex'] + self._position = data['position'] + self._lastSavedUpdate = data['lastSavedUpdate'] + + def isStale(self, timer): + if timer == 0 or self._lastSavedUpdate == 0: + return False + return time.time() - self._lastSavedUpdate > timer + def getName(self): return self._name @@ -414,7 +478,7 @@ def getPosition(self): watcher = min(self._watchers.values()) self._setBy = watcher self._position = watcher.getPosition() - self._lastUpdate = time.time() + self._lastSavedUpdate = self._lastUpdate = time.time() return self._position elif self._position is not None: return self._position + (age if self._playState == self.STATE_PLAYING else 0) @@ -424,12 +488,14 @@ def getPosition(self): def setPaused(self, paused=STATE_PAUSED, setBy=None): self._playState = paused self._setBy = setBy + self.writeToFile() def setPosition(self, position, setBy=None): self._position = position for watcher in self._watchers.values(): watcher.setPosition(position) self._setBy = setBy + self.writeToFile() def isPlaying(self): return self._playState == self.STATE_PLAYING @@ -441,7 +507,7 @@ def getWatchers(self): return list(self._watchers.values()) def addWatcher(self, watcher): - if self._watchers: + if self._watchers or self.isPermanent(): watcher.setPosition(self.getPosition()) self._watchers[watcher.getName()] = watcher watcher.setRoom(self) @@ -451,8 +517,9 @@ def removeWatcher(self, watcher): return del self._watchers[watcher.getName()] watcher.setRoom(None) - if not self._watchers: + if not self._watchers and not self.isPermanent(): self._position = 0 + self.writeToFile() def isEmpty(self): return not bool(self._watchers) @@ -465,9 +532,11 @@ def canControl(self, watcher): def setPlaylist(self, files, setBy=None): self._playlist = files + self.writeToFile() def setPlaylistIndex(self, index, setBy=None): self._playlistIndex = index + self.writeToFile() def getPlaylist(self): return self._playlist @@ -687,6 +756,8 @@ def _prepareArgParser(self): self._argparser.add_argument('--disable-chat', action='store_true', help=getMessage("server-chat-argument")) self._argparser.add_argument('--salt', metavar='salt', type=str, nargs='?', help=getMessage("server-salt-argument"), default=os.environ.get('SYNCPLAY_SALT')) self._argparser.add_argument('--motd-file', metavar='file', type=str, nargs='?', help=getMessage("server-motd-argument")) + self._argparser.add_argument('--rooms-dir', metavar='rooms', type=str, nargs='?', help=getMessage("server-rooms-argument")) + self._argparser.add_argument('--rooms-timer', metavar='timer', type=int, nargs='?',default=31558149, help=getMessage("server-timer-argument")) self._argparser.add_argument('--max-chat-message-length', metavar='maxChatMessageLength', type=int, nargs='?', help=getMessage("server-chat-maxchars-argument").format(constants.MAX_CHAT_MESSAGE_LENGTH)) self._argparser.add_argument('--max-username-length', metavar='maxUsernameLength', type=int, nargs='?', help=getMessage("server-maxusernamelength-argument").format(constants.MAX_USERNAME_LENGTH)) self._argparser.add_argument('--stats-db-file', metavar='file', type=str, nargs='?', help=getMessage("server-stats-db-file-argument"))