1 2import asynchat 3import asyncore 4import os 5import random 6import re 7import socket 8import subprocess 9import sys 10import threading 11import time 12import urllib.error 13import urllib.parse 14import urllib.request 15 16from syncplay import constants, utils 17from syncplay.messages import getMessage 18from syncplay.players.basePlayer import BasePlayer 19from syncplay.utils import isBSD, isLinux, isWindows, isMacOS 20 21 22class VlcPlayer(BasePlayer): 23 speedSupported = True 24 customOpenDialog = False 25 chatOSDSupported = False 26 alertOSDSupported = True 27 osdMessageSeparator = "; " 28 29 RE_ANSWER = re.compile(constants.VLC_ANSWER_REGEX) 30 SLAVE_ARGS = constants.VLC_SLAVE_ARGS 31 SLAVE_ARGS.extend(constants.VLC_SLAVE_EXTRA_ARGS) 32 vlcport = random.randrange(constants.VLC_MIN_PORT, constants.VLC_MAX_PORT) if (constants.VLC_MIN_PORT < constants.VLC_MAX_PORT) else constants.VLC_MIN_PORT 33 34 def __init__(self, client, playerPath, filePath, args): 35 from twisted.internet import reactor 36 self.reactor = reactor 37 self._client = client 38 self._paused = None 39 self._duration = None 40 self._filename = None 41 self._filepath = None 42 self._filechanged = False 43 self._lastVLCPositionUpdate = None 44 self.shownVLCLatencyError = False 45 self._previousPreviousPosition = -2 46 self._previousPosition = -1 47 self._position = 0 48 try: # Hack to fix locale issue without importing locale library 49 self.radixChar = "{:n}".format(1.5)[1:2] 50 if self.radixChar == "" or self.radixChar == "1" or self.radixChar == "5": 51 raise ValueError 52 except: 53 self._client.ui.showErrorMessage( 54 "Failed to determine locale. As a fallback Syncplay is using the following radix character: \".\".") 55 self.radixChar = "." 56 57 self._durationAsk = threading.Event() 58 self._filenameAsk = threading.Event() 59 self._pathAsk = threading.Event() 60 self._positionAsk = threading.Event() 61 self._pausedAsk = threading.Event() 62 self._vlcready = threading.Event() 63 self._vlcclosed = threading.Event() 64 self._listener = None 65 try: 66 self._listener = self.__Listener(self, playerPath, filePath, args, self._vlcready, self._vlcclosed) 67 except ValueError: 68 self._client.ui.showErrorMessage(getMessage("vlc-failed-connection"), True) 69 self.reactor.callFromThread(self._client.stop, True,) 70 return 71 try: 72 self._listener.setDaemon(True) 73 self._listener.start() 74 if not self._vlcready.wait(constants.VLC_OPEN_MAX_WAIT_TIME): 75 self._vlcready.set() 76 self._client.ui.showErrorMessage(getMessage("vlc-failed-connection"), True) 77 self.reactor.callFromThread(self._client.stop, True,) 78 self.reactor.callFromThread(self._client.initPlayer, self,) 79 except: 80 pass 81 82 def _fileUpdateClearEvents(self): 83 self._durationAsk.clear() 84 self._filenameAsk.clear() 85 self._pathAsk.clear() 86 87 def _fileUpdateWaitEvents(self): 88 self._durationAsk.wait() 89 self._filenameAsk.wait() 90 self._pathAsk.wait() 91 92 def _onFileUpdate(self): 93 self._fileUpdateClearEvents() 94 self._getFileInfo() 95 self._fileUpdateWaitEvents() 96 args = (self._filename, self._duration, self._filepath) 97 self.reactor.callFromThread(self._client.updateFile, *args) 98 self.setPaused(self._client.getGlobalPaused()) 99 self.setPosition(self._client.getGlobalPosition()) 100 101 def askForStatus(self): 102 self._filechanged = False 103 self._positionAsk.clear() 104 self._pausedAsk.clear() 105 self._listener.sendLine(".") 106 if self._filename and not self._filechanged: 107 self._positionAsk.wait(constants.PLAYER_ASK_DELAY) 108 self._client.updatePlayerStatus(self._paused, self.getCalculatedPosition()) 109 else: 110 self._client.updatePlayerStatus(self._client.getGlobalPaused(), self._client.getGlobalPosition()) 111 112 def getCalculatedPosition(self): 113 if self._lastVLCPositionUpdate is None: 114 return self._client.getGlobalPosition() 115 diff = time.time() - self._lastVLCPositionUpdate 116 if diff > constants.PLAYER_ASK_DELAY and not self._paused: 117 self._client.ui.showDebugMessage("VLC did not response in time, so assuming position is {} ({}+{})".format( 118 self._position + diff, self._position, diff)) 119 if diff > constants.VLC_LATENCY_ERROR_THRESHOLD: 120 if not self.shownVLCLatencyError or constants.DEBUG_MODE: 121 self._client.ui.showErrorMessage(getMessage("media-player-latency-warning").format(int(diff))) 122 self.shownVLCLatencyError = True 123 return self._position + diff 124 else: 125 return self._position 126 127 def displayMessage( 128 self, message, 129 duration=constants.OSD_DURATION * 1000, OSDType=constants.OSD_DURATION, mood=constants.MESSAGE_NEUTRAL 130 ): 131 duration /= 1000 132 if OSDType != constants.OSD_ALERT: 133 self._listener.sendLine('display-osd: {}, {}, {}'.format('top-right', duration, message)) 134 else: 135 self._listener.sendLine('display-secondary-osd: {}, {}, {}'.format('center', duration, message)) 136 137 def setSpeed(self, value): 138 self._listener.sendLine("set-rate: {:.2n}".format(value)) 139 140 def setFeatures(self, featureList): 141 pass 142 143 def setPosition(self, value): 144 self._lastVLCPositionUpdate = time.time() 145 self._listener.sendLine("set-position: {}".format(value).replace(".", self.radixChar)) 146 147 def setPaused(self, value): 148 self._paused = value 149 if not value: 150 self._lastVLCPositionUpdate = time.time() 151 self._listener.sendLine('set-playstate: {}'.format("paused" if value else "playing")) 152 153 def getMRL(self, fileURL): 154 if utils.isURL(fileURL): 155 fileURL = urllib.parse.quote(fileURL, safe="%/:=&?~#+!$,;'@()*") 156 return fileURL 157 158 fileURL = fileURL.replace('\\', '/') 159 fileURL = fileURL.encode('utf8') 160 fileURL = urllib.parse.quote_plus(fileURL) 161 if isWindows(): 162 fileURL = "file:///" + fileURL 163 else: 164 fileURL = "file://" + fileURL 165 fileURL = fileURL.replace("+", "%20") 166 return fileURL 167 168 def openFile(self, filePath, resetPosition=False): 169 if not utils.isURL(filePath): 170 normedPath = os.path.normpath(filePath) 171 if os.path.isfile(normedPath): 172 filePath = normedPath 173 if utils.isASCII(filePath) and not utils.isURL(filePath): 174 self._listener.sendLine('load-file: {}'.format(filePath)) 175 else: 176 fileURL = self.getMRL(filePath) 177 self._listener.sendLine('load-file: {}'.format(fileURL)) 178 179 def _getFileInfo(self): 180 self._listener.sendLine("get-duration") 181 self._listener.sendLine("get-filepath") 182 self._listener.sendLine("get-filename") 183 184 def lineReceived(self, line): 185 # try: 186 line = line.decode('utf-8') 187 self._client.ui.showDebugMessage("player << {}".format(line)) 188 # except: 189 # pass 190 match, name, value = self.RE_ANSWER.match(line), "", "" 191 if match: 192 name, value = match.group('command'), match.group('argument') 193 194 if line == "filepath-change-notification": 195 self._filechanged = True 196 t = threading.Thread(target=self._onFileUpdate) 197 t.setDaemon(True) 198 t.start() 199 elif name == "filepath": 200 self._filechanged = True 201 if value == "no-input": 202 self._filepath = None 203 else: 204 if "file://" in value: 205 value = value.replace("file://", "") 206 if not os.path.isfile(value): 207 value = value.lstrip("/") 208 elif utils.isURL(value): 209 value = urllib.parse.unquote(value) 210 # value = value.decode('utf-8') 211 self._filepath = value 212 self._pathAsk.set() 213 elif name == "duration": 214 if value == "no-input": 215 self._duration = 0 216 elif value == "invalid-32-bit-value": 217 self._duration = 0 218 self.drop(getMessage("vlc-failed-versioncheck")) 219 else: 220 self._duration = float(value.replace(",", ".")) 221 self._durationAsk.set() 222 elif name == "playstate": 223 self._paused = bool(value != 'playing') if (value != "no-input" and self._filechanged == False) else self._client.getGlobalPaused() 224 diff = time.time() - self._lastVLCPositionUpdate if self._lastVLCPositionUpdate else 0 225 if ( 226 self._paused == False and 227 self._position == self._previousPreviousPosition and 228 self._previousPosition == self._position and 229 self._duration and 230 self._duration > constants.PLAYLIST_LOAD_NEXT_FILE_MINIMUM_LENGTH and 231 (self._duration - self._position) < constants.VLC_EOF_DURATION_THRESHOLD and 232 diff > constants.VLC_LATENCY_ERROR_THRESHOLD 233 ): 234 self._client.ui.showDebugMessage("Treating 'playing' response as 'paused' due to VLC EOF bug") 235 self.setPaused(True) 236 self._pausedAsk.set() 237 elif name == "position": 238 newPosition = float(value.replace(",", ".")) if (value != "no-input" and not self._filechanged) else self._client.getGlobalPosition() 239 if newPosition == self._previousPosition and newPosition != self._duration and self._paused is False: 240 self._client.ui.showDebugMessage( 241 "Not considering position {} duplicate as new time because of VLC time precision bug".format( 242 newPosition)) 243 self._previousPreviousPosition = self._previousPosition 244 self._previousPosition = self._position 245 self._positionAsk.set() 246 return 247 self._previousPreviousPosition = self._previousPosition 248 self._previousPosition = self._position 249 self._position = newPosition 250 if self._position < 0 and self._duration > 2147 and self._vlcVersion == "3.0.0": 251 self.drop(getMessage("vlc-failed-versioncheck")) 252 self._lastVLCPositionUpdate = time.time() 253 self._positionAsk.set() 254 elif name == "filename": 255 self._filechanged = True 256 self._filename = value 257 self._filenameAsk.set() 258 elif line.startswith("vlc-version: "): 259 self._vlcVersion = line.split(': ')[1].replace(' ', '-').split('-')[0] 260 if not utils.meetsMinVersion(self._vlcVersion, constants.VLC_MIN_VERSION): 261 self._client.ui.showErrorMessage(getMessage("vlc-version-mismatch").format(constants.VLC_MIN_VERSION)) 262 self._vlcready.set() 263 264 @staticmethod 265 def run(client, playerPath, filePath, args): 266 vlc = VlcPlayer(client, VlcPlayer.getExpandedPath(playerPath), filePath, args) 267 return vlc 268 269 @staticmethod 270 def getDefaultPlayerPathsList(): 271 l = [] 272 for path in constants.VLC_PATHS: 273 p = VlcPlayer.getExpandedPath(path) 274 if p: 275 l.append(p) 276 return l 277 278 @staticmethod 279 def isValidPlayerPath(path): 280 if "vlc" in path.lower() and VlcPlayer.getExpandedPath(path): 281 return True 282 return False 283 284 @staticmethod 285 def getPlayerPathErrors(playerPath, filePath): 286 return None 287 288 @staticmethod 289 def getIconPath(path): 290 return constants.VLC_ICONPATH 291 292 @staticmethod 293 def getExpandedPath(playerPath): 294 if not os.path.isfile(playerPath): 295 if os.path.isfile(playerPath + "vlc.exe"): 296 playerPath += "vlc.exe" 297 return playerPath 298 elif os.path.isfile(playerPath + "\\vlc.exe"): 299 playerPath += "\\vlc.exe" 300 return playerPath 301 elif os.path.isfile(playerPath + "VLCPortable.exe"): 302 playerPath += "VLCPortable.exe" 303 return playerPath 304 elif os.path.isfile(playerPath + "\\VLCPortable.exe"): 305 playerPath += "\\VLCPortable.exe" 306 return playerPath 307 if os.access(playerPath, os.X_OK): 308 return playerPath 309 for path in os.environ['PATH'].split(':'): 310 path = os.path.join(os.path.realpath(path), playerPath) 311 if os.access(path, os.X_OK): 312 return path 313 314 def drop(self, dropErrorMessage=None): 315 if self._listener: 316 self._vlcclosed.clear() 317 self._listener.sendLine('close-vlc') 318 self._vlcclosed.wait() 319 self._durationAsk.set() 320 self._filenameAsk.set() 321 self._pathAsk.set() 322 self._positionAsk.set() 323 self._vlcready.set() 324 self._pausedAsk.set() 325 if dropErrorMessage: 326 self.reactor.callFromThread(self._client.ui.showErrorMessage, dropErrorMessage, True) 327 self.reactor.callFromThread(self._client.stop, False,) 328 329 class __Listener(threading.Thread, asynchat.async_chat): 330 def __init__(self, playerController, playerPath, filePath, args, vlcReady, vlcClosed): 331 self.__playerController = playerController 332 self.requestedVLCVersion = False 333 self.vlcHasResponded = False 334 self.oldIntfVersion = None 335 self.timeVLCLaunched = None 336 call = [playerPath] 337 if filePath: 338 if utils.isASCII(filePath): 339 call.append(filePath) 340 else: 341 call.append(self.__playerController.getMRL(filePath)) 342 if isLinux(): 343 if 'snap' in playerPath: 344 playerController.vlcIntfPath = '/snap/vlc/current/usr/lib/vlc/lua/intf/' 345 playerController.vlcIntfUserPath = os.path.join(os.getenv('HOME', '.'), "snap/vlc/current/.local/share/vlc/lua/intf/") 346 else: 347 playerController.vlcIntfPath = "/usr/lib/vlc/lua/intf/" 348 playerController.vlcIntfUserPath = os.path.join(os.getenv('HOME', '.'), ".local/share/vlc/lua/intf/") 349 elif isMacOS(): 350 playerController.vlcIntfPath = "/Applications/VLC.app/Contents/MacOS/share/lua/intf/" 351 playerController.vlcIntfUserPath = os.path.join( 352 os.getenv('HOME', '.'), "Library/Application Support/org.videolan.vlc/lua/intf/") 353 elif isBSD(): 354 # *BSD ports/pkgs install to /usr/local by default. 355 # This should also work for all the other BSDs, such as OpenBSD or DragonFly. 356 playerController.vlcIntfPath = "/usr/local/lib/vlc/lua/intf/" 357 playerController.vlcIntfUserPath = os.path.join(os.getenv('HOME', '.'), ".local/share/vlc/lua/intf/") 358 elif "vlcportable.exe" in playerPath.lower(): 359 playerController.vlcIntfPath = os.path.dirname(playerPath).replace("\\", "/") + "/App/vlc/lua/intf/" 360 playerController.vlcIntfUserPath = playerController.vlcIntfPath 361 else: 362 playerController.vlcIntfPath = os.path.dirname(playerPath).replace("\\", "/") + "/lua/intf/" 363 playerController.vlcIntfUserPath = os.path.join(os.getenv('APPDATA', '.'), "VLC\\lua\\intf\\") 364 playerController.vlcModulePath = playerController.vlcIntfPath + "modules/?.luac" 365 def _createIntfFolder(vlcSyncplayInterfaceDir): 366 self.__playerController._client.ui.showDebugMessage("Checking if syncplay.lua intf directory exists") 367 from pathlib import Path 368 if os.path.exists(vlcSyncplayInterfaceDir): 369 self.__playerController._client.ui.showDebugMessage("Found syncplay.lua intf directory:'{}'".format(vlcSyncplayInterfaceDir)) 370 else: 371 self.__playerController._client.ui.showDebugMessage("syncplay.lua intf directory not found, so creating directory '{}'".format(vlcSyncplayInterfaceDir)) 372 Path(vlcSyncplayInterfaceDir).mkdir(mode=0o755, parents=True, exist_ok=True) 373 def _intfNeedsUpdating(vlcSyncplayInterfacePath): 374 self.__playerController._client.ui.showDebugMessage("Checking if '{}' exists and if it is the expected version".format(vlcSyncplayInterfacePath)) 375 if not os.path.isfile(vlcSyncplayInterfacePath): 376 self.__playerController._client.ui.showDebugMessage("syncplay.lua not found, so file needs copying") 377 return True 378 if os.path.isfile(vlcSyncplayInterfacePath): 379 with open(vlcSyncplayInterfacePath, 'rU') as interfacefile: 380 for line in interfacefile: 381 if "local connectorversion" in line: 382 interface_version = line[26:31] 383 if interface_version == constants.VLC_INTERFACE_VERSION: 384 self.__playerController._client.ui.showDebugMessage("syncplay.lua exists and is expected version, so no file needs copying") 385 return False 386 else: 387 self.oldIntfVersion = line[26:31] 388 self.__playerController._client.ui.showDebugMessage("syncplay.lua is {} but expected version is {} so file needs to be copied".format(interface_version, constants.VLC_INTERFACE_VERSION)) 389 return True 390 self.__playerController._client.ui.showDebugMessage("Up-to-dateness checks failed, so copy the file.") 391 return True 392 if _intfNeedsUpdating(os.path.join(playerController.vlcIntfUserPath, "syncplay.lua")): 393 try: 394 _createIntfFolder(playerController.vlcIntfUserPath) 395 copyForm = utils.findResourcePath("syncplay.lua") 396 copyTo = os.path.join(playerController.vlcIntfUserPath, "syncplay.lua") 397 self.__playerController._client.ui.showDebugMessage("Copying VLC Lua Interface from '{}' to '{}'".format(copyForm, copyTo)) 398 import shutil 399 if os.path.exists(copyTo): 400 os.chmod(copyTo, 0o755) 401 shutil.copyfile(copyForm, copyTo) 402 os.chmod(copyTo, 0o755) 403 except Exception as e: 404 playerController._client.ui.showErrorMessage(e) 405 return 406 if isLinux(): 407 playerController.vlcDataPath = "/usr/lib/syncplay/resources" 408 else: 409 playerController.vlcDataPath = utils.findWorkingDir() + "\\resources" 410 playerController.SLAVE_ARGS.append('--data-path={}'.format(playerController.vlcDataPath)) 411 playerController.SLAVE_ARGS.append( 412 '--lua-config=syncplay={{modulepath=\"{}\",port=\"{}\"}}'.format( 413 playerController.vlcModulePath, str(playerController.vlcport))) 414 415 call.extend(playerController.SLAVE_ARGS) 416 if args: 417 call.extend(args) 418 419 self._vlcready = vlcReady 420 self._vlcclosed = vlcClosed 421 self._vlcVersion = None 422 423 if isWindows() and getattr(sys, 'frozen', '') and getattr(sys, '_MEIPASS', '') is not None: # Needed for pyinstaller --onefile bundle 424 self.__process = subprocess.Popen( 425 call, stdin=subprocess.PIPE, stderr=subprocess.PIPE, stdout=subprocess.PIPE, 426 shell=False, creationflags=0x08000000) 427 else: 428 self.__process = subprocess.Popen(call, stderr=subprocess.PIPE, stdout=subprocess.PIPE) 429 self.timeVLCLaunched = time.time() 430 if self._shouldListenForSTDOUT(): 431 for line in iter(self.__process.stderr.readline, ''): 432 line = line.decode('utf-8') 433 self.vlcHasResponded = True 434 self.timeVLCLaunched = None 435 if "[syncplay]" in line: 436 if "Listening on host" in line: 437 break 438 if "Hosting Syncplay" in line: 439 break 440 elif "Couldn't find lua interface" in line: 441 playerController._client.ui.showErrorMessage( 442 getMessage("vlc-failed-noscript").format(line), True) 443 break 444 elif "lua interface error" in line: 445 playerController._client.ui.showErrorMessage( 446 getMessage("media-player-error").format(line), True) 447 break 448 if not isMacOS(): 449 self.__process.stderr = None 450 else: 451 vlcoutputthread = threading.Thread(target=self.handle_vlcoutput, args=()) 452 vlcoutputthread.setDaemon(True) 453 vlcoutputthread.start() 454 threading.Thread.__init__(self, name="VLC Listener") 455 asynchat.async_chat.__init__(self) 456 self.set_terminator(b'\n') 457 self._ibuffer = [] 458 self.create_socket(socket.AF_INET, socket.SOCK_STREAM) 459 self._sendingData = threading.Lock() 460 461 def _shouldListenForSTDOUT(self): 462 return not isWindows() 463 464 def initiate_send(self): 465 with self._sendingData: 466 asynchat.async_chat.initiate_send(self) 467 468 def run(self): 469 self._vlcready.clear() 470 self.connect(('localhost', self.__playerController.vlcport)) 471 asyncore.loop() 472 473 def handle_connect(self): 474 asynchat.async_chat.handle_connect(self) 475 self._vlcready.set() 476 self.timeVLCLaunched = None 477 478 def collect_incoming_data(self, data): 479 self._ibuffer.append(data) 480 481 def handle_close(self): 482 if self.timeVLCLaunched and time.time() - self.timeVLCLaunched < constants.VLC_OPEN_MAX_WAIT_TIME: 483 try: 484 self.__playerController._client.ui.showDebugMessage("Failed to connect to VLC, but reconnecting as within max wait time") 485 except: 486 pass 487 self.run() 488 elif self.vlcHasResponded: 489 asynchat.async_chat.handle_close(self) 490 self.__playerController.drop() 491 else: 492 self.vlcHasResponded = True 493 asynchat.async_chat.handle_close(self) 494 self.__playerController.drop(getMessage("vlc-failed-connection").format(constants.VLC_MIN_VERSION)) 495 496 def handle_vlcoutput(self): 497 out = self.__process.stderr 498 for line in iter(out.readline, ''): 499 line = line.decode('utf-8') 500 if '[syncplay] core interface debug: removing module' in line: 501 self.__playerController.drop() 502 break 503 out.close() 504 505 def found_terminator(self): 506 self.vlcHasResponded = True 507 self.__playerController.lineReceived(b"".join(self._ibuffer)) 508 self._ibuffer = [] 509 510 def sendLine(self, line): 511 if self.connected: 512 if not self.requestedVLCVersion: 513 self.requestedVLCVersion = True 514 self.sendLine("get-vlc-version") 515 # try: 516 lineToSend = line + "\n" 517 self.push(lineToSend.encode('utf-8')) 518 if self.__playerController._client and self.__playerController._client.ui: 519 self.__playerController._client.ui.showDebugMessage("player >> {}".format(line)) 520 # except: 521 # pass 522 if line == "close-vlc": 523 self._vlcclosed.set() 524 if not self.connected and not self.timeVLCLaunched: 525 # For circumstances where Syncplay is not connected to VLC and is not reconnecting 526 try: 527 self.__process.terminate() 528 except: # When VLC is already closed 529 pass 530