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