* Readiness override (initial implementation) * Fix broken chat code on server * Fix room context menu when alone in room
916 lines
38 KiB
Python
Executable File
916 lines
38 KiB
Python
Executable File
import argparse
|
|
import codecs
|
|
import hashlib
|
|
import os
|
|
import time
|
|
from string import Template
|
|
|
|
from twisted.enterprise import adbapi
|
|
from twisted.internet import task, reactor
|
|
from twisted.internet.protocol import Factory
|
|
|
|
try:
|
|
from OpenSSL import crypto
|
|
from OpenSSL.SSL import TLSv1_2_METHOD
|
|
from twisted.internet import ssl
|
|
except:
|
|
pass
|
|
|
|
import syncplay
|
|
from syncplay import constants
|
|
from syncplay.messages import getMessage
|
|
from syncplay.protocols import SyncServerProtocol
|
|
from syncplay.utils import RoomPasswordProvider, NotControlledRoom, RandomStringGenerator, meetsMinVersion, playlistIsValid, truncateText, getListAsMultilineString, convertMultilineStringToList
|
|
|
|
class SyncFactory(Factory):
|
|
def __init__(self, port='', password='', motdFilePath=None, roomsDbFile=None, permanentRoomsFile=None, 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
|
|
syncplay.messages.setLanguage(syncplay.messages.getInitialLanguage())
|
|
print(getMessage("welcome-server-notification").format(syncplay.version))
|
|
self.port = port
|
|
if password:
|
|
password = password.encode('utf-8')
|
|
password = hashlib.md5(password).hexdigest()
|
|
self.password = password
|
|
if salt is None:
|
|
salt = RandomStringGenerator.generate_server_salt()
|
|
print(getMessage("no-salt-notification").format(salt))
|
|
self._salt = salt
|
|
self._motdFilePath = motdFilePath
|
|
self.roomsDbFile = roomsDbFile
|
|
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
|
|
self.permanentRoomsFile = permanentRoomsFile if permanentRoomsFile is not None and os.path.isfile(permanentRoomsFile) else None
|
|
self.permanentRooms = self.loadListFromMultilineTextFile(self.permanentRoomsFile) if self.permanentRoomsFile is not None else []
|
|
if not isolateRooms:
|
|
self._roomManager = RoomManager(self.roomsDbFile, self.permanentRooms)
|
|
else:
|
|
self._roomManager = PublicRoomManager()
|
|
if statsDbFile is not None:
|
|
self._statsDbHandle = StatsDBManager(statsDbFile)
|
|
self._statsRecorder = StatsRecorder(self._statsDbHandle, self._roomManager)
|
|
statsDelay = 5*(int(self.port)%10 + 1)
|
|
self._statsRecorder.startRecorder(statsDelay)
|
|
else:
|
|
self._statsDbHandle = None
|
|
if tlsCertPath is not None:
|
|
self.certPath = tlsCertPath
|
|
self._TLSattempts = 0
|
|
self._allowTLSconnections(self.certPath)
|
|
else:
|
|
self.certPath = None
|
|
self.options = None
|
|
self.serverAcceptsTLS = False
|
|
|
|
def loadListFromMultilineTextFile(self, path):
|
|
if not os.path.isfile(path):
|
|
return []
|
|
with open(path) as f:
|
|
multiline = f.read().splitlines()
|
|
return multiline
|
|
|
|
def loadRoom(self):
|
|
rooms = self._roomsDbHandle.loadRooms()
|
|
|
|
def buildProtocol(self, addr):
|
|
return SyncServerProtocol(self)
|
|
|
|
def sendState(self, watcher, doSeek=False, forcedUpdate=False):
|
|
room = watcher.getRoom()
|
|
if room:
|
|
paused, position = room.isPaused(), room.getPosition()
|
|
setBy = room.getSetBy()
|
|
watcher.sendState(position, paused, doSeek, setBy, forcedUpdate)
|
|
|
|
def getFeatures(self):
|
|
features = dict()
|
|
features["isolateRooms"] = self.isolateRooms
|
|
features["readiness"] = not self.disableReady
|
|
features["managedRooms"] = True
|
|
features["persistentRooms"] = self.roomsDbFile is not None
|
|
features["chat"] = not self.disableChat
|
|
features["maxChatMessageLength"] = self.maxChatMessageLength
|
|
features["maxUsernameLength"] = self.maxUsernameLength
|
|
features["maxRoomNameLength"] = constants.MAX_ROOM_NAME_LENGTH
|
|
features["maxFilenameLength"] = constants.MAX_FILENAME_LENGTH
|
|
features["setOthersReadiness"] = True
|
|
|
|
return features
|
|
|
|
def getMotd(self, userIp, username, room, clientVersion):
|
|
oldClient = False
|
|
if constants.WARN_OLD_CLIENTS:
|
|
if not meetsMinVersion(clientVersion, constants.RECENT_CLIENT_THRESHOLD):
|
|
oldClient = True
|
|
if self._motdFilePath and os.path.isfile(self._motdFilePath):
|
|
tmpl = codecs.open(self._motdFilePath, "r", "utf-8-sig").read()
|
|
args = dict(version=syncplay.version, userIp=userIp, username=username, room=room)
|
|
try:
|
|
motd = Template(tmpl).substitute(args)
|
|
if oldClient:
|
|
motdwarning = getMessage("new-syncplay-available-motd-message").format(clientVersion)
|
|
motd = "{}\n{}".format(motdwarning, motd)
|
|
return motd if len(motd) < constants.SERVER_MAX_TEMPLATE_LENGTH else getMessage("server-messed-up-motd-too-long").format(constants.SERVER_MAX_TEMPLATE_LENGTH, len(motd))
|
|
except ValueError:
|
|
return getMessage("server-messed-up-motd-unescaped-placeholders")
|
|
elif oldClient:
|
|
return getMessage("new-syncplay-available-motd-message").format(clientVersion)
|
|
else:
|
|
return ""
|
|
|
|
def addWatcher(self, watcherProtocol, username, roomName):
|
|
roomName = truncateText(roomName, constants.MAX_ROOM_NAME_LENGTH)
|
|
username = self._roomManager.findFreeUsername(username, self.maxUsernameLength)
|
|
watcher = Watcher(self, watcherProtocol, username)
|
|
self.setWatcherRoom(watcher, roomName, asJoin=True)
|
|
|
|
def setWatcherRoom(self, watcher, roomName, asJoin=False):
|
|
roomName = truncateText(roomName, constants.MAX_ROOM_NAME_LENGTH)
|
|
self._roomManager.moveWatcher(watcher, roomName)
|
|
if asJoin:
|
|
self.sendJoinMessage(watcher)
|
|
else:
|
|
self.sendRoomSwitchMessage(watcher)
|
|
|
|
room = watcher.getRoom()
|
|
roomSetByName = room.getSetBy().getName() if room.getSetBy() else None
|
|
watcher.setPlaylist(roomSetByName, room.getPlaylist())
|
|
watcher.setPlaylistIndex(roomSetByName, room.getPlaylistIndex())
|
|
if RoomPasswordProvider.isControlledRoom(roomName):
|
|
for controller in room.getControllers():
|
|
watcher.sendControlledRoomAuthStatus(True, controller, roomName)
|
|
|
|
def sendRoomSwitchMessage(self, watcher):
|
|
l = lambda w: w.sendSetting(watcher.getName(), watcher.getRoom(), None, None)
|
|
self._roomManager.broadcast(watcher, l)
|
|
self._roomManager.broadcastRoom(watcher, lambda w: w.sendSetReady(watcher.getName(), watcher.isReady(), False))
|
|
if self.roomsDbFile:
|
|
l = lambda w: w.sendList(toGUIOnly=True)
|
|
self._roomManager.broadcast(watcher, l)
|
|
|
|
def removeWatcher(self, watcher):
|
|
if watcher and watcher.getRoom():
|
|
self.sendLeftMessage(watcher)
|
|
self._roomManager.removeWatcher(watcher)
|
|
if self.roomsDbFile:
|
|
l = lambda w: w.sendList(toGUIOnly=True)
|
|
self._roomManager.broadcast(watcher, l)
|
|
|
|
def sendLeftMessage(self, watcher):
|
|
l = lambda w: w.sendSetting(watcher.getName(), watcher.getRoom(), None, {"left": True})
|
|
self._roomManager.broadcast(watcher, l)
|
|
|
|
def sendJoinMessage(self, watcher):
|
|
l = lambda w: w.sendSetting(watcher.getName(), watcher.getRoom(), None, {"joined": True, "version": watcher.getVersion(), "features": watcher.getFeatures()}) if w != watcher else None
|
|
self._roomManager.broadcast(watcher, l)
|
|
self._roomManager.broadcastRoom(watcher, lambda w: w.sendSetReady(watcher.getName(), watcher.isReady(), False))
|
|
if self.roomsDbFile:
|
|
l = lambda w: w.sendList(toGUIOnly=True)
|
|
self._roomManager.broadcast(watcher, l)
|
|
|
|
def sendFileUpdate(self, watcher):
|
|
if watcher.getFile():
|
|
l = lambda w: w.sendSetting(watcher.getName(), watcher.getRoom(), watcher.getFile(), None)
|
|
self._roomManager.broadcast(watcher, l)
|
|
|
|
def forcePositionUpdate(self, watcher, doSeek, watcherPauseState):
|
|
room = watcher.getRoom()
|
|
if room.canControl(watcher):
|
|
paused, position = room.isPaused(), watcher.getPosition()
|
|
setBy = watcher
|
|
l = lambda w: w.sendState(position, paused, doSeek, setBy, True)
|
|
room.setPosition(watcher.getPosition(), setBy)
|
|
self._roomManager.broadcastRoom(watcher, l)
|
|
else:
|
|
watcher.sendState(room.getPosition(), watcherPauseState, False, watcher, True) # Fixes BC break with 1.2.x
|
|
watcher.sendState(room.getPosition(), room.isPaused(), True, room.getSetBy(), True)
|
|
|
|
def getAllWatchersForUser(self, forUser):
|
|
return self._roomManager.getAllWatchersForUser(forUser)
|
|
|
|
def getEmptyPersistentRooms(self):
|
|
return self._roomManager.getEmptyPersistentRooms()
|
|
|
|
def authRoomController(self, watcher, password, roomBaseName=None):
|
|
room = watcher.getRoom()
|
|
roomName = roomBaseName if roomBaseName else room.getName()
|
|
try:
|
|
success = RoomPasswordProvider.check(roomName, password, self._salt)
|
|
if success:
|
|
watcher.getRoom().addController(watcher)
|
|
self._roomManager.broadcast(watcher, lambda w: w.sendControlledRoomAuthStatus(success, watcher.getName(), room._name))
|
|
except NotControlledRoom:
|
|
newName = RoomPasswordProvider.getControlledRoomName(roomName, password, self._salt)
|
|
watcher.sendNewControlledRoom(newName, password)
|
|
except ValueError:
|
|
self._roomManager.broadcastRoom(watcher, lambda w: w.sendControlledRoomAuthStatus(False, watcher.getName(), room._name))
|
|
|
|
def sendChat(self, watcher, message):
|
|
message = truncateText(message, self.maxChatMessageLength)
|
|
messageDict = {"message": message, "username": watcher.getName()}
|
|
self._roomManager.broadcastRoom(watcher, lambda w: w.sendChatMessage(messageDict))
|
|
|
|
def setReady(self, watcher, isReady, manuallyInitiated=True, username=None):
|
|
if username and username != watcher.getName():
|
|
room = watcher.getRoom()
|
|
if room.canControl(watcher):
|
|
for watcherToSet in room.getWatchers():
|
|
if watcherToSet.getName() == username:
|
|
watcherToSet.setReady(isReady)
|
|
self._roomManager.broadcastRoom(watcherToSet, lambda w: w.sendSetReady(watcherToSet.getName(), watcherToSet.isReady(), manuallyInitiated, watcher.getName()))
|
|
if isReady:
|
|
messageDict = { "message": getMessage("ready-chat-message").format(username, watcherToSet.getName()), "username": watcher.getName()}
|
|
else:
|
|
messageDict = {"message": getMessage("not-ready-chat-message").format(username, watcherToSet.getName()), "username": watcher.getName()}
|
|
self._roomManager.broadcastRoom(watcher, lambda w: w.sendChatMessage(messageDict, "setOthersReadiness"))
|
|
else:
|
|
watcher.setReady(isReady)
|
|
self._roomManager.broadcastRoom(watcher, lambda w: w.sendSetReady(watcher.getName(), watcher.isReady(), manuallyInitiated))
|
|
|
|
def setPlaylist(self, watcher, files):
|
|
room = watcher.getRoom()
|
|
if room.canControl(watcher) and playlistIsValid(files):
|
|
watcher.getRoom().setPlaylist(files, watcher)
|
|
self._roomManager.broadcastRoom(watcher, lambda w: w.setPlaylist(watcher.getName(), files))
|
|
else:
|
|
watcher.setPlaylist(room.getName(), room.getPlaylist())
|
|
watcher.setPlaylistIndex(room.getName(), room.getPlaylistIndex())
|
|
|
|
def setPlaylistIndex(self, watcher, index):
|
|
room = watcher.getRoom()
|
|
if room.canControl(watcher):
|
|
watcher.getRoom().setPlaylistIndex(index, watcher)
|
|
self._roomManager.broadcastRoom(watcher, lambda w: w.setPlaylistIndex(watcher.getName(), index))
|
|
else:
|
|
watcher.setPlaylistIndex(room.getName(), room.getPlaylistIndex())
|
|
|
|
def _allowTLSconnections(self, path):
|
|
try:
|
|
privKey = open(path+'/privkey.pem', 'rt').read()
|
|
certif = open(path+'/cert.pem', 'rt').read()
|
|
chain = open(path+'/chain.pem', 'rt').read()
|
|
|
|
self.lastEditCertTime = os.path.getmtime(path+'/cert.pem')
|
|
|
|
privKeyPySSL = crypto.load_privatekey(crypto.FILETYPE_PEM, privKey)
|
|
certifPySSL = crypto.load_certificate(crypto.FILETYPE_PEM, certif)
|
|
chainPySSL = [crypto.load_certificate(crypto.FILETYPE_PEM, chain)]
|
|
|
|
cipherListString = "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:"\
|
|
"ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:"\
|
|
"ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384"
|
|
accCiphers = ssl.AcceptableCiphers.fromOpenSSLCipherString(cipherListString)
|
|
|
|
try:
|
|
contextFactory = ssl.CertificateOptions(privateKey=privKeyPySSL, certificate=certifPySSL,
|
|
extraCertChain=chainPySSL, acceptableCiphers=accCiphers,
|
|
raiseMinimumTo=ssl.TLSVersion.TLSv1_2)
|
|
except AttributeError:
|
|
contextFactory = ssl.CertificateOptions(privateKey=privKeyPySSL, certificate=certifPySSL,
|
|
extraCertChain=chainPySSL, acceptableCiphers=accCiphers,
|
|
method=TLSv1_2_METHOD)
|
|
|
|
self.options = contextFactory
|
|
self.serverAcceptsTLS = True
|
|
self._TLSattempts = 0
|
|
print("TLS support is enabled.")
|
|
except Exception as e:
|
|
self.options = None
|
|
self.serverAcceptsTLS = False
|
|
self.lastEditCertTime = None
|
|
print("Error while loading the TLS certificates.")
|
|
print(e)
|
|
print("TLS support is not enabled.")
|
|
|
|
def checkLastEditCertTime(self):
|
|
try:
|
|
outTime = os.path.getmtime(self.certPath+'/cert.pem')
|
|
except:
|
|
outTime = None
|
|
return outTime
|
|
|
|
def updateTLSContextFactory(self):
|
|
self._allowTLSconnections(self.certPath)
|
|
self._TLSattempts += 1
|
|
if self._TLSattempts < constants.TLS_CERT_ROTATION_MAX_RETRIES:
|
|
self.serverAcceptsTLS = True
|
|
|
|
|
|
class StatsRecorder(object):
|
|
def __init__(self, dbHandle, roomManager):
|
|
self._dbHandle = dbHandle
|
|
self._roomManagerHandle = roomManager
|
|
|
|
def startRecorder(self, delay):
|
|
try:
|
|
self._dbHandle.connect()
|
|
reactor.callLater(delay, self._scheduleClientSnapshot)
|
|
except:
|
|
print("--- Error in initializing the stats database. Server Stats not enabled. ---")
|
|
|
|
def _scheduleClientSnapshot(self):
|
|
self._clientSnapshotTimer = task.LoopingCall(self._runClientSnapshot)
|
|
self._clientSnapshotTimer.start(constants.SERVER_STATS_SNAPSHOT_INTERVAL)
|
|
|
|
def _runClientSnapshot(self):
|
|
try:
|
|
snapshotTime = int(time.time())
|
|
rooms = self._roomManagerHandle.exportRooms()
|
|
for room in rooms.values():
|
|
for watcher in room.getWatchers():
|
|
self._dbHandle.addVersionLog(snapshotTime, watcher.getVersion())
|
|
except:
|
|
pass
|
|
|
|
class RoomsRecorder(StatsRecorder):
|
|
def __init__(self, dbHandle, roomManager):
|
|
self._dbHandle = dbHandle
|
|
self._roomManagerHandle = roomManager
|
|
|
|
def startRecorder(self, delay):
|
|
try:
|
|
self._dbHandle.connect()
|
|
reactor.callLater(delay, self._scheduleClientSnapshot) # TODO: FIX THIS!
|
|
except:
|
|
print("--- Error in initializing the stats database. Server Stats not enabled. ---")
|
|
|
|
def _scheduleClientSnapshot(self):
|
|
self._clientSnapshotTimer = task.LoopingCall(self._runClientSnapshot)
|
|
self._clientSnapshotTimer.start(constants.SERVER_STATS_SNAPSHOT_INTERVAL)
|
|
|
|
def _runClientSnapshot(self):
|
|
try:
|
|
snapshotTime = int(time.time())
|
|
rooms = self._roomManagerHandle.exportRooms()
|
|
for room in rooms.values():
|
|
for watcher in room.getWatchers():
|
|
self._dbHandle.addVersionLog(snapshotTime, watcher.getVersion())
|
|
except:
|
|
pass
|
|
|
|
class StatsDBManager(object):
|
|
def __init__(self, dbpath):
|
|
self._dbPath = dbpath
|
|
self._connection = None
|
|
|
|
def __del__(self):
|
|
if self._connection is not None:
|
|
self._connection.close()
|
|
|
|
def connect(self):
|
|
self._connection = adbapi.ConnectionPool("sqlite3", self._dbPath, check_same_thread=False)
|
|
self._createSchema()
|
|
|
|
def _createSchema(self):
|
|
initQuery = 'create table if not exists clients_snapshots (snapshot_time INTEGER, version STRING)'
|
|
return self._connection.runQuery(initQuery)
|
|
|
|
def addVersionLog(self, timestamp, version):
|
|
content = (timestamp, version, )
|
|
self._connection.runQuery("INSERT INTO clients_snapshots VALUES (?, ?)", content)
|
|
|
|
class RoomDBManager(object):
|
|
def __init__(self, dbpath, loadroomscallback):
|
|
self._dbPath = dbpath
|
|
self._connection = None
|
|
self._loadRoomsCallback = loadroomscallback
|
|
|
|
def __del__(self):
|
|
if self._connection is not None:
|
|
self._connection.close()
|
|
|
|
def connect(self):
|
|
self._connection = adbapi.ConnectionPool("sqlite3", self._dbPath, check_same_thread=False)
|
|
self._createSchema().addCallback(self.loadRooms)
|
|
|
|
def _createSchema(self):
|
|
initQuery = 'create table if not exists persistent_rooms (name STRING PRIMARY KEY, playlist STRING, playlistIndex INTEGER, position REAL, lastSavedUpdate INTEGER)'
|
|
return self._connection.runQuery(initQuery)
|
|
|
|
def saveRoom(self, name, playlist, playlistIndex, position, lastUpdate):
|
|
content = (name, playlist, playlistIndex, position, lastUpdate)
|
|
self._connection.runQuery("INSERT OR REPLACE INTO persistent_rooms VALUES (?, ?, ?, ?, ?)", content)
|
|
|
|
def deleteRoom(self, name):
|
|
self._connection.runQuery("DELETE FROM persistent_rooms where name = ?", [name])
|
|
|
|
def loadRooms(self, result=None):
|
|
roomsQuery = "SELECT * FROM persistent_rooms"
|
|
rooms = self._connection.runQuery(roomsQuery)
|
|
rooms.addCallback(self.loadedRooms)
|
|
|
|
def loadedRooms(self, rooms):
|
|
self._loadRoomsCallback(rooms)
|
|
|
|
class RoomManager(object):
|
|
def __init__(self, roomsdbfile=None, permanentRooms=[]):
|
|
self._roomsDbFile = roomsdbfile
|
|
self._rooms = {}
|
|
self._permanentRooms = permanentRooms
|
|
if self._roomsDbFile is not None:
|
|
self._roomsDbHandle = RoomDBManager(self._roomsDbFile, self.loadRooms)
|
|
self._roomsDbHandle.connect()
|
|
else:
|
|
self._roomsDbHandle = None
|
|
|
|
def loadRooms(self, rooms):
|
|
roomsLoaded = []
|
|
for roomDetails in rooms:
|
|
roomName = truncateText(roomDetails[0], constants.MAX_ROOM_NAME_LENGTH)
|
|
room = Room(roomDetails[0], self._roomsDbHandle)
|
|
room.loadRoom(roomDetails)
|
|
if roomName in self._permanentRooms:
|
|
room.setPermanent(True)
|
|
self._rooms[roomName] = room
|
|
roomsLoaded.append(roomName)
|
|
for roomName in self._permanentRooms:
|
|
if roomName not in roomsLoaded:
|
|
roomDetails = (roomName, "", 0, 0, 0)
|
|
room = Room(roomName, self._roomsDbHandle)
|
|
room.loadRoom(roomDetails)
|
|
room.setPermanent(True)
|
|
self._rooms[roomName] = room
|
|
|
|
def broadcastRoom(self, sender, whatLambda):
|
|
room = sender.getRoom()
|
|
if room and room.getName() in self._rooms:
|
|
for receiver in room.getWatchers():
|
|
whatLambda(receiver)
|
|
|
|
def broadcast(self, sender, whatLambda):
|
|
for room in self._rooms.values():
|
|
for receiver in room.getWatchers():
|
|
whatLambda(receiver)
|
|
|
|
def getAllWatchersForUser(self, sender):
|
|
watchers = []
|
|
for room in self._rooms.values():
|
|
for watcher in room.getWatchers():
|
|
watchers.append(watcher)
|
|
return watchers
|
|
|
|
def getPersistentRooms(self, sender):
|
|
persistentRooms = []
|
|
for room in self._rooms.values():
|
|
if room.isPersistent():
|
|
persistentRooms.append(room.getName())
|
|
return persistentRooms
|
|
|
|
def getEmptyPersistentRooms(self):
|
|
emptyPersistentRooms = []
|
|
for room in self._rooms.values():
|
|
if len(room.getWatchers()) == 0:
|
|
emptyPersistentRooms.append(room.getName())
|
|
return emptyPersistentRooms
|
|
|
|
def moveWatcher(self, watcher, roomName):
|
|
roomName = truncateText(roomName, constants.MAX_ROOM_NAME_LENGTH)
|
|
self.removeWatcher(watcher)
|
|
room = self._getRoom(roomName)
|
|
room.addWatcher(watcher)
|
|
|
|
def removeWatcher(self, watcher):
|
|
oldRoom = watcher.getRoom()
|
|
if oldRoom:
|
|
oldRoom.removeWatcher(watcher)
|
|
self._deleteRoomIfEmpty(oldRoom)
|
|
|
|
def _getRoom(self, roomName):
|
|
if roomName in self._rooms:
|
|
return self._rooms[roomName]
|
|
else:
|
|
if RoomPasswordProvider.isControlledRoom(roomName):
|
|
room = ControlledRoom(roomName, self._roomsDbHandle)
|
|
else:
|
|
if roomName in self._rooms:
|
|
self._deleteRoomIfEmpty(self._rooms[roomName])
|
|
room = Room(roomName, self._roomsDbHandle)
|
|
self._rooms[roomName] = room
|
|
return room
|
|
|
|
def _deleteRoomIfEmpty(self, room):
|
|
if room.isEmpty() and room.getName():
|
|
if self._roomsDbHandle and room.isPermanent():
|
|
return
|
|
if self._roomsDbHandle and room.isNotPermanent():
|
|
if room.isPersistent() and not room.isPlaylistEmpty():
|
|
return
|
|
self._roomsDbHandle.deleteRoom(room.getName())
|
|
del self._rooms[room.getName()]
|
|
|
|
def findFreeUsername(self, username, maxUsernameLength=constants.MAX_USERNAME_LENGTH):
|
|
username = truncateText(username, maxUsernameLength)
|
|
allnames = []
|
|
for room in self._rooms.values():
|
|
for watcher in room.getWatchers():
|
|
allnames.append(watcher.getName().lower())
|
|
while username.lower() in allnames:
|
|
username += '_'
|
|
return username
|
|
|
|
def exportRooms(self):
|
|
return self._rooms
|
|
|
|
|
|
class PublicRoomManager(RoomManager):
|
|
def broadcast(self, sender, what):
|
|
self.broadcastRoom(sender, what)
|
|
|
|
def getAllWatchersForUser(self, sender):
|
|
return sender.getRoom().getWatchers()
|
|
|
|
def moveWatcher(self, watcher, room):
|
|
oldRoom = watcher.getRoom()
|
|
l = lambda w: w.sendSetting(watcher.getName(), oldRoom, None, {"left": True})
|
|
self.broadcast(watcher, l)
|
|
RoomManager.moveWatcher(self, watcher, room)
|
|
watcher.setFile(watcher.getFile())
|
|
|
|
|
|
class Room(object):
|
|
STATE_PAUSED = 0
|
|
STATE_PLAYING = 1
|
|
|
|
def __init__(self, name, roomsdbhandle):
|
|
self._name = name
|
|
self._roomsDbHandle = roomsdbhandle
|
|
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
|
|
self._permanent = False
|
|
|
|
def __str__(self, *args, **kwargs):
|
|
return self.getName()
|
|
|
|
def roomsCanPersist(self):
|
|
return self._roomsDbHandle is not None
|
|
|
|
def isPersistent(self):
|
|
return self.roomsCanPersist() and not self.isMarkedAsTemporary()
|
|
|
|
def isMarkedAsTemporary(self):
|
|
roomName = self.getName().lower()
|
|
return roomName.endswith("-temp") or "-temp:" in roomName
|
|
|
|
def isPlaylistEmpty(self):
|
|
return len(self._playlist) == 0
|
|
|
|
def isPermanent(self):
|
|
return self._permanent
|
|
|
|
def isNotPermanent(self):
|
|
return not self.isPermanent()
|
|
|
|
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 writeToDb(self):
|
|
if not self.isPersistent():
|
|
return
|
|
processed_playlist = getListAsMultilineString(self._playlist)
|
|
self._roomsDbHandle.saveRoom(self._name, processed_playlist, self._playlistIndex, self._position, self._lastSavedUpdate)
|
|
|
|
def loadRoom(self, room):
|
|
name, playlist, playlistindex, position, lastupdate = room
|
|
self._name = name
|
|
self._playlist = convertMultilineStringToList(playlist)
|
|
self._playlistIndex = playlistindex
|
|
self._position = position
|
|
self._lastSavedUpdate = lastupdate
|
|
|
|
def getName(self):
|
|
return self._name
|
|
|
|
def getPosition(self):
|
|
age = time.time() - self._lastUpdate
|
|
if self._watchers and age > 1:
|
|
watcher = min(self._watchers.values())
|
|
self._setBy = watcher
|
|
self._position = watcher.getPosition()
|
|
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)
|
|
else:
|
|
return 0
|
|
|
|
def setPaused(self, paused=STATE_PAUSED, setBy=None):
|
|
self._playState = paused
|
|
self._setBy = setBy
|
|
self.writeToDb()
|
|
|
|
def setPosition(self, position, setBy=None):
|
|
self._position = position
|
|
for watcher in self._watchers.values():
|
|
watcher.setPosition(position)
|
|
self._setBy = setBy
|
|
self.writeToDb()
|
|
|
|
def setPermanent(self, newState):
|
|
self._permanent = newState
|
|
|
|
def isPlaying(self):
|
|
return self._playState == self.STATE_PLAYING
|
|
|
|
def isPaused(self):
|
|
return self._playState == self.STATE_PAUSED
|
|
|
|
def getWatchers(self):
|
|
return list(self._watchers.values())
|
|
|
|
def addWatcher(self, watcher):
|
|
if self._watchers or self.isPersistent():
|
|
watcher.setPosition(self.getPosition())
|
|
self._watchers[watcher.getName()] = watcher
|
|
watcher.setRoom(self)
|
|
|
|
def removeWatcher(self, watcher):
|
|
if watcher.getName() not in self._watchers:
|
|
return
|
|
del self._watchers[watcher.getName()]
|
|
watcher.setRoom(None)
|
|
if not self._watchers and not self.isPersistent():
|
|
self._position = 0
|
|
self.writeToDb()
|
|
|
|
def isEmpty(self):
|
|
return not bool(self._watchers)
|
|
|
|
def getSetBy(self):
|
|
return self._setBy
|
|
|
|
def canControl(self, watcher):
|
|
return True
|
|
|
|
def setPlaylist(self, files, setBy=None):
|
|
self._playlist = files
|
|
self.writeToDb()
|
|
|
|
def setPlaylistIndex(self, index, setBy=None):
|
|
self._playlistIndex = index
|
|
self.writeToDb()
|
|
|
|
def getPlaylist(self):
|
|
return self._playlist
|
|
|
|
def getPlaylistIndex(self):
|
|
return self._playlistIndex
|
|
|
|
def getControllers(self):
|
|
return []
|
|
|
|
class ControlledRoom(Room):
|
|
def __init__(self, name, roomsdbhandle):
|
|
Room.__init__(self, name, roomsdbhandle)
|
|
self._controllers = {}
|
|
|
|
def getPosition(self):
|
|
age = time.time() - self._lastUpdate
|
|
if self._controllers and age > 1:
|
|
watcher = min(self._controllers.values())
|
|
self._setBy = watcher
|
|
self._position = watcher.getPosition()
|
|
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)
|
|
else:
|
|
return 0
|
|
|
|
def addController(self, watcher):
|
|
self._controllers[watcher.getName()] = watcher
|
|
|
|
def removeWatcher(self, watcher):
|
|
Room.removeWatcher(self, watcher)
|
|
if watcher.getName() in self._controllers:
|
|
del self._controllers[watcher.getName()]
|
|
self.writeToDb()
|
|
|
|
def setPaused(self, paused=Room.STATE_PAUSED, setBy=None):
|
|
if self.canControl(setBy):
|
|
Room.setPaused(self, paused, setBy)
|
|
|
|
def setPosition(self, position, setBy=None):
|
|
if self.canControl(setBy):
|
|
Room.setPosition(self, position, setBy)
|
|
|
|
def setPlaylist(self, files, setBy=None):
|
|
if self.canControl(setBy) and playlistIsValid(files):
|
|
self._playlist = files
|
|
|
|
def setPlaylistIndex(self, index, setBy=None):
|
|
if self.canControl(setBy):
|
|
self._playlistIndex = index
|
|
|
|
def canControl(self, watcher):
|
|
return watcher.getName() in self._controllers
|
|
|
|
def getControllers(self):
|
|
return {}
|
|
|
|
|
|
class Watcher(object):
|
|
def __init__(self, server, connector, name):
|
|
self._ready = None
|
|
self._server = server
|
|
self._connector = connector
|
|
self._name = name
|
|
self._room = None
|
|
self._file = None
|
|
self._position = None
|
|
self._lastUpdatedOn = time.time()
|
|
self._sendStateTimer = None
|
|
self._connector.setWatcher(self)
|
|
reactor.callLater(0.1, self._scheduleSendState)
|
|
|
|
def setFile(self, file_):
|
|
if file_ and "name" in file_:
|
|
file_["name"] = truncateText(file_["name"], constants.MAX_FILENAME_LENGTH)
|
|
self._file = file_
|
|
self._server.sendFileUpdate(self)
|
|
|
|
def setRoom(self, room):
|
|
self._room = room
|
|
if room is None:
|
|
self._deactivateStateTimer()
|
|
else:
|
|
self._resetStateTimer()
|
|
self._askForStateUpdate(True, True)
|
|
|
|
def setReady(self, ready):
|
|
self._ready = ready
|
|
|
|
def getFeatures(self):
|
|
features = self._connector.getFeatures()
|
|
return features
|
|
|
|
def isReady(self):
|
|
if self._server.disableReady:
|
|
return None
|
|
return self._ready
|
|
|
|
def getRoom(self):
|
|
return self._room
|
|
|
|
def getName(self):
|
|
return self._name
|
|
|
|
def getVersion(self):
|
|
return self._connector.getVersion()
|
|
|
|
def getFile(self):
|
|
return self._file
|
|
|
|
def setPosition(self, position):
|
|
self._position = position
|
|
|
|
def getPosition(self):
|
|
if self._position is None:
|
|
return None
|
|
if self._room.isPlaying():
|
|
timePassedSinceSet = time.time() - self._lastUpdatedOn
|
|
else:
|
|
timePassedSinceSet = 0
|
|
return self._position + timePassedSinceSet
|
|
|
|
def sendSetting(self, user, room, file_, event):
|
|
self._connector.sendUserSetting(user, room, file_, event)
|
|
|
|
def sendNewControlledRoom(self, roomBaseName, password):
|
|
self._connector.sendNewControlledRoom(roomBaseName, password)
|
|
|
|
def sendControlledRoomAuthStatus(self, success, username, room):
|
|
self._connector.sendControlledRoomAuthStatus(success, username, room)
|
|
|
|
def sendChatMessage(self, message, skipIfSupportsFeature=None):
|
|
if self._connector.meetsMinVersion(constants.CHAT_MIN_VERSION):
|
|
if skipIfSupportsFeature and self.supportsFeature(skipIfSupportsFeature):
|
|
return
|
|
self._connector.sendMessage({"Chat": message})
|
|
|
|
def sendList(self, toGUIOnly=False):
|
|
if toGUIOnly and self.isGUIUser(self._connector.getFeatures()):
|
|
clientFeatures = self._connector.getFeatures()
|
|
if "uiMode" in clientFeatures:
|
|
if clientFeatures["uiMode"] == constants.CONSOLE_UI_MODE:
|
|
return
|
|
else:
|
|
return
|
|
self._connector.sendList()
|
|
|
|
def isGUIUser(self, clientFeatures):
|
|
clientFeatures = self._connector.getFeatures()
|
|
uiMode = clientFeatures["uiMode"] if "uiMode" in clientFeatures else constants.UNKNOWN_UI_MODE
|
|
if uiMode == constants.UNKNOWN_UI_MODE:
|
|
uiMode = constants.FALLBACK_ASSUMED_UI_MODE
|
|
return uiMode == constants.GRAPHICAL_UI_MODE
|
|
|
|
def supportsFeature(self, clientFeature):
|
|
clientFeatures = self._connector.getFeatures()
|
|
return clientFeatures[clientFeature] if clientFeature in clientFeatures else False
|
|
|
|
def sendSetReady(self, username, isReady, manuallyInitiated=True, setByUsername=None):
|
|
self._connector.sendSetReady(username, isReady, manuallyInitiated, setByUsername)
|
|
|
|
def setPlaylistIndex(self, username, index):
|
|
self._connector.setPlaylistIndex(username, index)
|
|
|
|
def setPlaylist(self, username, files):
|
|
self._connector.setPlaylist(username, files)
|
|
|
|
def __lt__(self, b):
|
|
if self.getPosition() is None or self._file is None:
|
|
return False
|
|
if b.getPosition() is None or b.getFile() is None:
|
|
return True
|
|
return self.getPosition() < b.getPosition()
|
|
|
|
def _scheduleSendState(self):
|
|
self._sendStateTimer = task.LoopingCall(self._askForStateUpdate)
|
|
self._sendStateTimer.start(constants.SERVER_STATE_INTERVAL)
|
|
|
|
def _askForStateUpdate(self, doSeek=False, forcedUpdate=False):
|
|
self._server.sendState(self, doSeek, forcedUpdate)
|
|
|
|
def _resetStateTimer(self):
|
|
if self._sendStateTimer:
|
|
if self._sendStateTimer.running:
|
|
self._sendStateTimer.stop()
|
|
self._sendStateTimer.start(constants.SERVER_STATE_INTERVAL)
|
|
|
|
def _deactivateStateTimer(self):
|
|
if self._sendStateTimer and self._sendStateTimer.running:
|
|
self._sendStateTimer.stop()
|
|
|
|
def sendState(self, position, paused, doSeek, setBy, forcedUpdate):
|
|
if self._connector.isLogged():
|
|
self._connector.sendState(position, paused, doSeek, setBy, forcedUpdate)
|
|
if time.time() - self._lastUpdatedOn > constants.PROTOCOL_TIMEOUT:
|
|
self._server.removeWatcher(self)
|
|
self._connector.drop()
|
|
|
|
def __hasPauseChanged(self, paused):
|
|
if paused is None:
|
|
return False
|
|
return self._room.isPaused() and not paused or not self._room.isPaused() and paused
|
|
|
|
def _updatePositionByAge(self, messageAge, paused, position):
|
|
if not paused:
|
|
position += messageAge
|
|
return position
|
|
|
|
def updateState(self, position, paused, doSeek, messageAge):
|
|
pauseChanged = self.__hasPauseChanged(paused)
|
|
self._lastUpdatedOn = time.time()
|
|
if pauseChanged:
|
|
self.getRoom().setPaused(Room.STATE_PAUSED if paused else Room.STATE_PLAYING, self)
|
|
if position is not None:
|
|
position = self._updatePositionByAge(messageAge, paused, position)
|
|
self.setPosition(position)
|
|
if doSeek or pauseChanged:
|
|
self._server.forcePositionUpdate(self, doSeek, paused)
|
|
|
|
def isController(self):
|
|
return RoomPasswordProvider.isControlledRoom(self._room.getName()) \
|
|
and self._room.canControl(self)
|
|
|
|
|
|
class ConfigurationGetter(object):
|
|
def getConfiguration(self):
|
|
self._prepareArgParser()
|
|
args = self._argparser.parse_args()
|
|
if args.port is None:
|
|
args.port = constants.DEFAULT_PORT
|
|
return args
|
|
|
|
def _prepareArgParser(self):
|
|
self._argparser = argparse.ArgumentParser(
|
|
description=getMessage("server-argument-description"),
|
|
epilog=getMessage("server-argument-epilog"))
|
|
self._argparser.add_argument('--port', metavar='port', type=str, nargs='?', help=getMessage("server-port-argument"))
|
|
self._argparser.add_argument('--password', metavar='password', type=str, nargs='?', help=getMessage("server-password-argument"), default=os.environ.get('SYNCPLAY_PASSWORD'))
|
|
self._argparser.add_argument('--isolate-rooms', action='store_true', help=getMessage("server-isolate-room-argument"))
|
|
self._argparser.add_argument('--disable-ready', action='store_true', help=getMessage("server-disable-ready-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('--motd-file', metavar='file', type=str, nargs='?', help=getMessage("server-motd-argument"))
|
|
self._argparser.add_argument('--rooms-db-file', metavar='rooms', type=str, nargs='?', help=getMessage("server-rooms-argument"))
|
|
self._argparser.add_argument('--permanent-rooms-file', metavar='permanentrooms', type=str, nargs='?', help=getMessage("server-permanent-rooms-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"))
|
|
self._argparser.add_argument('--tls', metavar='path', type=str, nargs='?', help=getMessage("server-startTLS-argument"))
|
|
self._argparser.add_argument('--ipv4-only', action='store_true', help=getMessage("server-listen-only-on-ipv4"))
|
|
self._argparser.add_argument('--ipv6-only', action='store_true', help=getMessage("server-listen-only-on-ipv6"))
|
|
self._argparser.add_argument('--interface-ipv4', metavar='interfaceIPv4', type=str, nargs='?', help=getMessage("server-interface-ipv4"), default='')
|
|
self._argparser.add_argument('--interface-ipv6', metavar='interfaceIPv6', type=str, nargs='?', help=getMessage("server-interface-ipv6"), default='')
|