This repository has been archived by the owner on Jul 20, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathplexv2.py
executable file
·425 lines (359 loc) · 12.1 KB
/
plexv2.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
from handyv2 import TheHandy
from flask import Flask, request, send_file
from xml.dom import minidom
from inspect import currentframe
from plexapi.server import PlexServer
from tempfile import NamedTemporaryFile
import tempfile, time, os, json, shutil, requests, sys, html, re, hashlib, threading
#Classes
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
class ScriptHandler:
"""
Caches scripts for usage
"""
def __init__(self):
self.db = []
self.len = 5
#Keep only 5 latest
def clean(self):
self.db = self.db[-(self.len):]
#Prepare a script for usage
def addScript(self, file_loc, _id, local=False):
fx = get_extless(file_loc)
tmp = NamedTemporaryFile()
csv_path = fx + ".csv"
fun_path = fx + ".funscript"
if (os.path.exists(fun_path)):
self.convert_funscript_to_csv(fun_path, tmp.name)
elif (os.path.exists(csv_path)):
shutil.copyfile(csv_path,tmp.name)
else:
return None
csv_path = tmp.name
http = None
if (local):
print("Uploaded to local server")
http = "http://{}/script/{}".format(settings["access_ip"],_id)
else:
print("Uploaded to handyfeeling server")
http = self.upload_funscript(csv_path)
#TODO: These run out after some hours, account for that
print(f"Script url: {http}")
self.db.append({
"id": _id,
"http": http,
"hash": self.get_digest(csv_path),
"csv": tmp,
"local": local
})
self.clean()
return http
#Return a script (if it exists)
def getScript(self, _id):
for i in self.db:
if i["id"] == _id:
return i["csv"].name,i["http"]
return None,None
#Return if script exists
def hasScript(self, filepath):
fx = get_extless(filepath)
return os.path.exists(fx + ".funscript") or os.path.exists(fx + ".csv")
#Remove a given script
def removeScript(self, _id):
self.db = [i for i in self.db if i["id"] != _id]
def get_digest(self, file_path):
"""
Calculate sha256 hash of given file
"""
h = hashlib.sha256()
with open(file_path, "rb") as file:
while True:
# Reading is buffered, so we can read smaller chunks.
chunk = file.read(h.block_size)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def convert_funscript_to_csv(self,input_file,output_file):
"""
Convert a funscript to csv
"""
with open(input_file) as fr:
jsn = json.load(fr)
with open(output_file, "w") as fw:
fw.write("#Converted to CSV using script_converter.py")
#BUT WHAT ABOUT MUH SETTINGS
#fuckem
for i in jsn["actions"]:
fw.write("{},{}\r\n".format(i["at"],i["pos"]))
def upload_funscript(self, input_file):
"""
Upload a script to handyfeelings hosting api and return the url
Handyfeeling rejects files over 2MB
So we convert to csv per default avoid that
Since it turns 2355KB into 127KB
"""
filename = "".join(re.findall(r"(\w+|\.)", input_file[input_file.rfind("/")+1:])) #|\
multipart_form_data = {
"syncFile": (filename, open(input_file, "rb")),
}
response = requests.post("https://www.handyfeeling.com/api/sync/upload?local=true", files=multipart_form_data)
#TODO: 413 Request entity too large
if (not response.status_code in range(200,299)):
print(f"{bcolors.WARNING}{response.text}\n{response.status_code}{bcolors.ENDC}", file=sys.stderr)
return None
data = json.loads((response.content).decode("utf-8"))
print(f"{bcolors.OKCYAN}handyfeeling {data}{bcolors.ENDC}", file=sys.stderr)
return html.unescape(data["url"])
class HandyDB:
"""
Caches theHandy for usage
"""
def __init__(self):
self.db = {}
#Add a handy instance
def addInstance(self, _id):
self.clean()
hnd = TheHandy()
hnd.onReady(settings["handy_key"])
self.db[_id] = {
"time": time.time(),
"handy": hnd,
"video": None
}
return hnd
#Yeah this sucks, get a small wrapper class, or anything else, please
def getHandy(self, _id):
self.db[_id]["time"] = time.time()
return self.db[_id]["handy"]
def getVideo(self, _id):
self.db[_id]["time"] = time.time()
return self.db[_id]["video"]
def setVideo(self, _id, data):
self.db[_id]["time"] = time.time()
self.db[_id]["video"] = data
def hasInstance(self, _id):
self.clean()
return len([i for i in self.db if i == _id]) > 0
def clean(self):
for _id in self.db:
if ((time.time() - self.db[_id]["time"]) / 60 / 60 > settings["timeout"]):
del self.db[_id]
#Support functions
def get_extless(input_file):
file_name, file_extension = os.path.splitext(input_file)
return file_name
#Return video file path
def plex_getvideofile(video_key):
data_url = "http://{}{}?X-Plex-Token={}".format(settings["plex_ip"], video_key, settings["plex_token"])
with requests.get(data_url) as r: #BUG: KeyError on non-video
with minidom.parseString(r.text) as xmldoc:
#print(f"{bcolors.OKCYAN}Plex video data {xmldoc.toprettyxml()}{bcolors.ENDC}")
part = xmldoc.getElementsByTagName("Part")
if (len(part) > 0):
return part[0].attributes["file"].value
return None
#Return viewOffset in ms
def plex_gettime_old(player_uuid):
when = time.time()
data_url = "http://{}/status/sessions?X-Plex-Token={}".format(settings["plex_ip"], settings["plex_token"])
with requests.get(data_url) as r:
with minidom.parseString(r.text) as xmldoc:
#print(f"{bcolors.OKCYAN}Plex session data {xmldoc.toprettyxml()}{bcolors.ENDC}")
for i in xmldoc.getElementsByTagName("Video"):
for playerelm in i.getElementsByTagName("Player"):
if playerelm.attributes["machineIdentifier"].value == player_uuid:
#if (playerelm.attributes["product"].value != "DLNA"):
return int(i.attributes["viewOffset"].value) + int(time.time() - when)
return None
#Return if the player is on the same network as the server (that we assume the script is running on aswell)
def plex_islocal(player_uuid):
for session in plex.sessions():
for player in session.players:
if (player.machineIdentifier == player_uuid):
if player.product == "DLNA":
return "DLNA"
return player.local
return None
app = Flask(__name__)
#Default settings
settings = {
"app_secret": "REPLACE_ME",
"plex_token": "REPLACE_ME",
"handy_key": "REPLACE_ME",
"plex_ip": "127.0.0.1:32400",
"access_ip": "REPLACE_ME",
"view_offset": 50,
"timeout": 2,
"pause_sync": False
}
#If there are no default settings
if not os.path.exists("settings.json"):
with open("settings.json", "w") as f:
json.dump(settings, f, indent="\t")
print(f"{bcolors.WARNING}Please edit settings.json!{bcolors.ENDC}")
sys.exit(0)
#Load user settings
with open("settings.json", "r+") as f:
#Append settings with user specified settings
settings.update(json.load(f))
#Detect invalid values
if ("REPLACE_ME" in [
settings["plex_token"],
settings["handy_key"],
settings["plex_ip"],
]):
print(f"{bcolors.WARNING}Please edit settings.json!{bcolors.ENDC}")
sys.exit(1) #Its an error this time
#Update settings (if any new ones are present)
f.seek(0)
json.dump(settings, f, indent="\t")
f.truncate()
app.secret_key = settings["app_secret"]
#Nice api
plex = PlexServer("http://" + settings["plex_ip"],settings["plex_token"])
script_db = ScriptHandler()
handy_db = HandyDB()
@app.route("/script/<name>", methods=["GET"])
def script_dir(name):
"""
Returns local scripts stored for local usage
"""
script_path, script_http = script_db.getScript(name)
assert os.path.exists(script_path)
return send_file(script_path)
class PlexDelay:
def __init__(self):
self.isRunning = False
self.calculated = False
self.report_delay = 0
self.catched = False
def shouldCatch(self):
return self.isRunning
def hasCalculated(self):
return self.calculated
def _auxRun(self, player_uuid):
client = [i for i in plex.clients() if i.machineIdentifier == player_uuid][0]
#TODO: Check for error (len <= 0)
times = []
if True:
#Get report delay
for i in range(30):
when = time.time() #In seconds
if (i % 2 == 0):
client.pause()
else:
client.play()
rtt = (time.time() - when) / 2
while (not self.catched): #Busy wait
pass
self.catched = False
offset = when - rtt
offset = (time.time() - offset) * 1000 #In MS
#TODO: If less than 0, wait (some leftover event is firing)
print(f"Report sync: (num, rtt): {i} {offset}")
times.append(offset)
time.sleep(0.15)
self.report_delay = sum(times) / len(times)
self.catched = False
self.report_delay = max(self.report_delay, 0)
print(f"Report delay: {self.report_delay}")
self.calculated=True
def totalDelay(self):
return int(self.report_delay)
def run(self, player_uuid):
if (not settings["pause_sync"] or self.calculated):
return
self.isRunning = True
self._auxRun(player_uuid)
self.isRunning = False
def catch(self, event_type):
if (event_type in ["media.resume", "media.play", "media.pause"]):
self.catched=True
delay_c = PlexDelay()
@app.route("/", methods=["POST"])
def index():
global delay_c
"""
Receives a Plex event and handles accordingly
"""
#Video data
parsed = json.loads(request.form["payload"])
#Player device unique id
player_uuid = parsed["Player"]["uuid"]
#Video unique id
video_uuid = parsed["Metadata"]["ratingKey"]
#Video url
video_url = parsed["Metadata"]["key"]
#Event type
event_type = parsed["event"]
if (delay_c.shouldCatch()):
delay_c.catch(event_type)
return "OK"
#Plex data
json_data = json.dumps(parsed, indent='\t')
print(f"{bcolors.OKCYAN}Plex json data {json_data}{bcolors.ENDC}")
print(f"{bcolors.OKCYAN}Plex event type {event_type}{bcolors.ENDC}")
#Any play event
if (event_type in ["media.resume", "media.play"]):
#Video file exists
video_file = plex_getvideofile(video_url)
if (video_file != None):
print("Video File: ", video_file)
#Script exists
script_path, script_http = script_db.getScript(video_uuid)
if (script_http == None):
#If we run over DLNA, we have a hard time knowing the viewOffset
#So we ignore DLNA clients
isLocal = plex_islocal(player_uuid)
if (isLocal == "DLNA"):
print("Ignoring DLNA...")
return "OK"
#Uploaded the given script
script_http = script_db.addScript(video_file, video_uuid, local=isLocal)
#Script exists
if (script_http != None):
print("Script HTTP: ", script_http)
#If we dont have an instance of theHandy or the instance got broken
if (not handy_db.hasInstance(player_uuid) or not handy_db.getHandy(player_uuid).isReady()):
#Create new instance
handy_db.addInstance(player_uuid)
delay_c.calculated = False
delay_c.run(player_uuid)
if (settings["view_offset"]) != 0:
print("setOffset", handy_db.getHandy(player_uuid).setOffset(settings["view_offset"]))
#If its a different video than last time, initialize
if (handy_db.getVideo(player_uuid) != video_uuid):
#Send the script to theHandy
print("setScript", handy_db.getHandy(player_uuid).setScript(script_http))
#Set current video playing in our db
handy_db.setVideo(player_uuid, video_uuid)
#If handy exists
if handy_db.hasInstance(player_uuid):
#And has same video (we didnt switch from scripted to non-scripted)
if (handy_db.getVideo(player_uuid) == video_uuid):
#Get viewOffset and play from there
viewOffset = plex_gettime_old(player_uuid)
if (delay_c.calculated):
viewOffset += delay_c.totalDelay()
print("onPlay", handy_db.getHandy(player_uuid).onPlay(viewOffset))
#If handy exists
if handy_db.hasInstance(player_uuid):
#If video is paused
if (event_type in ["media.pause", "media.stop"]):
print("onPause", handy_db.getHandy(player_uuid).onPause())
#If video is stopped
if (event_type in ["media.stop"]):
script_db.removeScript(video_uuid)
return "OK"
if __name__ == "__main__":
app.run(host="0.0.0.0",port=8008)