@scrypted/server 0.94.2 → 0.94.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrypted/server",
3
- "version": "0.94.2",
3
+ "version": "0.94.4",
4
4
  "description": "",
5
5
  "dependencies": {
6
6
  "@mapbox/node-pre-gyp": "^1.0.11",
@@ -0,0 +1,101 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ from typing import Any
5
+ import shutil
6
+
7
+ def get_requirements_files(requirements: str):
8
+ want_requirements = requirements + '.txt'
9
+ installed_requirementstxt = requirements + '.installed.txt'
10
+ return want_requirements, installed_requirementstxt
11
+
12
+ def need_requirements(requirements_basename: str, requirements_str: str):
13
+ _, installed_requirementstxt = get_requirements_files(requirements_basename)
14
+ if not os.path.exists(installed_requirementstxt):
15
+ return True
16
+ try:
17
+ f = open(installed_requirementstxt, "rb")
18
+ installed_requirements = f.read().decode('utf8')
19
+ return requirements_str != installed_requirements
20
+ except:
21
+ return True
22
+
23
+ def remove_pip_dirs(plugin_volume: str):
24
+ try:
25
+ for de in os.listdir(plugin_volume):
26
+ if (
27
+ de.startswith("linux")
28
+ or de.startswith("darwin")
29
+ or de.startswith("win32")
30
+ or de.startswith("python")
31
+ or de.startswith("node")
32
+ ):
33
+ filePath = os.path.join(plugin_volume, de)
34
+ print("Removing old dependencies: %s" % filePath)
35
+ try:
36
+ shutil.rmtree(filePath)
37
+ except:
38
+ pass
39
+ except:
40
+ pass
41
+
42
+
43
+ def install_with_pip(
44
+ python_prefix: str,
45
+ packageJson: Any,
46
+ requirements_str: str,
47
+ requirements_basename: str,
48
+ ignore_error: bool = False,
49
+ ):
50
+ requirementstxt, installed_requirementstxt = get_requirements_files(requirements_basename)
51
+
52
+ os.makedirs(python_prefix, exist_ok=True)
53
+
54
+ print(f"{os.path.basename(requirementstxt)} (outdated)")
55
+ print(requirements_str)
56
+
57
+ f = open(requirementstxt, "wb")
58
+ f.write(requirements_str.encode())
59
+ f.close()
60
+
61
+ try:
62
+ pythonVersion = packageJson["scrypted"]["pythonVersion"]
63
+ except:
64
+ pythonVersion = None
65
+
66
+ pipArgs = [
67
+ sys.executable,
68
+ "-m",
69
+ "pip",
70
+ "install",
71
+ "-r",
72
+ requirementstxt,
73
+ "--prefix",
74
+ python_prefix,
75
+ ]
76
+ if pythonVersion:
77
+ print("Specific Python version requested. Forcing reinstall.")
78
+ # prevent uninstalling system packages.
79
+ pipArgs.append("--ignore-installed")
80
+ # force reinstall even if it exists in system packages.
81
+ pipArgs.append("--force-reinstall")
82
+
83
+ p = subprocess.Popen(pipArgs, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
84
+
85
+ while True:
86
+ line = p.stdout.readline()
87
+ if not line:
88
+ break
89
+ line = line.decode("utf8").rstrip("\r\n")
90
+ print(line)
91
+ result = p.wait()
92
+ print("pip install result %s" % result)
93
+ if result:
94
+ if not ignore_error:
95
+ raise Exception("non-zero result from pip %s" % result)
96
+ else:
97
+ print("ignoring non-zero result from pip %s" % result)
98
+ else:
99
+ f = open(installed_requirementstxt, "wb")
100
+ f.write(requirements_str.encode())
101
+ f.close()
@@ -6,7 +6,7 @@ import gc
6
6
  import os
7
7
  import platform
8
8
  import shutil
9
- import subprocess
9
+ from plugin_pip import install_with_pip, remove_pip_dirs, need_requirements
10
10
  import sys
11
11
  import threading
12
12
  import time
@@ -41,6 +41,12 @@ import rpc
41
41
  import rpc_reader
42
42
 
43
43
 
44
+ OPTIONAL_REQUIREMENTS = """
45
+ ptpython
46
+ """.strip()
47
+
48
+
49
+
44
50
  class ClusterObject(TypedDict):
45
51
  id: str
46
52
  port: int
@@ -337,6 +343,7 @@ class PluginRemote:
337
343
  self.pluginId = pluginId
338
344
  self.hostInfo = hostInfo
339
345
  self.loop = loop
346
+ self.replPort = None
340
347
  self.__dict__['__proxy_oneway_methods'] = [
341
348
  'notify',
342
349
  'updateDeviceState',
@@ -567,81 +574,28 @@ class PluginRemote:
567
574
  if not os.path.exists(python_prefix):
568
575
  os.makedirs(python_prefix)
569
576
 
577
+ str_requirements = ""
570
578
  if 'requirements.txt' in zip.namelist():
571
- requirements = zip.open('requirements.txt').read()
572
- str_requirements = requirements.decode('utf8')
573
-
574
- requirementstxt = os.path.join(
575
- python_prefix, 'requirements.txt')
576
- installed_requirementstxt = os.path.join(
577
- python_prefix, 'requirements.installed.txt')
578
-
579
- need_pip = True
580
- try:
581
- existing = open(installed_requirementstxt).read()
582
- need_pip = existing != str_requirements
583
- except:
584
- pass
585
-
586
- if need_pip:
587
- try:
588
- for de in os.listdir(plugin_volume):
589
- if de.startswith('linux') or de.startswith('darwin') or de.startswith('win32') or de.startswith('python') or de.startswith('node'):
590
- filePath = os.path.join(plugin_volume, de)
591
- print('Removing old dependencies: %s' %
592
- filePath)
593
- try:
594
- shutil.rmtree(filePath)
595
- except:
596
- pass
597
- except:
598
- pass
599
-
600
- os.makedirs(python_prefix)
601
-
602
- print('requirements.txt (outdated)')
603
- print(str_requirements)
604
-
605
- f = open(requirementstxt, 'wb')
606
- f.write(requirements)
607
- f.close()
608
-
609
- try:
610
- pythonVersion = packageJson['scrypted']['pythonVersion']
611
- except:
612
- pythonVersion = None
613
-
614
- pipArgs = [
615
- sys.executable,
616
- '-m', 'pip', 'install', '-r', requirementstxt,
617
- '--prefix', python_prefix
618
- ]
619
- if pythonVersion:
620
- print('Specific Python version requested. Forcing reinstall.')
621
- # prevent uninstalling system packages.
622
- pipArgs.append('--ignore-installed')
623
- # force reinstall even if it exists in system packages.
624
- pipArgs.append('--force-reinstall')
625
-
626
- p = subprocess.Popen(pipArgs, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
627
-
628
- while True:
629
- line = p.stdout.readline()
630
- if not line:
631
- break
632
- line = line.decode('utf8').rstrip('\r\n')
633
- print(line)
634
- result = p.wait()
635
- print('pip install result %s' % result)
636
- if result:
637
- raise Exception('non-zero result from pip %s' % result)
638
-
639
- f = open(installed_requirementstxt, 'wb')
640
- f.write(requirements)
641
- f.close()
642
- else:
643
- print('requirements.txt (up to date)')
644
- print(str_requirements)
579
+ str_requirements = zip.open('requirements.txt').read().decode('utf8')
580
+
581
+ optional_requirements_basename = os.path.join(
582
+ python_prefix, 'requirements.optional')
583
+ requirements_basename = os.path.join(
584
+ python_prefix, 'requirements')
585
+
586
+ need_pip = True
587
+ if str_requirements:
588
+ need_pip = need_requirements(requirements_basename, str_requirements)
589
+ if not need_pip:
590
+ need_pip = need_requirements(optional_requirements_basename, OPTIONAL_REQUIREMENTS)
591
+
592
+ if need_pip:
593
+ remove_pip_dirs(plugin_volume)
594
+ install_with_pip(python_prefix, packageJson, OPTIONAL_REQUIREMENTS, optional_requirements_basename, ignore_error=True)
595
+ install_with_pip(python_prefix, packageJson, str_requirements, requirements_basename, ignore_error=False)
596
+ else:
597
+ print('requirements.txt (up to date)')
598
+ print(str_requirements)
645
599
 
646
600
  sys.path.insert(0, zipPath)
647
601
  if platform.system() != 'Windows':
@@ -744,7 +698,14 @@ class PluginRemote:
744
698
 
745
699
  if not forkMain:
746
700
  from main import create_scrypted_plugin # type: ignore
747
- return await rpc.maybe_await(create_scrypted_plugin())
701
+ pluginInstance = await rpc.maybe_await(create_scrypted_plugin())
702
+ try:
703
+ from plugin_repl import createREPLServer
704
+ self.replPort = await createREPLServer(sdk, pluginInstance)
705
+ except Exception as e:
706
+ print(f"Warning: Python REPL cannot be loaded: {e}")
707
+ self.replPort = 0
708
+ return pluginInstance
748
709
 
749
710
  from main import fork # type: ignore
750
711
  forked = await rpc.maybe_await(fork())
@@ -795,7 +756,13 @@ class PluginRemote:
795
756
  pass
796
757
 
797
758
  async def getServicePort(self, name):
798
- pass
759
+ if name == "repl":
760
+ if self.replPort is None:
761
+ raise Exception('REPL unavailable: Plugin not loaded.')
762
+ if self.replPort == 0:
763
+ raise Exception('REPL unavailable: Python REPL not available.')
764
+ return self.replPort
765
+ raise Exception(f'unknown service {name}')
799
766
 
800
767
  async def start_stats_runner(self):
801
768
  update_stats = await self.peer.getParam('updateStats')
@@ -860,6 +827,14 @@ def main(rpcTransport: rpc_reader.RpcTransport):
860
827
 
861
828
 
862
829
  def plugin_main(rpcTransport: rpc_reader.RpcTransport):
830
+ if True:
831
+ main(rpcTransport)
832
+ return
833
+
834
+ # 03/05/2024
835
+ # Not sure why this code below was necessary. I thought it was gstreamer needing to
836
+ # be initialized on the main thread, but that no longer seems to be the case.
837
+
863
838
  # gi import will fail on windows (and posisbly elsewhere)
864
839
  # if it does, try starting without it.
865
840
  try:
@@ -0,0 +1,143 @@
1
+ import asyncio
2
+ import concurrent.futures
3
+ from prompt_toolkit import print_formatted_text
4
+ from prompt_toolkit.contrib.telnet.server import TelnetServer
5
+ from ptpython.repl import embed, PythonRepl
6
+ import socket
7
+ import telnetlib
8
+ import threading
9
+ from typing import List, Dict, Any
10
+
11
+ from scrypted_python.scrypted_sdk import ScryptedStatic, ScryptedDevice
12
+
13
+ from rpc import maybe_await
14
+
15
+
16
+ def configure(repl: PythonRepl) -> None:
17
+ repl.confirm_exit = False
18
+ repl.enable_system_bindings = False
19
+ repl.enable_mouse_support = False
20
+
21
+
22
+ async def createREPLServer(sdk: ScryptedStatic, plugin: ScryptedDevice) -> int:
23
+ deviceManager = sdk.deviceManager
24
+ systemManager = sdk.systemManager
25
+ mediaManager = sdk.mediaManager
26
+
27
+ # Create the proxy server to handle initial control messages
28
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
29
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
30
+ sock.settimeout(None)
31
+ sock.bind(('localhost', 0))
32
+ sock.listen(1)
33
+
34
+ async def start_telnet_repl(future, filter) -> None:
35
+ if filter == "undefined":
36
+ filter = None
37
+
38
+ chain: List[str] = []
39
+ nativeIds: Dict[str, Any] = deviceManager.nativeIds
40
+ reversed: Dict[str, str] = {v.id: k for k, v in nativeIds.items()}
41
+
42
+ while filter is not None:
43
+ id = nativeIds.get(filter).id
44
+ d = systemManager.getDeviceById(id)
45
+ chain.append(filter)
46
+ filter = reversed.get(d.providerId)
47
+
48
+ chain.reverse()
49
+ device = plugin
50
+ for c in chain:
51
+ device = await maybe_await(device.getDevice(c))
52
+
53
+ realDevice = systemManager.getDeviceById(device.id)
54
+
55
+ # Select a free port for the telnet server
56
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
57
+ s.bind(('localhost', 0))
58
+ telnet_port = s.getsockname()[1]
59
+ s.close()
60
+
61
+ async def interact(connection) -> None:
62
+ global_dict = {
63
+ **globals(),
64
+ "print": print_formatted_text,
65
+ "help": lambda *args, **kwargs: print_formatted_text("Help is not available in this environment"),
66
+ }
67
+ locals_dict = {
68
+ "device": device,
69
+ "systemManager": systemManager,
70
+ "deviceManager": deviceManager,
71
+ "mediaManager": mediaManager,
72
+ "sdk": sdk,
73
+ "realDevice": realDevice
74
+ }
75
+ vars_prompt = '\n'.join([f" {k}" for k in locals_dict.keys()])
76
+ banner = f"Python REPL variables:\n{vars_prompt}"
77
+ print_formatted_text(banner)
78
+ await embed(return_asyncio_coroutine=True, globals=global_dict, locals=locals_dict, configure=configure)
79
+
80
+ # Start the REPL server
81
+ telnet_server = TelnetServer(interact=interact, port=telnet_port, enable_cpr=False)
82
+ telnet_server.start()
83
+
84
+ future.set_result(telnet_port)
85
+
86
+ loop = asyncio.get_event_loop()
87
+
88
+ def handle_connection(conn: socket.socket):
89
+ conn.settimeout(None)
90
+ filter = conn.recv(1024).decode()
91
+
92
+ future = concurrent.futures.Future()
93
+ loop.call_soon_threadsafe(loop.create_task, start_telnet_repl(future, filter))
94
+ telnet_port = future.result()
95
+
96
+ telnet_client = telnetlib.Telnet('localhost', telnet_port, timeout=None)
97
+
98
+ def telnet_negotiation_cb(telnet_socket, command, option):
99
+ pass # ignore telnet negotiation
100
+ telnet_client.set_option_negotiation_callback(telnet_negotiation_cb)
101
+
102
+ # initialize telnet terminal
103
+ # this tells the telnet server we are a vt100 terminal
104
+ telnet_client.get_socket().sendall(b'\xff\xfb\x18\xff\xfa\x18\x00\x61\x6e\x73\x69\xff\xf0')
105
+ telnet_client.get_socket().sendall(b'\r\n')
106
+
107
+ # Bridge the connection to the telnet server, two way
108
+ def forward_to_telnet():
109
+ while True:
110
+ data = conn.recv(1024)
111
+ if not data:
112
+ break
113
+ telnet_client.write(data)
114
+ def forward_to_socket():
115
+ prompt_count = 0
116
+ while True:
117
+ data = telnet_client.read_some()
118
+ if not data:
119
+ conn.sendall('REPL exited'.encode())
120
+ break
121
+ if b">>>" in data:
122
+ # This is an ugly hack - somewhere in ptpython, the
123
+ # initial prompt is being printed many times. Normal
124
+ # telnet clients handle it properly, but xtermjs doesn't
125
+ # like it. We just replace the first few with spaces
126
+ # so it's not too ugly.
127
+ prompt_count += 1
128
+ if prompt_count < 5:
129
+ data = data.replace(b">>>", b" ")
130
+ conn.sendall(data)
131
+
132
+ threading.Thread(target=forward_to_telnet).start()
133
+ threading.Thread(target=forward_to_socket).start()
134
+
135
+ def accept_connection():
136
+ while True:
137
+ conn, addr = sock.accept()
138
+ threading.Thread(target=handle_connection, args=(conn,)).start()
139
+
140
+ threading.Thread(target=accept_connection).start()
141
+
142
+ proxy_port = sock.getsockname()[1]
143
+ return proxy_port