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