# coding:utf8 import ast import os import re import subprocess import sys import threading import time from syncplay import constants, utils from syncplay.players.basePlayer import BasePlayer from syncplay.messages import getMessage from syncplay.utils import isMacOS, isWindows class MplayerPlayer(BasePlayer): speedSupported = True customOpenDialog = False alertOSDSupported = False chatOSDSupported = False osdMessageSeparator = "; " RE_ANSWER = re.compile(constants.MPLAYER_ANSWER_REGEX) POSITION_QUERY = 'time_pos' OSD_QUERY = 'osd_show_text' def __init__(self, client, playerPath, filePath, args): from twisted.internet import reactor self.reactor = reactor self._client = client self._paused = None self._position = 0.0 self._duration = None self._filename = None self._filepath = None self.quitReason = None self.lastLoadedTime = None self.fileLoaded = False self.delayedFilePath = None try: self._listener = self.__Listener(self, playerPath, filePath, args) except ValueError: self._client.ui.showMessage(getMessage("mplayer-file-required-notification")) self._client.ui.showMessage(getMessage("mplayer-file-required-notification/example")) self.drop() return self._listener.setDaemon(True) self._listener.start() self._durationAsk = threading.Event() self._filenameAsk = threading.Event() self._pathAsk = threading.Event() self._positionAsk = threading.Event() self._pausedAsk = threading.Event() self._preparePlayer() def _fileUpdateClearEvents(self): self._durationAsk.clear() self._filenameAsk.clear() self._pathAsk.clear() def _fileUpdateWaitEvents(self): self._durationAsk.wait() self._filenameAsk.wait() self._pathAsk.wait() def _onFileUpdate(self): self._fileUpdateClearEvents() self._getFilename() self._getLength() self._getFilepath() self._fileUpdateWaitEvents() self._client.updateFile(self._filename, self._duration, self._filepath) def _preparePlayer(self): self.setPaused(True) self.reactor.callLater(0, self._client.initPlayer, self) self._onFileUpdate() def askForStatus(self): self._positionAsk.clear() self._pausedAsk.clear() self._getPaused() self._getPosition() self._positionAsk.wait() self._pausedAsk.wait() self._client.updatePlayerStatus(self._paused, self._position) def _setProperty(self, property_, value): self._listener.sendLine("set_property {} {}".format(property_, value)) def _getProperty(self, property_): self._listener.sendLine("get_property {}".format(property_)) def displayMessage( self, message, duration=(constants.OSD_DURATION * 1000), OSDType=constants.OSD_NOTIFICATION, mood=constants.MESSAGE_NEUTRAL ): messageString = self._sanitizeText(message.replace("\\n", "")).replace("", "\\n") self._listener.sendLine('{} "{!s}" {} {}'.format( self.OSD_QUERY, messageString, duration, constants.MPLAYER_OSD_LEVEL)) def displayChatMessage(self, username, message): messageString = "<{}> {}".format(username, message) messageString = self._sanitizeText(messageString.replace("\\n", "")).replace("", "\\n") duration = int(constants.OSD_DURATION * 1000) self._listener.sendLine('{} "{!s}" {} {}'.format( self.OSD_QUERY, messageString, duration, constants.MPLAYER_OSD_LEVEL)) def setSpeed(self, value): self._setProperty('speed', "{:.2f}".format(value)) def _loadFile(self, filePath): self._listener.sendLine('loadfile {}'.format(self._quoteArg(filePath))) def openFile(self, filePath, resetPosition=False): self._filepath = filePath self._loadFile(filePath) self._onFileUpdate() if self._paused != self._client.getGlobalPaused(): self.setPaused(self._client.getGlobalPaused()) self.setPosition(self._client.getGlobalPosition()) def setFeatures(self, featureList): pass def setPosition(self, value): self._position = max(value, 0) self._setProperty(self.POSITION_QUERY, "{}".format(value)) time.sleep(0.03) def setPaused(self, value): if self._paused != value: self._paused = not self._paused self._listener.sendLine('pause') def _getFilename(self): self._getProperty('filename') def _getLength(self): self._getProperty('length') def _getFilepath(self): self._getProperty('path') def _getPaused(self): self._getProperty('pause') def _getPosition(self): self._getProperty(self.POSITION_QUERY) def _sanitizeText(self, text): text = text.replace("\r", "") text = text.replace("\n", "") text = text.replace("\\\"", "") text = text.replace("\"", "") text = text.replace("%", "%%") text = text.replace("\\", "\\\\") text = text.replace("{", "\\\\{") text = text.replace("}", "\\\\}") text = text.replace("", "\\\"") return text def _quoteArg(self, arg): arg = arg.replace('\\', '\\\\') arg = arg.replace("'", "\\'") arg = arg.replace('"', '\\"') arg = arg.replace("\r", "") arg = arg.replace("\n", "") return '"{}"'.format(arg) def _fileIsLoaded(self): return True def _handleUnknownLine(self, line): pass def _storePosition(self, value): self._position = max(value, 0) def _storePauseState(self, value): self._paused = value def lineReceived(self, line): if line: self._client.ui.showDebugMessage("player << {}".format(line)) line = line.replace("[cplayer] ", "") # -v workaround line = line.replace("[term-msg] ", "") # -v workaround line = line.replace(" cplayer: ", "") # --msg-module workaround line = line.replace(" term-msg: ", "") if ( "Failed to get value of property" in line or "=(unavailable)" in line or line == "ANS_filename=" or line == "ANS_length=" or line == "ANS_path=" ): if "filename" in line: self._getFilename() elif "length" in line: self._getLength() elif "path" in line: self._getFilepath() return match = self.RE_ANSWER.match(line) if not match: self._handleUnknownLine(line) return name, value = [m for m in match.groups() if m] name = name.lower() if name == self.POSITION_QUERY: self._storePosition(float(value)) self._positionAsk.set() elif name == "pause": self._storePauseState(bool(value == 'yes')) self._pausedAsk.set() elif name == "length": try: self._duration = float(value) except: self._duration = 0 self._durationAsk.set() elif name == "path": self._filepath = value self._pathAsk.set() elif name == "filename": self._filename = value self._filenameAsk.set() elif name == "exiting": if value != 'Quit': if self.quitReason is None: self.quitReason = getMessage("media-player-error").format(value) self.reactor.callFromThread(self._client.ui.showErrorMessage, self.quitReason, True) self.drop() @staticmethod def run(client, playerPath, filePath, args): mplayer = MplayerPlayer(client, MplayerPlayer.getExpandedPath(playerPath), filePath, args) return mplayer @staticmethod def getDefaultPlayerPathsList(): l = [] for path in constants.MPLAYER_PATHS: p = MplayerPlayer.getExpandedPath(path) if p: l.append(p) return l @staticmethod def getIconPath(path): return constants.MPLAYER_ICONPATH @staticmethod def getStartupArgs(path, userArgs): args = [] if userArgs: args.extend(userArgs) args.extend(constants.MPLAYER_SLAVE_ARGS) return args @staticmethod def isValidPlayerPath(path): if "mplayer" in path and MplayerPlayer.getExpandedPath(path) and "mplayerc.exe" not in path: # "mplayerc.exe" is Media Player Classic (not Home Cinema): return True return False @staticmethod def getPlayerPathErrors(playerPath, filePath): if not filePath: return getMessage("no-file-path-config-error") @staticmethod def getExpandedPath(playerPath): if not os.path.isfile(playerPath): if os.path.isfile(playerPath + "mplayer.exe"): playerPath += "mplayer.exe" return playerPath elif os.path.isfile(playerPath + "\\mplayer.exe"): playerPath += "\\mplayer.exe" return playerPath if os.access(playerPath, os.X_OK): return playerPath for path in os.environ['PATH'].split(':'): path = os.path.join(os.path.realpath(path), playerPath) if os.access(path, os.X_OK): return path def notMplayer2(self): self.reactor.callFromThread(self._client.ui.showErrorMessage, getMessage("mplayer2-required"), True) self.drop() def _takeLocksDown(self): self._durationAsk.set() self._filenameAsk.set() self._pathAsk.set() self._positionAsk.set() self._pausedAsk.set() def drop(self): self._listener.sendLine('quit') self._takeLocksDown() self.reactor.callFromThread(self._client.stop, False) class __Listener(threading.Thread): def __init__(self, playerController, playerPath, filePath, args): self.sendQueue = [] self.readyToSend = True self.lastSendTime = None self.lastNotReadyTime = None self.__playerController = playerController if not self.__playerController._client._config["chatOutputEnabled"]: self.__playerController.alertOSDSupported = False self.__playerController.chatOSDSupported = False if self.__playerController.getPlayerPathErrors(playerPath, filePath): raise ValueError() if filePath and '://' not in filePath: if not os.path.isfile(filePath) and 'PWD' in os.environ: filePath = os.environ['PWD'] + os.path.sep + filePath filePath = os.path.realpath(filePath) call = [playerPath] if filePath: if isWindows() and not utils.isASCII(filePath): self.__playerController.delayedFilePath = filePath filePath = None else: call.extend([filePath]) call.extend(playerController.getStartupArgs(playerPath, args)) # At least mpv may output escape sequences which result in syncplay # trying to parse something like # "\x1b[?1l\x1b>ANS_filename=blah.mkv". Work around this by # unsetting TERM. env = os.environ.copy() if 'TERM' in env: del env['TERM'] # On macOS, youtube-dl requires system python to run. Set the environment # to allow that version of python to be executed in the mpv subprocess. if isMacOS(): try: pythonLibs = subprocess.check_output(['/usr/bin/python', '-E', '-c', 'import sys; print(sys.path)'], text=True, env=dict()) pythonLibs = ast.literal_eval(pythonLibs) pythonPath = ':'.join(pythonLibs[1:]) except: pythonPath = None if pythonPath is not None: env['PATH'] = '/usr/bin:/usr/local/bin' env['PYTHONPATH'] = pythonPath if filePath: self.__process = subprocess.Popen( call, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=self.__getCwd(filePath, env), env=env, bufsize=0) else: self.__process = subprocess.Popen( call, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, bufsize=0) threading.Thread.__init__(self, name="MPlayer Listener") def __getCwd(self, filePath, env): if not filePath: return None if os.path.isfile(filePath): cwd = os.path.dirname(filePath) elif 'HOME' in env: cwd = env['HOME'] elif 'APPDATA' in env: cwd = env['APPDATA'] else: cwd = None return cwd def run(self): line = self.__process.stdout.readline() line = line.decode('utf-8') if "MPlayer 1" in line: self.__playerController.notMplayer2() else: line = line.rstrip("\r\n") self.__playerController.lineReceived(line) while self.__process.poll() is None: line = self.__process.stdout.readline() line = line.decode('utf-8') line = line.rstrip("\r\n") self.__playerController.lineReceived(line) self.__playerController.drop() def sendChat(self, message): if message: if message[:1] == "/" and message != "/": command = message[1:] if command and command[:1] == "/": message = message[1:] else: self.__playerController.reactor.callFromThread( self.__playerController._client.ui.executeCommand, command) return self.__playerController.reactor.callFromThread(self.__playerController._client.sendChat, message) def isReadyForSend(self): self.checkForReadinessOverride() return self.readyToSend def setReadyToSend(self, newReadyState): oldState = self.readyToSend self.readyToSend = newReadyState self.lastNotReadyTime = time.time() if newReadyState == False else None if self.readyToSend == True: self.__playerController._client.ui.showDebugMessage(" Ready to send: True") else: self.__playerController._client.ui.showDebugMessage(" Ready to send: False") if self.readyToSend == True and oldState == False: self.processSendQueue() def checkForReadinessOverride(self): if self.lastNotReadyTime and time.time() - self.lastNotReadyTime > constants.MPV_MAX_NEWFILE_COOLDOWN_TIME: self.setReadyToSend(True) def sendLine(self, line, notReadyAfterThis=None): self.checkForReadinessOverride() if self.readyToSend == False and "print_text ANS_pause" in line: self.__playerController._client.ui.showDebugMessage(" Not ready to get status update, so skipping") return try: if self.sendQueue: if constants.MPV_SUPERSEDE_IF_DUPLICATE_COMMANDS: for command in constants.MPV_SUPERSEDE_IF_DUPLICATE_COMMANDS: if line.startswith(command): for itemID, deletionCandidate in enumerate(self.sendQueue): if deletionCandidate.startswith(command): self.__playerController._client.ui.showDebugMessage( " Remove duplicate (supersede): {}".format(self.sendQueue[itemID])) try: self.sendQueue.remove(self.sendQueue[itemID]) except UnicodeWarning: self.__playerController._client.ui.showDebugMessage( " Unicode mismatch occured when trying to remove duplicate") # TODO: Prevent this from being triggered pass break break if constants.MPV_REMOVE_BOTH_IF_DUPLICATE_COMMANDS: for command in constants.MPV_REMOVE_BOTH_IF_DUPLICATE_COMMANDS: if line == command: for itemID, deletionCandidate in enumerate(self.sendQueue): if deletionCandidate == command: self.__playerController._client.ui.showDebugMessage( " Remove duplicate (delete both): {}".format(self.sendQueue[itemID])) self.__playerController._client.ui.showDebugMessage(self.sendQueue[itemID]) return except: self.__playerController._client.ui.showDebugMessage(" Problem removing duplicates, etc") self.sendQueue.append(line) self.processSendQueue() if notReadyAfterThis: self.setReadyToSend(False) def processSendQueue(self): while self.sendQueue and self.readyToSend: if self.lastSendTime and time.time() - self.lastSendTime < constants.MPV_SENDMESSAGE_COOLDOWN_TIME: self.__playerController._client.ui.showDebugMessage( " Throttling message send, so sleeping for {}".format( constants.MPV_SENDMESSAGE_COOLDOWN_TIME)) time.sleep(constants.MPV_SENDMESSAGE_COOLDOWN_TIME) try: lineToSend = self.sendQueue.pop() if lineToSend: self.lastSendTime = time.time() self.actuallySendLine(lineToSend) except IndexError: pass def actuallySendLine(self, line): try: # if not isinstance(line, str): # line = line.decode('utf8') line = line + "\n" self.__playerController._client.ui.showDebugMessage("player >> {}".format(line)) line = line.encode('utf-8') self.__process.stdin.write(line) except IOError: pass