1--[==========================================================================[
2 syncplay.lua: Syncplay interface module for VLC
3--[==========================================================================[
4
5 Principal author: Etoh
6 Other contributors: DerGenaue, jb, Pilotat
7 Project: https://syncplay.pl/
8 Version: 0.3.5
9
10 Note:
11 * This interface module is intended to be used in conjunction with Syncplay.
12 * Syncplay provides synchronized video playback across multiple media player instances over the net.
13 * Syncplay allows group of people who all have the same videos to watch them together wherever they are.
14 * Syncplay is available to download for free from https://syncplay.pl/
15
16--[==========================================================================[
17
18 === Installation instructions ===
19
20Syncplay should install this automatically to your user folder.
21
22 === Commands and responses ===
23 = Note: ? denotes optional responses; * denotes mandatory response; uses \n terminator.
24
25 .
26    ? >> inputstate-change: [<input/no-input>]
27    ? >> filepath-change-notification
28
29    * >> playstate: [<playing/paused/no-input>]
30    * >> position: [<decimal seconds/no-input>]
31
32 get-interface-version
33    * >> interface-version: [syncplay connector version]
34
35 get-vlc-version
36    * >> vlc-version: [VLC version]
37
38 get-duration
39    * >> duration: [<duration/no-input>]
40
41 get-filepath
42    * >> filepath: [<filepath/no-input>]
43
44 get-filename
45    * >> filepath: [<filename/no-input>]
46
47 get-title
48    * >> title: [<title/no-input>]
49
50 set-position: [decimal seconds]
51    ? >> play-error: no-input
52
53 seek-within-title: [decimal seconds]
54    ? >> seek-within-title-error: no-input
55
56 set-playstate: [<playing/paused>]
57    ? >> set-playstate-error: no-input
58
59 set-rate: [decimal rate]
60    ? >> set-rate-error: no-input
61
62 set-title
63    ? >> set-title-error: no-input
64
65 display-osd: [placement on screen <center/left/right/top/bottom/top-left/top-right/bottom-left/bottom-right>], [duration in seconds], [message]
66    ? >> display-osd-error: no-input
67
68 display-secondary-osd: [placement on screen <center/left/right/top/bottom/top-left/top-right/bottom-left/bottom-right>], [duration in seconds], [message]
69    ? >> display-secondary-osd-error: no-input
70
71 load-file: [filepath]
72    * >> load-file-attempted
73
74 close-vlc
75
76 [Unknown command]
77    * >> [Unknown command]-error: unknown-command
78
79--]==========================================================================]
80
81local connectorversion = "0.3.5"
82local vlcversion = vlc.misc.version()
83local vlcmajorversion = tonumber(vlcversion:sub(1,1)) -- get the major version of VLC
84local durationdelay = 500000 -- Pause for get_duration command etc for increased reliability (uses microseconds)
85local loopsleepduration = 2500 -- Pause for every event loop (uses microseconds)
86local quitcheckfrequency = 20 -- Check whether VLC has closed every X loops
87
88local host = "localhost"
89local port
90
91local titlemultiplier = 604800 -- One week
92
93local msgterminator = "\n"
94local msgseperator = ": "
95local argseperator = ", "
96
97local responsemarker = "-response"
98local errormarker = "-error"
99local notificationmarker = "-notification"
100
101local noinput = "no-input"
102local notimplemented = "not-implemented"
103local unknowncommand = "unknown-command"
104local unknownstream = "(Unknown Stream)"
105
106local oldfilepath
107local oldinputstate
108local newfilepath
109local newinputstate
110local oldtitle = 0
111local newtitle = 0
112
113local channel1
114local channel2
115local l
116
117local running = true
118
119
120function radixsafe_tonumber(str)
121    -- Version of tonumber that works with any radix character (but not thousand seperators)
122    -- Based on the public domain VLC common.lua us_tonumber() function
123
124    str = string.gsub(tostring(str), "[^0-9]", ".")
125    local s, i, d = string.match(str, "^([+-]?)(%d*)%.?(%d*)$")
126    if not s or not i or not d then
127        return nil
128    end
129
130    if s == "-" then
131        s = -1
132    else
133        s = 1
134    end
135    if i == "" then
136        i = "0"
137    end
138    if d == nil or d == "" then
139        d = "0"
140    end
141    return s * (tonumber(i) + tonumber(d)/(10^string.len(d)))
142end
143
144-- Start hosting Syncplay interface.
145
146port = radixsafe_tonumber(config["port"])
147if (port == nil or port < 1) then port = 4123 end
148
149function quit_vlc()
150    running = false
151    vlc.misc.quit()
152end
153
154function detectchanges()
155    -- Detects changes in VLC to report to Syncplay.
156    -- [Used by the poll / "." command]
157
158    local notificationbuffer = ""
159
160        if vlc.object.input() then
161            newinputstate = "input"
162            newfilepath = get_filepath()
163
164            if newfilepath ~= oldfilepath and get_filepath() ~= unknownstream then
165                oldfilepath = newfilepath
166                notificationbuffer = notificationbuffer .. "filepath-change"..notificationmarker..msgterminator
167            end
168
169            local titleerror
170            newtitle, titleerror = get_var("title", 0)
171            if newtitle ~= oldtitle and get_var("time", 0) > 1 then
172                vlc.misc.mwait(vlc.misc.mdate() + durationdelay) -- Don't give new title with old time
173            end
174            oldtitle = newtitle
175            notificationbuffer = notificationbuffer .. "playstate"..msgseperator..tostring(get_play_state())..msgterminator
176            notificationbuffer = notificationbuffer .. "position"..msgseperator..tostring(get_time())..msgterminator
177        else
178            notificationbuffer = notificationbuffer .. "playstate"..msgseperator..noinput..msgterminator
179            notificationbuffer = notificationbuffer .. "position"..msgseperator..noinput..msgterminator
180            newinputstate = noinput
181        end
182
183        if newinputstate ~= oldinputstate then
184            oldinputstate = newinputstate
185            notificationbuffer = notificationbuffer.."inputstate-change"..msgseperator..tostring(newinputstate)..msgterminator
186        end
187
188    return notificationbuffer
189end
190
191function get_args (argument, argcount)
192    -- Converts comma-space-seperated values into array of a given size, with last item absorbing all remaining data if needed.
193    -- [Used by the display-osd command]
194
195    local argarray = {}
196    local index
197    local i
198    local argbuffer
199
200    argbuffer = argument
201
202    for i = 1, argcount,1 do
203        if i == argcount  then
204            if argbuffer == nil then
205                argarray[i] = ""
206            else
207                argarray[i] = argbuffer
208            end
209        else
210            if string.find(argbuffer, argseperator) then
211                index = string.find(argbuffer, argseperator)
212                argarray[i] = string.sub(argbuffer, 0, index - 1)
213                argbuffer = string.sub(argbuffer, index + string.len(argseperator))
214            else
215                argarray[i] = ""
216            end
217        end
218
219    end
220
221    return argarray
222
223end
224
225
226function get_var( vartoget, fallbackvar )
227    -- [Used by the poll / '.' command to get time]
228
229    local response
230    local errormsg
231    local input = vlc.object.input()
232
233    if input then
234        response = vlc.var.get(input,tostring(vartoget))
235    else
236        response = fallbackvar
237        errormsg = noinput
238    end
239
240    if vlcmajorversion > 2 and vartoget == "time" then
241        response = response / 1000000
242    end
243
244    return response, errormsg
245end
246
247
248function set_var(vartoset, varvalue)
249    -- [Used by the set-time and set-rate commands]
250
251    local errormsg
252    local input = vlc.object.input()
253
254    if vlcmajorversion > 2 and vartoset == "time" then
255        varvalue = varvalue * 1000000
256    end
257
258    if input then
259        vlc.var.set(input,tostring(vartoset),varvalue)
260    else
261        errormsg = noinput
262    end
263
264    return  errormsg
265end
266
267function get_time()
268    local realtime, errormsg, longtime, title, titletime
269    realtime, errormsg = get_var("time", 0) -- Seconds
270    if errormsg ~= nil and errormsg ~= "" then
271        return errormsg
272    end
273
274    title = get_var("title", 0)
275
276    if errormsg ~= nil and errormsg ~= "" then
277        return realtime
278    end
279    titletime = title * titlemultiplier -- weeks
280    longtime = titletime + realtime
281    return longtime
282end
283
284function set_time ( timetoset)
285    local input = vlc.object.input()
286    if input then
287        local response, errormsg, realtime, titletrack
288        realtime = timetoset % titlemultiplier
289        oldtitle = radixsafe_tonumber(get_var("title", 0))
290        newtitle = (timetoset - realtime) / titlemultiplier
291        if oldtitle ~= newtitle and newtitle > -1 then
292            set_var("title", radixsafe_tonumber(newtitle))
293        end
294        errormsg = set_var("time", radixsafe_tonumber(realtime))
295        return errormsg
296    else
297        return noinput
298    end
299end
300
301function get_play_state()
302    -- [Used by the get-playstate command]
303
304    local response
305    local errormsg
306    local input = vlc.object.input()
307
308        if input then
309            response = vlc.playlist.status()
310        else
311            errormsg = noinput
312        end
313
314    return response, errormsg
315
316end
317
318function get_filepath ()
319    -- [Used by get-filepath command]
320
321    local response
322    local errormsg
323    local item
324    local input = vlc.object.input()
325
326        if input then
327            local item = vlc.input.item()
328            if item then
329                if string.find(item:uri(),"file://") then
330                     response = vlc.strings.decode_uri(item:uri())
331                elseif string.find(item:uri(),"dvd://") or string.find(item:uri(),"simpledvd://") then
332                     response = ":::DVD:::"
333                else
334                     local metas = item:metas()
335                     if metas and metas["url"] and string.len(metas["url"]) > 0 then
336                          response = metas["url"]
337                     elseif item:uri() and string.len(item:uri()) > 0 then
338                          response = item:uri()
339                     else
340                          response = unknownstream
341                     end
342                end
343            else
344                errormsg = noinput
345            end
346        else
347            errormsg = noinput
348        end
349
350    return response, errormsg
351end
352
353function get_filename ()
354    -- [Used by get-filename command]
355
356    local response
357    local index
358    local filename
359    filename = errormerge(get_filepath())
360    if filename == unknownstream then
361        return unknownstream
362    end
363    if filename == "" then
364        local input = vlc.object.input()
365        if input then
366            local item = vlc.input.item()
367            if item then
368                if item.name then
369                    response = ":::("..item.title..")"
370                    return response
371                end
372            end
373        end
374    end
375
376    if(filename ~= nil) and (filename ~= "") and (filename ~= noinput) then
377        index = string.len(tostring(string.match(filename, ".*/")))
378        if string.sub(filename,1,3) == ":::" then
379            return filename
380        elseif index then
381            response = string.sub(tostring(filename), index+1)
382        end
383    else
384          response = noinput
385    end
386
387    return response
388end
389
390function get_duration ()
391    -- [Used by get-duration command]
392
393    local response
394    local errormsg
395    local item
396    local input = vlc.object.input()
397
398        if input then
399            local item = vlc.input.item()
400            -- Try to get duration, which might not be available straight away
401            local i = 0
402            response = 0
403            repeat
404                vlc.misc.mwait(vlc.misc.mdate() + durationdelay)
405                if item and item:duration() then
406                    response = item:duration()
407                    if response < 1 then
408                        response = 0
409                    elseif string.sub(vlcversion,1,5) == "3.0.0" and response > 2147 and math.abs(response-(vlc.var.get(input,"length")/1000000)) > 5 then
410                        errormsg = "invalid-32-bit-value"
411                    end
412                end
413                i = i + 1
414            until response > 1 or i > 5
415        else
416            errormsg = noinput
417        end
418
419    return response, errormsg
420end
421
422
423function display_osd ( argument )
424    -- [Used by display-osd command]
425    local errormsg
426    local osdarray
427    local input = vlc.object.input()
428    if input and vlc.osd and vlc.object.vout() then
429        if not channel1 then
430            channel1 = vlc.osd.channel_register()
431        end
432        if not channel2 then
433            channel2 = vlc.osd.channel_register()
434        end
435        osdarray = get_args(argument,3)
436        --position, duration, message -> message, , position, duration (converted from seconds to microseconds)
437        local osdduration = radixsafe_tonumber(osdarray[2]) * 1000 * 1000
438        vlc.osd.message(osdarray[3],channel1,osdarray[1],osdduration)
439    else
440        errormsg = noinput
441    end
442    return errormsg
443end
444
445function display_secondary_osd ( argument )
446    -- [Used by display-secondary-osd command]
447    local errormsg
448    local osdarray
449    local input = vlc.object.input()
450    if input and vlc.osd and vlc.object.vout() then
451        if not channel1 then
452            channel1 = vlc.osd.channel_register()
453        end
454        if not channel2 then
455            channel2 = vlc.osd.channel_register()
456        end
457        osdarray = get_args(argument,3)
458        --position, duration, message -> message, , position, duration (converted from seconds to microseconds)
459        local osdduration = radixsafe_tonumber(osdarray[2]) * 1000 * 1000
460        vlc.osd.message(osdarray[3],channel2,osdarray[1],osdduration)
461    else
462        errormsg = noinput
463    end
464    return errormsg
465end
466
467function load_file (filepath)
468    -- [Used by load-file command]
469
470    local uri = vlc.strings.make_uri(filepath)
471    vlc.playlist.add({{path=uri}})
472    return "load-file-attempted\n"
473end
474
475function do_command ( command, argument)
476    -- Processes all commands sent by Syncplay (see protocol, above).
477
478    if command == "." then
479        do return detectchanges() end
480    end
481    local command = tostring(command)
482    local argument = tostring(argument)
483    local errormsg = ""
484    local response = ""
485
486    if     command == "get-interface-version" then response           = "interface-version"..msgseperator..connectorversion..msgterminator
487    elseif command == "get-vlc-version"       then response           = "vlc-version"..msgseperator..vlcversion..msgterminator
488    elseif command == "get-duration"          then response           = "duration"..msgseperator..errormerge(get_duration())..msgterminator
489    elseif command == "get-filepath"          then response           = "filepath"..msgseperator..errormerge(get_filepath())..msgterminator
490    elseif command == "get-filename"          then response           = "filename"..msgseperator..errormerge(get_filename())..msgterminator
491    elseif command == "get-title"             then response           = "title"..msgseperator..errormerge(get_var("title", 0))..msgterminator
492    elseif command == "set-position"          then           errormsg = set_time(radixsafe_tonumber(argument))
493    elseif command == "seek-within-title"     then           errormsg = set_var("time", radixsafe_tonumber(argument))
494    elseif command == "set-playstate"         then           errormsg = set_playstate(argument)
495    elseif command == "set-rate"              then           errormsg = set_var("rate", radixsafe_tonumber(argument))
496    elseif command == "set-title"             then           errormsg = set_var("title", radixsafe_tonumber(argument))
497    elseif command == "display-osd"           then           errormsg = display_osd(argument)
498	elseif command == "display-secondary-osd" then           errormsg = display_secondary_osd(argument)
499    elseif command == "load-file"             then response           = load_file(argument)
500    elseif command == "close-vlc"             then                      quit_vlc()
501    else                                                     errormsg = unknowncommand
502    end
503
504    if (errormsg ~= nil) and (errormsg ~= "") then
505        response = command..errormarker..msgseperator..tostring(errormsg)..msgterminator
506    end
507
508    return response
509
510end
511
512function errormerge(argument, errormsg)
513    -- Used to integrate 'no-input' error messages into command responses.
514
515    if (errormsg ~= nil) and (errormsg ~= "") then
516        do return errormsg end
517    end
518
519    return argument
520end
521
522function set_playstate(argument)
523    -- [Used by the set-playstate command]
524
525    local errormsg
526    local input = vlc.object.input()
527    local playstate
528    playstate, errormsg = get_play_state()
529
530    if playstate ~= "playing" then playstate =    "paused" end
531    if ((errormsg ~= noinput) and (playstate ~= argument)) then
532        vlc.playlist.pause()
533    end
534
535    return errormsg
536end
537
538if string.sub(vlcversion,1,2) == "1." or string.sub(vlcversion,1,3) == "2.0" or string.sub(vlcversion,1,3) == "2.1" or string.sub(vlcversion,1,5) == "2.2.0" then
539    vlc.msg.err("This version of VLC does not support Syncplay. Please use VLC 2.2.1+ or an alternative media player.")
540    quit_vlc()
541else
542    l = vlc.net.listen_tcp(host, port)
543    vlc.msg.info("Hosting Syncplay interface on port: "..port)
544end
545
546    -- main loop, which alternates between writing and reading
547
548while running == true do
549    --accept new connections and select active clients
550    local quitcheckcounter = 0
551	local fd = l:accept()
552    local buffer, inputbuffer, responsebuffer = "", "", ""
553    while fd >= 0 and running == true do
554
555        -- handle read mode
556
557        local str = vlc.net.recv ( fd, 1000)
558
559        local responsebuffer
560        if str == nil then str = "" end
561
562        local safestr = string.gsub(tostring(str), "\r", "")
563        if inputbuffer == nil then inputbuffer = "" end
564
565        inputbuffer = inputbuffer .. safestr
566
567        while string.find(inputbuffer, msgterminator) and running == true do
568            local index = string.find(inputbuffer, msgterminator)
569            local request = string.sub(inputbuffer, 0, index - 1)
570            local command
571            local argument
572            inputbuffer = string.sub(inputbuffer, index + string.len(msgterminator))
573
574            if (string.find(request, msgseperator)) then
575                index = string.find(request, msgseperator)
576                command = string.sub(request, 0, index - 1)
577                argument = string.sub(request, index  + string.len(msgseperator))
578
579            else
580                command = request
581            end
582
583            if (responsebuffer) then
584                responsebuffer = responsebuffer .. do_command(command,argument)
585            else
586                responsebuffer = do_command(command,argument)
587            end
588
589        end
590
591        if (running == false) then
592            net.close(fd)
593        end
594
595        -- handle write mode
596
597        if (responsebuffer and running == true) then
598            vlc.net.send( fd, responsebuffer )
599            responsebuffer = ""
600        end
601        vlc.misc.mwait(vlc.misc.mdate() + loopsleepduration) -- Don't waste processor time
602
603        -- check if VLC has been closed
604
605        quitcheckcounter = quitcheckcounter + 1
606
607        if quitcheckcounter > quitcheckfrequency then
608            if vlc.volume.get() == -256 then
609                running = false
610            end
611            quitcheckcounter = 0
612        end
613
614    end
615
616end