Initial server-side room persistence implementation (#434)

* 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 <assistant.moetron@gmail.com>
This commit is contained in:
Etoh 2021-10-28 16:46:07 +01:00 committed by GitHub
parent e2605577f5
commit f044e2de10
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 107 additions and 10 deletions

View File

@ -77,6 +77,14 @@ Random string used to generate managed room passwords.
.B \-\-motd\-file [file] .B \-\-motd\-file [file]
Path to a file from which motd (Message Of The Day) will be read. 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 .TP
.B \-\-max\-chat\-message\-length [maxChatMessageLength] .B \-\-max\-chat\-message\-length [maxChatMessageLength]
Maximum number of characters in one chat message (default is 150). Maximum number of characters in one chat message (default is 150).

View File

@ -34,6 +34,8 @@ def main():
args.port, args.port,
args.password, args.password,
args.motd_file, args.motd_file,
args.rooms_dir,
args.rooms_timer,
args.isolate_rooms, args.isolate_rooms,
args.salt, args.salt,
args.disable_ready, args.disable_ready,

View File

@ -474,6 +474,8 @@ de = {
"server-salt-argument": "zufällige Zeichenkette, die zur Erstellung von Passwörtern verwendet wird", "server-salt-argument": "zufällige Zeichenkette, die zur Erstellung von Passwörtern verwendet wird",
"server-disable-ready-argument": "Bereitschaftsfeature deaktivieren", "server-disable-ready-argument": "Bereitschaftsfeature deaktivieren",
"server-motd-argument": "Pfad zur Datei, von der die Nachricht des Tages geladen wird", "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-argument": "Soll Chat deaktiviert werden?",
"server-chat-maxchars-argument": "Maximale Zeichenzahl in einer Chatnachricht (Standard ist {})", "server-chat-maxchars-argument": "Maximale Zeichenzahl in einer Chatnachricht (Standard ist {})",
"server-maxusernamelength-argument": "Maximale Zeichenzahl in einem Benutzernamen (Standard ist {})", "server-maxusernamelength-argument": "Maximale Zeichenzahl in einem Benutzernamen (Standard ist {})",

View File

@ -475,6 +475,8 @@ en = {
"server-salt-argument": "random string used to generate managed room passwords", "server-salt-argument": "random string used to generate managed room passwords",
"server-disable-ready-argument": "disable readiness feature", "server-disable-ready-argument": "disable readiness feature",
"server-motd-argument": "path to file from which motd will be fetched", "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-argument": "Should chat be disabled?",
"server-chat-maxchars-argument": "Maximum number of characters in a chat message (default is {})", # Default number of characters "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 {})", "server-maxusernamelength-argument": "Maximum number of characters in a username (default is {})",

View File

@ -474,6 +474,8 @@ es = {
"server-salt-argument": "cadena aleatoria utilizada para generar contraseñas de salas administradas", "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-disable-ready-argument": "deshabilitar la función de preparación",
"server-motd-argument": "ruta al archivo del cual se obtendrá el texto motd", "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-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-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 {})", "server-maxusernamelength-argument": "Número máximo de caracteres para el nombre de usuario (el valor predeterminado es {})",

View File

@ -474,6 +474,8 @@ it = {
"server-salt-argument": "usare stringhe casuali per generare le password delle stanze gestite", "server-salt-argument": "usare stringhe casuali per generare le password delle stanze gestite",
"server-disable-ready-argument": "disabilita la funzionalità \"pronto\"", "server-disable-ready-argument": "disabilita la funzionalità \"pronto\"",
"server-motd-argument": "percorso del file da cui verrà letto il messaggio del giorno", "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-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-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 è {})", "server-maxusernamelength-argument": "Numero massimo di caratteri in un nome utente (default è {})",

View File

@ -475,6 +475,8 @@ pt_BR = {
"server-salt-argument": "string aleatória utilizada para gerar senhas de salas gerenciadas", "server-salt-argument": "string aleatória utilizada para gerar senhas de salas gerenciadas",
"server-disable-ready-argument": "desativar recurso de prontidão", "server-disable-ready-argument": "desativar recurso de prontidão",
"server-motd-argument": "caminho para o arquivo o qual o motd será obtido", "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-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-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 é {})", "server-maxusernamelength-argument": "Número máximos de caracteres num nome de usuário (o padrão é {})",

View File

@ -474,6 +474,8 @@ pt_PT = {
"server-salt-argument": "string aleatória utilizada para gerar senhas de salas gerenciadas", "server-salt-argument": "string aleatória utilizada para gerar senhas de salas gerenciadas",
"server-disable-ready-argument": "desativar recurso de prontidão", "server-disable-ready-argument": "desativar recurso de prontidão",
"server-motd-argument": "caminho para o arquivo o qual o motd será obtido", "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-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-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 é {})", "server-maxusernamelength-argument": "Número máximos de caracteres num nome de utilizador (o padrão é {})",

View File

@ -471,6 +471,8 @@ ru = {
"server-salt-argument": "генерировать пароли к управляемым комнатам на основании указанной строки (соли)", "server-salt-argument": "генерировать пароли к управляемым комнатам на основании указанной строки (соли)",
"server-disable-ready-argument": "отключить статусы готов/не готов", "server-disable-ready-argument": "отключить статусы готов/не готов",
"server-motd-argument": "путь к файлу, из которого будет извлекаться MOTD-сообщение", "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-argument": "Должен ли чат быть отключён?",
"server-chat-maxchars-argument": "Максимальное число символов в сообщениях в чате (по умолчанию {})", "server-chat-maxchars-argument": "Максимальное число символов в сообщениях в чате (по умолчанию {})",
"server-maxusernamelength-argument": "Максимальное число символов в именах пользователей (по умолчанию {})", "server-maxusernamelength-argument": "Максимальное число символов в именах пользователей (по умолчанию {})",

View File

@ -475,6 +475,8 @@ tr = {
"server-salt-argument": "yönetilen oda şifreleri oluşturmak için kullanılan rastgele dize", "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-disable-ready-argument": "hazır olma özelliğini devre dışı bırak",
"server-motd-argument": "motd alınacak dosyanın yolu", "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-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-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 {})", "server-maxusernamelength-argument": "Bir kullanıcı adındaki maksimum karakter sayısı (varsayılan {})",

View File

@ -4,6 +4,7 @@ import hashlib
import os import os
import random import random
import time import time
import json
from string import Template from string import Template
from twisted.enterprise import adbapi from twisted.enterprise import adbapi
@ -25,7 +26,7 @@ from syncplay.utils import RoomPasswordProvider, NotControlledRoom, RandomString
class SyncFactory(Factory): 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, disableReady=False, disableChat=False, maxChatMessageLength=constants.MAX_CHAT_MESSAGE_LENGTH,
maxUsernameLength=constants.MAX_USERNAME_LENGTH, statsDbFile=None, tlsCertPath=None): maxUsernameLength=constants.MAX_USERNAME_LENGTH, statsDbFile=None, tlsCertPath=None):
self.isolateRooms = isolateRooms self.isolateRooms = isolateRooms
@ -40,12 +41,14 @@ class SyncFactory(Factory):
print(getMessage("no-salt-notification").format(salt)) print(getMessage("no-salt-notification").format(salt))
self._salt = salt self._salt = salt
self._motdFilePath = motdFilePath 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.disableReady = disableReady
self.disableChat = disableChat self.disableChat = disableChat
self.maxChatMessageLength = maxChatMessageLength if maxChatMessageLength is not None else constants.MAX_CHAT_MESSAGE_LENGTH 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 self.maxUsernameLength = maxUsernameLength if maxUsernameLength is not None else constants.MAX_USERNAME_LENGTH
if not isolateRooms: if not isolateRooms:
self._roomManager = RoomManager() self._roomManager = RoomManager(self._roomsDirPath, self._roomsTimer)
else: else:
self._roomManager = PublicRoomManager() self._roomManager = PublicRoomManager()
if statsDbFile is not None: if statsDbFile is not None:
@ -311,8 +314,22 @@ class DBManager(object):
class RoomManager(object): class RoomManager(object):
def __init__(self): def __init__(self, roomsDir=None, timer=0):
self._roomsDir = roomsDir
self._timer = timer
self._rooms = {} 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): def broadcastRoom(self, sender, whatLambda):
room = sender.getRoom() room = sender.getRoom()
@ -342,16 +359,19 @@ class RoomManager(object):
oldRoom = watcher.getRoom() oldRoom = watcher.getRoom()
if oldRoom: if oldRoom:
oldRoom.removeWatcher(watcher) oldRoom.removeWatcher(watcher)
self._deleteRoomIfEmpty(oldRoom) if self._roomsDir is None or oldRoom.isStale(self._timer):
self._deleteRoomIfEmpty(oldRoom)
def _getRoom(self, roomName): 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] return self._rooms[roomName]
else: else:
if RoomPasswordProvider.isControlledRoom(roomName): if RoomPasswordProvider.isControlledRoom(roomName):
room = ControlledRoom(roomName) room = ControlledRoom(roomName)
else: else:
room = Room(roomName) if roomName in self._rooms:
self._deleteRoomIfEmpty(self._rooms[roomName])
room = Room(roomName, self._roomsDir)
self._rooms[roomName] = room self._rooms[roomName] = room
return room return room
@ -392,19 +412,63 @@ class Room(object):
STATE_PAUSED = 0 STATE_PAUSED = 0
STATE_PLAYING = 1 STATE_PLAYING = 1
def __init__(self, name): def __init__(self, name, _roomsDir=None):
self._name = name self._name = name
self._roomsDir = _roomsDir
self._watchers = {} self._watchers = {}
self._playState = self.STATE_PAUSED self._playState = self.STATE_PAUSED
self._setBy = None self._setBy = None
self._playlist = [] self._playlist = []
self._playlistIndex = None self._playlistIndex = None
self._lastUpdate = time.time() self._lastUpdate = time.time()
self._lastSavedUpdate = 0
self._position = 0 self._position = 0
def __str__(self, *args, **kwargs): def __str__(self, *args, **kwargs):
return self.getName() 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): def getName(self):
return self._name return self._name
@ -414,7 +478,7 @@ class Room(object):
watcher = min(self._watchers.values()) watcher = min(self._watchers.values())
self._setBy = watcher self._setBy = watcher
self._position = watcher.getPosition() self._position = watcher.getPosition()
self._lastUpdate = time.time() self._lastSavedUpdate = self._lastUpdate = time.time()
return self._position return self._position
elif self._position is not None: elif self._position is not None:
return self._position + (age if self._playState == self.STATE_PLAYING else 0) return self._position + (age if self._playState == self.STATE_PLAYING else 0)
@ -424,12 +488,14 @@ class Room(object):
def setPaused(self, paused=STATE_PAUSED, setBy=None): def setPaused(self, paused=STATE_PAUSED, setBy=None):
self._playState = paused self._playState = paused
self._setBy = setBy self._setBy = setBy
self.writeToFile()
def setPosition(self, position, setBy=None): def setPosition(self, position, setBy=None):
self._position = position self._position = position
for watcher in self._watchers.values(): for watcher in self._watchers.values():
watcher.setPosition(position) watcher.setPosition(position)
self._setBy = setBy self._setBy = setBy
self.writeToFile()
def isPlaying(self): def isPlaying(self):
return self._playState == self.STATE_PLAYING return self._playState == self.STATE_PLAYING
@ -441,7 +507,7 @@ class Room(object):
return list(self._watchers.values()) return list(self._watchers.values())
def addWatcher(self, watcher): def addWatcher(self, watcher):
if self._watchers: if self._watchers or self.isPermanent():
watcher.setPosition(self.getPosition()) watcher.setPosition(self.getPosition())
self._watchers[watcher.getName()] = watcher self._watchers[watcher.getName()] = watcher
watcher.setRoom(self) watcher.setRoom(self)
@ -451,8 +517,9 @@ class Room(object):
return return
del self._watchers[watcher.getName()] del self._watchers[watcher.getName()]
watcher.setRoom(None) watcher.setRoom(None)
if not self._watchers: if not self._watchers and not self.isPermanent():
self._position = 0 self._position = 0
self.writeToFile()
def isEmpty(self): def isEmpty(self):
return not bool(self._watchers) return not bool(self._watchers)
@ -465,9 +532,11 @@ class Room(object):
def setPlaylist(self, files, setBy=None): def setPlaylist(self, files, setBy=None):
self._playlist = files self._playlist = files
self.writeToFile()
def setPlaylistIndex(self, index, setBy=None): def setPlaylistIndex(self, index, setBy=None):
self._playlistIndex = index self._playlistIndex = index
self.writeToFile()
def getPlaylist(self): def getPlaylist(self):
return self._playlist return self._playlist
@ -687,6 +756,8 @@ class ConfigurationGetter(object):
self._argparser.add_argument('--disable-chat', action='store_true', help=getMessage("server-chat-argument")) 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('--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('--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-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('--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")) self._argparser.add_argument('--stats-db-file', metavar='file', type=str, nargs='?', help=getMessage("server-stats-db-file-argument"))