Merge branch 'master' of git.dolda2000.com:/srv/git/r/utils
[utils.git] / mpsync
1 #!/usr/bin/python3
2
3 import os, sys, getopt, io, termios
4 import socket, json, pprint
5
6 class key(object):
7     def __init__(self, nm):
8         self.nm = nm
9     def __repr__(self):
10         return "<key %s>" % (self.nm,)
11 class akey(key):
12     def __init__(self, cc):
13         super().__init__(repr(cc))
14         self.cc = cc
15     def __eq__(self, o):
16         return self.cc == o
17
18 class rawtty(object):
19     K_LEFT = key("K_LEFT")
20     K_RIGHT = key("K_RIGHT")
21     K_UP = key("K_UP")
22     K_DOWN = key("K_DOWN")
23     MOD_SHIFT = key("MOD_SHIFT")
24     MOD_META = key("MOD_META")
25     MOD_CTRL = key("MOD_CTRL")
26
27     def __init__(self, *, path="/dev/tty"):
28         self.io = io.FileIO(os.open(path, os.O_RDWR | os.O_NOCTTY), "r+")
29         attr = termios.tcgetattr(self.io.fileno())
30         self.bka = list(attr)
31         attr[3] &= ~termios.ECHO & ~termios.ICANON
32         termios.tcsetattr(self.io.fileno(), termios.TCSANOW, attr)
33
34     def getc(self):
35         b = self.io.read(1)
36         return None if b == b"" else b[0]
37
38     _csikeys = {'A': K_UP, 'B': K_DOWN, 'C': K_RIGHT, 'D': K_LEFT}
39     def readkey(self):
40         c = self.getc()
41         if c == 27:
42             c = self.getc()
43             if c == 27:
44                 return akey("\x1b"), set()
45             elif c == ord('O'):
46                 return None, set()
47             elif c == ord('['):
48                 pars = []
49                 par = None
50                 while True:
51                     c = self.getc()
52                     if 48 <= c <= 57:
53                         if par is None:
54                             par = 0
55                         par = (par * 10) + (c - 48)
56                     elif c == ord(';'):
57                         pars.append(par)
58                         par = None
59                     else:
60                         if par is not None:
61                             pars.append(par)
62                         break
63                 if c == ord('~'):
64                     key = None
65                 elif chr(c) in self._csikeys:
66                     key = self._csikeys[chr(c)]
67                 else:
68                     key = None
69                 mods = set()
70                 if len(pars) > 1:
71                     if (pars[1] - 1) & 1:
72                         mods.add(self.MOD_SHIFT)
73                     if (pars[1] - 1) & 2:
74                         mods.add(self.MOD_META)
75                     if (pars[1] - 1) & 4:
76                         mods.add(self.MOD_CTRL)
77                 return key, mods
78             else:
79                 return akey(chr(c)), [self.MOD_META]
80         else:
81             return akey(chr(c)), set()
82
83     def close(self):
84         termios.tcsetattr(self.io.fileno(), termios.TCSANOW, self.bka)
85         self.io.close()
86
87     def __enter__(self):
88         return self
89
90     def __exit__(self, *exc):
91         self.close()
92         return False
93
94 class target(object):
95     def __init__(self, path):
96         self.path = path
97         self.sk = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
98         self.sk.connect(path)
99         self.obuf = bytearray()
100         self.ibuf = bytearray()
101
102     def write(self, data):
103         self.obuf.extend(data)
104
105     def flush(self):
106         while len(self.obuf) > 0:
107             ret = self.sk.send(self.obuf)
108             self.obuf[:ret] = b""
109
110     def recv(self, hint=1024):
111         data = self.sk.recv(hint)
112         if data == b"":
113             raise EOFError()
114         self.ibuf.extend(data)
115
116     def readline(self):
117         p = 0
118         while True:
119             p2 = self.ibuf.find(b'\n', p)
120             if p2 != -1:
121                 ret = bytes(self.ibuf[:p2])
122                 self.ibuf[:p2 + 1] = b""
123                 return ret
124             p = len(self.ibuf)
125             self.recv()
126
127     def send(self, data):
128         self.write(data)
129         self.flush()
130
131     def getresp(self):
132         while True:
133             resp = json.loads(self.readline().decode("utf-8"))
134             if "event" in resp:
135                 continue
136             return resp
137
138     def runcmd(self, *cmd):
139         self.send(json.dumps({"command": cmd}).encode("utf-8") + b"\n")
140         resp = self.getresp()
141         if "error" not in resp:
142             sys.stderr.write("mpsync: strange response from %s: %r\n" % (self.path, resp))
143         if resp["error"] != "success":
144             sys.stderr.write("mpsync: error response from %s: %r\n" % (self.path, resp))
145         return resp
146
147     def getprop(self, pname):
148         return self.runcmd("get_property", pname)["data"]
149
150     def setprop(self, pname, val):
151         self.runcmd("set_property", pname, val)
152
153 def usage(out):
154     out.write("usage: mpsync [-h] SOCKET...\n")
155     out.write("players: mpv -input-unix-socket SOCKET -pause [ARGS...] FILE\n")
156
157 opts, args = getopt.getopt(sys.argv[1:], "h")
158 for o, a in opts:
159     if o == "-h":
160         usage(sys.stdout)
161         sys.exit(0)
162 if len(args) < 1:
163     usage(sys.stderr)
164     sys.exit(1)
165 for path in args:
166     if not os.path.exists(path):
167         sys.stderr.write("mpsync: %s: no such file or directory\n" % path)
168         sys.exit(1)
169
170 targets = []
171 for path in args:
172     targets.append(target(path))
173
174 def runcmd(*cmd):
175     for tgt in targets:
176         tgt.runcmd(*cmd)
177
178 def simulcmd(*cmd):
179     cmd = json.dumps({"command": cmd}).encode("utf-8") + b"\n"
180     for tgt in targets:
181         tgt.send(cmd)
182     for tgt in targets:
183         resp = tgt.getresp()
184         if "error" not in resp:
185             sys.stderr.write("mpsync: strange response from %s: %r\n" % (tgt.path, resp))
186         if resp["error"] != "success":
187             sys.stderr.write("mpsync: error response from %s: %r\n" % (tgt.path, resp))
188
189 def relseek(ss, offsets):
190     cur = targets[0].getprop("time-pos")
191     for tgt, off in zip(targets, offsets):
192         tgt.setprop("time-pos", cur + ss + off)
193
194 def getoffsets():
195     opos = targets[0].getprop("time-pos")
196     ret = []
197     for tgt in targets:
198         ret.append(tgt.getprop("time-pos") - opos)
199     return ret
200
201 def main(tty):
202     runcmd("set_property", "hr-seek", "yes")
203     paused = targets[0].getprop("pause")
204     mutemode = 0
205     offsets = [0.0] * len(targets)
206     while True:
207         c, mods = tty.readkey()
208         if c == 'q':
209             return
210         elif c == 'Q':
211             runcmd("quit")
212             return
213         elif c == ' ':
214             paused = not paused
215             simulcmd("set_property", "pause", paused)
216         elif c == rawtty.K_LEFT and not mods:
217             relseek(-10, offsets)
218         elif c == rawtty.K_RIGHT and not mods:
219             relseek(10, offsets)
220         elif c == rawtty.K_UP and not mods:
221             relseek(60, offsets)
222         elif c == rawtty.K_DOWN and not mods:
223             relseek(-60, offsets)
224         elif c == rawtty.K_LEFT and mods == {rawtty.MOD_SHIFT}:
225             relseek(-2, offsets)
226         elif c == rawtty.K_RIGHT and mods == {rawtty.MOD_SHIFT}:
227             relseek(2, offsets)
228         elif c == rawtty.K_UP and mods == {rawtty.MOD_SHIFT}:
229             relseek(5, offsets)
230         elif c == rawtty.K_DOWN and mods == {rawtty.MOD_SHIFT}:
231             relseek(-5, offsets)
232         elif c == 's':
233             relseek(0, offsets)
234         elif c == 'm':
235             mutemode = (mutemode + 1) % 3
236             if mutemode == 0:
237                 for tgt in targets:
238                     tgt.setprop("mute", False)
239             elif mutemode == 1:
240                 for i, tgt in enumerate(targets):
241                     tgt.setprop("mute", i != 0)
242             elif mutemode == 2:
243                 for tgt in targets:
244                     tgt.setprop("mute", True)
245             targets[0].runcmd("show_text", "Audio mode: %s" % ["All", "One", "None"][mutemode])
246         elif c == 'S':
247             offsets = getoffsets()
248             for tgt, off in zip(targets, offsets):
249                 tgt.runcmd("show_text", "Offset: %f" % off)
250         elif c == '.':
251             runcmd("frame_step")
252             paused = True
253         elif c == ',':
254             runcmd("frame_back_step")
255             paused = True
256
257 with rawtty() as tty:
258     try:
259         main(tty)
260     except KeyboardInterrupt:
261         pass