Skip to content

Commit

Permalink
Initial server-side room persistence implementation (#434)
Browse files Browse the repository at this point in the history
* Added room permanence option to server

* Fixed error if roomsDirPath is None

* Sanitized filenames

* Delete room file on empty playlist

* Fixed position not saving when leaving and seeking, and position not loading after a restart

* Decoupled permanence check

* Added --rooms-timer option that limits the max lifespan of persistent rooms

* Assigned filename to variable to deduplicate calculation

* Freed up room when loading unwanted room from file

Co-authored-by: Assistant <[email protected]>
  • Loading branch information
Et0h and Assistant authored Oct 28, 2021
1 parent e260557 commit f044e2d
Show file tree
Hide file tree
Showing 11 changed files with 107 additions and 10 deletions.
8 changes: 8 additions & 0 deletions docs/syncplay-server.1
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions syncplay/ep_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions syncplay/messages_de.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {})",
Expand Down
2 changes: 2 additions & 0 deletions syncplay/messages_en.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {})",
Expand Down
2 changes: 2 additions & 0 deletions syncplay/messages_es.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {})",
Expand Down
2 changes: 2 additions & 0 deletions syncplay/messages_it.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 è {})",
Expand Down
2 changes: 2 additions & 0 deletions syncplay/messages_pt_BR.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 é {})",
Expand Down
2 changes: 2 additions & 0 deletions syncplay/messages_pt_PT.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 é {})",
Expand Down
2 changes: 2 additions & 0 deletions syncplay/messages_ru.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "Максимальное число символов в именах пользователей (по умолчанию {})",
Expand Down
2 changes: 2 additions & 0 deletions syncplay/messages_tr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {})",
Expand Down
91 changes: 81 additions & 10 deletions syncplay/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
import random
import time
import json
from string import Template

from twisted.enterprise import adbapi
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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"))
Expand Down

0 comments on commit f044e2d

Please sign in to comment.