1# coding:utf8 2import ast 3import os 4import re 5import subprocess 6import sys 7import threading 8import time 9 10 11from syncplay import constants, utils 12from syncplay.players.basePlayer import BasePlayer 13from syncplay.messages import getMessage 14from syncplay.utils import isMacOS, isWindows 15 16 17class MplayerPlayer(BasePlayer): 18 speedSupported = True 19 customOpenDialog = False 20 alertOSDSupported = False 21 chatOSDSupported = False 22 osdMessageSeparator = "; " 23 24 RE_ANSWER = re.compile(constants.MPLAYER_ANSWER_REGEX) 25 POSITION_QUERY = 'time_pos' 26 OSD_QUERY = 'osd_show_text' 27 28 def __init__(self, client, playerPath, filePath, args): 29 from twisted.internet import reactor 30 self.reactor = reactor 31 self._client = client 32 self._paused = None 33 self._position = 0.0 34 self._duration = None 35 self._filename = None 36 self._filepath = None 37 self.quitReason = None 38 self.lastLoadedTime = None 39 self.fileLoaded = False 40 self.delayedFilePath = None 41 try: 42 self._listener = self.__Listener(self, playerPath, filePath, args) 43 except ValueError: 44 self._client.ui.showMessage(getMessage("mplayer-file-required-notification")) 45 self._client.ui.showMessage(getMessage("mplayer-file-required-notification/example")) 46 self.drop() 47 return 48 self._listener.setDaemon(True) 49 self._listener.start() 50 51 self._durationAsk = threading.Event() 52 self._filenameAsk = threading.Event() 53 self._pathAsk = threading.Event() 54 55 self._positionAsk = threading.Event() 56 self._pausedAsk = threading.Event() 57 58 self._preparePlayer() 59 60 def _fileUpdateClearEvents(self): 61 self._durationAsk.clear() 62 self._filenameAsk.clear() 63 self._pathAsk.clear() 64 65 def _fileUpdateWaitEvents(self): 66 self._durationAsk.wait() 67 self._filenameAsk.wait() 68 self._pathAsk.wait() 69 70 def _onFileUpdate(self): 71 self._fileUpdateClearEvents() 72 self._getFilename() 73 self._getLength() 74 self._getFilepath() 75 self._fileUpdateWaitEvents() 76 self._client.updateFile(self._filename, self._duration, self._filepath) 77 78 def _preparePlayer(self): 79 self.setPaused(True) 80 self.reactor.callLater(0, self._client.initPlayer, self) 81 self._onFileUpdate() 82 83 def askForStatus(self): 84 self._positionAsk.clear() 85 self._pausedAsk.clear() 86 self._getPaused() 87 self._getPosition() 88 self._positionAsk.wait() 89 self._pausedAsk.wait() 90 self._client.updatePlayerStatus(self._paused, self._position) 91 92 def _setProperty(self, property_, value): 93 self._listener.sendLine("set_property {} {}".format(property_, value)) 94 95 def _getProperty(self, property_): 96 self._listener.sendLine("get_property {}".format(property_)) 97 98 def displayMessage( 99 self, message, 100 duration=(constants.OSD_DURATION * 1000), OSDType=constants.OSD_NOTIFICATION, mood=constants.MESSAGE_NEUTRAL 101 ): 102 messageString = self._sanitizeText(message.replace("\\n", "<NEWLINE>")).replace("<NEWLINE>", "\\n") 103 self._listener.sendLine('{} "{!s}" {} {}'.format( 104 self.OSD_QUERY, messageString, duration, constants.MPLAYER_OSD_LEVEL)) 105 106 def displayChatMessage(self, username, message): 107 messageString = "<{}> {}".format(username, message) 108 messageString = self._sanitizeText(messageString.replace("\\n", "<NEWLINE>")).replace("<NEWLINE>", "\\n") 109 duration = int(constants.OSD_DURATION * 1000) 110 self._listener.sendLine('{} "{!s}" {} {}'.format( 111 self.OSD_QUERY, messageString, duration, constants.MPLAYER_OSD_LEVEL)) 112 113 def setSpeed(self, value): 114 self._setProperty('speed', "{:.2f}".format(value)) 115 116 def _loadFile(self, filePath): 117 self._listener.sendLine('loadfile {}'.format(self._quoteArg(filePath))) 118 119 def openFile(self, filePath, resetPosition=False): 120 self._filepath = filePath 121 self._loadFile(filePath) 122 self._onFileUpdate() 123 if self._paused != self._client.getGlobalPaused(): 124 self.setPaused(self._client.getGlobalPaused()) 125 self.setPosition(self._client.getGlobalPosition()) 126 127 def setFeatures(self, featureList): 128 pass 129 130 def setPosition(self, value): 131 self._position = max(value, 0) 132 self._setProperty(self.POSITION_QUERY, "{}".format(value)) 133 time.sleep(0.03) 134 135 def setPaused(self, value): 136 if self._paused != value: 137 self._paused = not self._paused 138 self._listener.sendLine('pause') 139 140 def _getFilename(self): 141 self._getProperty('filename') 142 143 def _getLength(self): 144 self._getProperty('length') 145 146 def _getFilepath(self): 147 self._getProperty('path') 148 149 def _getPaused(self): 150 self._getProperty('pause') 151 152 def _getPosition(self): 153 self._getProperty(self.POSITION_QUERY) 154 155 def _sanitizeText(self, text): 156 text = text.replace("\r", "") 157 text = text.replace("\n", "") 158 text = text.replace("\\\"", "<SYNCPLAY_QUOTE>") 159 text = text.replace("\"", "<SYNCPLAY_QUOTE>") 160 text = text.replace("%", "%%") 161 text = text.replace("\\", "\\\\") 162 text = text.replace("{", "\\\\{") 163 text = text.replace("}", "\\\\}") 164 text = text.replace("<SYNCPLAY_QUOTE>", "\\\"") 165 return text 166 167 def _quoteArg(self, arg): 168 arg = arg.replace('\\', '\\\\') 169 arg = arg.replace("'", "\\'") 170 arg = arg.replace('"', '\\"') 171 arg = arg.replace("\r", "") 172 arg = arg.replace("\n", "") 173 return '"{}"'.format(arg) 174 175 def _fileIsLoaded(self): 176 return True 177 178 def _handleUnknownLine(self, line): 179 pass 180 181 def _storePosition(self, value): 182 self._position = max(value, 0) 183 184 def _storePauseState(self, value): 185 self._paused = value 186 187 def lineReceived(self, line): 188 if line: 189 self._client.ui.showDebugMessage("player << {}".format(line)) 190 line = line.replace("[cplayer] ", "") # -v workaround 191 line = line.replace("[term-msg] ", "") # -v workaround 192 line = line.replace(" cplayer: ", "") # --msg-module workaround 193 line = line.replace(" term-msg: ", "") 194 if ( 195 "Failed to get value of property" in line or 196 "=(unavailable)" in line or 197 line == "ANS_filename=" or 198 line == "ANS_length=" or 199 line == "ANS_path=" 200 ): 201 if "filename" in line: 202 self._getFilename() 203 elif "length" in line: 204 self._getLength() 205 elif "path" in line: 206 self._getFilepath() 207 return 208 match = self.RE_ANSWER.match(line) 209 if not match: 210 self._handleUnknownLine(line) 211 return 212 213 name, value = [m for m in match.groups() if m] 214 name = name.lower() 215 216 if name == self.POSITION_QUERY: 217 self._storePosition(float(value)) 218 self._positionAsk.set() 219 elif name == "pause": 220 self._storePauseState(bool(value == 'yes')) 221 self._pausedAsk.set() 222 elif name == "length": 223 try: 224 self._duration = float(value) 225 except: 226 self._duration = 0 227 self._durationAsk.set() 228 elif name == "path": 229 self._filepath = value 230 self._pathAsk.set() 231 elif name == "filename": 232 self._filename = value 233 self._filenameAsk.set() 234 elif name == "exiting": 235 if value != 'Quit': 236 if self.quitReason is None: 237 self.quitReason = getMessage("media-player-error").format(value) 238 self.reactor.callFromThread(self._client.ui.showErrorMessage, self.quitReason, True) 239 self.drop() 240 241 @staticmethod 242 def run(client, playerPath, filePath, args): 243 mplayer = MplayerPlayer(client, MplayerPlayer.getExpandedPath(playerPath), filePath, args) 244 return mplayer 245 246 @staticmethod 247 def getDefaultPlayerPathsList(): 248 l = [] 249 for path in constants.MPLAYER_PATHS: 250 p = MplayerPlayer.getExpandedPath(path) 251 if p: 252 l.append(p) 253 return l 254 255 @staticmethod 256 def getIconPath(path): 257 return constants.MPLAYER_ICONPATH 258 259 @staticmethod 260 def getStartupArgs(path, userArgs): 261 args = [] 262 if userArgs: 263 args.extend(userArgs) 264 args.extend(constants.MPLAYER_SLAVE_ARGS) 265 return args 266 267 @staticmethod 268 def isValidPlayerPath(path): 269 if "mplayer" in path and MplayerPlayer.getExpandedPath(path) and "mplayerc.exe" not in path: # "mplayerc.exe" is Media Player Classic (not Home Cinema): 270 return True 271 return False 272 273 @staticmethod 274 def getPlayerPathErrors(playerPath, filePath): 275 if not filePath: 276 return getMessage("no-file-path-config-error") 277 278 @staticmethod 279 def getExpandedPath(playerPath): 280 if not os.path.isfile(playerPath): 281 if os.path.isfile(playerPath + "mplayer.exe"): 282 playerPath += "mplayer.exe" 283 return playerPath 284 elif os.path.isfile(playerPath + "\\mplayer.exe"): 285 playerPath += "\\mplayer.exe" 286 return playerPath 287 if os.access(playerPath, os.X_OK): 288 return playerPath 289 for path in os.environ['PATH'].split(':'): 290 path = os.path.join(os.path.realpath(path), playerPath) 291 if os.access(path, os.X_OK): 292 return path 293 294 def notMplayer2(self): 295 self.reactor.callFromThread(self._client.ui.showErrorMessage, getMessage("mplayer2-required"), True) 296 self.drop() 297 298 def _takeLocksDown(self): 299 self._durationAsk.set() 300 self._filenameAsk.set() 301 self._pathAsk.set() 302 self._positionAsk.set() 303 self._pausedAsk.set() 304 305 def drop(self): 306 self._listener.sendLine('quit') 307 self._takeLocksDown() 308 self.reactor.callFromThread(self._client.stop, False) 309 310 class __Listener(threading.Thread): 311 def __init__(self, playerController, playerPath, filePath, args): 312 self.sendQueue = [] 313 self.readyToSend = True 314 self.lastSendTime = None 315 self.lastNotReadyTime = None 316 self.__playerController = playerController 317 if not self.__playerController._client._config["chatOutputEnabled"]: 318 self.__playerController.alertOSDSupported = False 319 self.__playerController.chatOSDSupported = False 320 if self.__playerController.getPlayerPathErrors(playerPath, filePath): 321 raise ValueError() 322 if filePath and '://' not in filePath: 323 if not os.path.isfile(filePath) and 'PWD' in os.environ: 324 filePath = os.environ['PWD'] + os.path.sep + filePath 325 filePath = os.path.realpath(filePath) 326 327 call = [playerPath] 328 if filePath: 329 if isWindows() and not utils.isASCII(filePath): 330 self.__playerController.delayedFilePath = filePath 331 filePath = None 332 else: 333 call.extend([filePath]) 334 call.extend(playerController.getStartupArgs(playerPath, args)) 335 # At least mpv may output escape sequences which result in syncplay 336 # trying to parse something like 337 # "\x1b[?1l\x1b>ANS_filename=blah.mkv". Work around this by 338 # unsetting TERM. 339 env = os.environ.copy() 340 if 'TERM' in env: 341 del env['TERM'] 342 # On macOS, youtube-dl requires system python to run. Set the environment 343 # to allow that version of python to be executed in the mpv subprocess. 344 if isMacOS(): 345 try: 346 pythonLibs = subprocess.check_output(['/usr/bin/python', '-E', '-c', 347 'import sys; print(sys.path)'], 348 text=True, env=dict()) 349 pythonLibs = ast.literal_eval(pythonLibs) 350 pythonPath = ':'.join(pythonLibs[1:]) 351 except: 352 pythonPath = None 353 if pythonPath is not None: 354 env['PATH'] = '/usr/bin:/usr/local/bin' 355 env['PYTHONPATH'] = pythonPath 356 if filePath: 357 self.__process = subprocess.Popen( 358 call, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT, 359 cwd=self.__getCwd(filePath, env), env=env, bufsize=0) 360 else: 361 self.__process = subprocess.Popen( 362 call, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT, 363 env=env, bufsize=0) 364 threading.Thread.__init__(self, name="MPlayer Listener") 365 366 def __getCwd(self, filePath, env): 367 if not filePath: 368 return None 369 if os.path.isfile(filePath): 370 cwd = os.path.dirname(filePath) 371 elif 'HOME' in env: 372 cwd = env['HOME'] 373 elif 'APPDATA' in env: 374 cwd = env['APPDATA'] 375 else: 376 cwd = None 377 return cwd 378 379 def run(self): 380 line = self.__process.stdout.readline() 381 line = line.decode('utf-8') 382 if "MPlayer 1" in line: 383 self.__playerController.notMplayer2() 384 else: 385 line = line.rstrip("\r\n") 386 self.__playerController.lineReceived(line) 387 while self.__process.poll() is None: 388 line = self.__process.stdout.readline() 389 line = line.decode('utf-8') 390 line = line.rstrip("\r\n") 391 self.__playerController.lineReceived(line) 392 self.__playerController.drop() 393 394 def sendChat(self, message): 395 if message: 396 if message[:1] == "/" and message != "/": 397 command = message[1:] 398 if command and command[:1] == "/": 399 message = message[1:] 400 else: 401 self.__playerController.reactor.callFromThread( 402 self.__playerController._client.ui.executeCommand, command) 403 return 404 self.__playerController.reactor.callFromThread(self.__playerController._client.sendChat, message) 405 406 def isReadyForSend(self): 407 self.checkForReadinessOverride() 408 return self.readyToSend 409 410 def setReadyToSend(self, newReadyState): 411 oldState = self.readyToSend 412 self.readyToSend = newReadyState 413 self.lastNotReadyTime = time.time() if newReadyState == False else None 414 if self.readyToSend == True: 415 self.__playerController._client.ui.showDebugMessage("<mpv> Ready to send: True") 416 else: 417 self.__playerController._client.ui.showDebugMessage("<mpv> Ready to send: False") 418 if self.readyToSend == True and oldState == False: 419 self.processSendQueue() 420 421 def checkForReadinessOverride(self): 422 if self.lastNotReadyTime and time.time() - self.lastNotReadyTime > constants.MPV_MAX_NEWFILE_COOLDOWN_TIME: 423 self.setReadyToSend(True) 424 425 def sendLine(self, line, notReadyAfterThis=None): 426 self.checkForReadinessOverride() 427 if self.readyToSend == False and "print_text ANS_pause" in line: 428 self.__playerController._client.ui.showDebugMessage("<mpv> Not ready to get status update, so skipping") 429 return 430 try: 431 if self.sendQueue: 432 if constants.MPV_SUPERSEDE_IF_DUPLICATE_COMMANDS: 433 for command in constants.MPV_SUPERSEDE_IF_DUPLICATE_COMMANDS: 434 if line.startswith(command): 435 for itemID, deletionCandidate in enumerate(self.sendQueue): 436 if deletionCandidate.startswith(command): 437 self.__playerController._client.ui.showDebugMessage( 438 "<mpv> Remove duplicate (supersede): {}".format(self.sendQueue[itemID])) 439 try: 440 self.sendQueue.remove(self.sendQueue[itemID]) 441 except UnicodeWarning: 442 self.__playerController._client.ui.showDebugMessage( 443 "<mpv> Unicode mismatch occured when trying to remove duplicate") 444 # TODO: Prevent this from being triggered 445 pass 446 break 447 break 448 if constants.MPV_REMOVE_BOTH_IF_DUPLICATE_COMMANDS: 449 for command in constants.MPV_REMOVE_BOTH_IF_DUPLICATE_COMMANDS: 450 if line == command: 451 for itemID, deletionCandidate in enumerate(self.sendQueue): 452 if deletionCandidate == command: 453 self.__playerController._client.ui.showDebugMessage( 454 "<mpv> Remove duplicate (delete both): {}".format(self.sendQueue[itemID])) 455 self.__playerController._client.ui.showDebugMessage(self.sendQueue[itemID]) 456 return 457 except: 458 self.__playerController._client.ui.showDebugMessage("<mpv> Problem removing duplicates, etc") 459 self.sendQueue.append(line) 460 self.processSendQueue() 461 if notReadyAfterThis: 462 self.setReadyToSend(False) 463 464 def processSendQueue(self): 465 while self.sendQueue and self.readyToSend: 466 if self.lastSendTime and time.time() - self.lastSendTime < constants.MPV_SENDMESSAGE_COOLDOWN_TIME: 467 self.__playerController._client.ui.showDebugMessage( 468 "<mpv> Throttling message send, so sleeping for {}".format( 469 constants.MPV_SENDMESSAGE_COOLDOWN_TIME)) 470 time.sleep(constants.MPV_SENDMESSAGE_COOLDOWN_TIME) 471 try: 472 lineToSend = self.sendQueue.pop() 473 if lineToSend: 474 self.lastSendTime = time.time() 475 self.actuallySendLine(lineToSend) 476 except IndexError: 477 pass 478 479 def actuallySendLine(self, line): 480 try: 481 # if not isinstance(line, str): 482 # line = line.decode('utf8') 483 line = line + "\n" 484 self.__playerController._client.ui.showDebugMessage("player >> {}".format(line)) 485 line = line.encode('utf-8') 486 self.__process.stdin.write(line) 487 except IOError: 488 pass 489