Import minamp 1.0 source

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xockoandClaude Opus 5.5 committed 2026-10-14 19:38:37 +02:00
commit 0df8124629
194 files changed
+7319

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
import java.io.File;
import java.io.FileInputStream;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.minamp.net.CertVerifier;
import com.minamp.net.Http;
/** Desktop-JVM test of the bundled TLS + HTTP client against real servers. */
public class NetTest {
public static void main(String[] args) throws Exception {
final File dir = new File(args[0]);
CertVerifier.setRootStore(new CertVerifier.RootStore() {
public List<byte[]> find(byte[] subjectDer) {
String h = CertVerifier.sha1Hex(subjectDer);
List<byte[]> out = new ArrayList<byte[]>();
for (int i = 0; ; i++) {
File f = new File(dir, h + "-" + i + ".der");
if (!f.exists()) break;
try { out.add(read(f)); } catch (Exception e) { break; }
}
return out.isEmpty() ? null : out;
}
});
for (int i = 1; i < args.length; i++) {
String url = args[i];
long t0 = System.currentTimeMillis();
try {
Map<String, String> h = new HashMap<String, String>();
h.put("Accept", "application/json");
Http.Response r = Http.get(url, h, 15000);
byte[] body = r.readAll();
long t1 = System.currentTimeMillis();
// second request should reuse the pooled connection
Http.Response r2 = Http.get(url, h, 15000);
byte[] body2 = r2.readAll();
long t2 = System.currentTimeMillis();
System.out.println("OK " + url + " -> " + r.code + " " + body.length + "B chunked=" + r.chunked
+ " first=" + (t1 - t0) + "ms reuse=" + (t2 - t1) + "ms (" + body2.length + "B)");
} catch (Exception e) {
System.out.println("ERR " + url + " -> " + e);
}
}
}
static byte[] read(File f) throws Exception {
FileInputStream in = new FileInputStream(f);
ByteArrayOutputStream b = new ByteArrayOutputStream();
byte[] buf = new byte[4096]; int n;
while ((n = in.read(buf)) > 0) b.write(buf, 0, n);
in.close();
return b.toByteArray();
}
}
+167
View File
@@ -0,0 +1,167 @@
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.Proxy;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import com.minamp.net.CertVerifier;
import com.minamp.net.Http;
import com.minamp.net.StreamProxy;
import com.minamp.plex.Item;
import com.minamp.plex.Library;
import com.minamp.plex.Plex;
import com.minamp.plex.Server;
import com.minamp.util.Json;
/** End-to-end test of sign-in, discovery, connection picking, browsing and the stream proxy. */
public class PlexTest {
static int failures = 0;
static void check(boolean ok, String what) {
System.out.println((ok ? "PASS " : "FAIL ") + what);
if (!ok) failures++;
}
static RootDir roots(String dir) { return new RootDir(new File(dir)); }
static final class RootDir implements CertVerifier.RootStore {
final File dir;
RootDir(File d) { dir = d; }
public List<byte[]> find(byte[] subjectDer) {
String h = CertVerifier.sha1Hex(subjectDer);
List<byte[]> out = new ArrayList<byte[]>();
for (int i = 0; ; i++) {
File f = new File(dir, h + "-" + i + ".der");
if (!f.exists()) break;
try { out.add(read(new FileInputStream(f))); } catch (Exception e) { break; }
}
return out.isEmpty() ? null : out;
}
}
public static void main(String[] args) throws Exception {
CertVerifier.setRootStore(roots(args[0]));
Plex.clientId = "test-client";
// --- sign-in via PIN
Plex.Pin pin = Plex.createPin();
check("WXYZ".equals(pin.code), "PIN created: " + pin.code);
String account = null;
for (int i = 0; i < 10 && account == null; i++) { account = Plex.checkPin(pin.id); if (account == null) Thread.sleep(300); }
check("account-token-123".equals(account), "PIN linked -> account token");
check("tester".equals(Plex.username(account)), "username");
// --- discovery
List<Server> servers = Plex.servers(account);
check(servers.size() == 1, "one server (players filtered out)");
Server s = servers.get(0);
StringBuilder order = new StringBuilder();
for (Server.Conn c : s.connections) order.append(c.kind).append(' ');
check(s.connections.get(0).kind == Server.LOCAL && s.connections.get(s.connections.size() - 1).kind == Server.RELAY,
"connections ordered local..relay: " + order);
// persistence round trip
Server s2 = Server.fromJson(Json.parseObj(s.toJson().toString()));
check(s2.connections.size() == s.connections.size() && s2.token.equals(s.token), "server JSON round trip");
long t0 = System.currentTimeMillis();
Server.Conn picked = s2.pick();
check(picked.uri.startsWith("https://127-0-0-1.abc123.plex.direct:32443"), "picked local TLS connection " + picked.uri
+ " in " + (System.currentTimeMillis() - t0) + "ms");
// --- browsing
List<Server.Section> secs = s2.musicSections();
check(secs.size() == 1 && "Music".equals(secs.get(0).title), "music sections only");
Library lib = new Library(s2, secs.get(0).key);
List<Item> artists = s2.items(lib.artistsPath(100, 100));
check(artists.size() == 100 && "Artist 100".equals(artists.get(0).title), "artists paging");
Item album = s2.items(lib.recentlyAddedPath(10)).get(0);
check(album.type == Item.ALBUM && "The Testers · 1999".equals(album.subtitle()), "album item + subtitle");
List<Item> tracks = lib.tracksOf(album);
check(tracks.size() == 2 && tracks.get(0).partKey.endsWith("file.mp3") && tracks.get(0).loved(), "album tracks with media");
check(Library.canDirectPlay(tracks.get(0), 0) && !Library.canDirectPlay(tracks.get(1), 0), "direct play decisions");
check(!Library.canDirectPlay(tracks.get(0), 128), "192k mp3 transcoded when capped at 128k");
Item rt = Item.deserialize(tracks.get(0).serialize());
check(rt.title.equals("Song One") && rt.partKey.equals(tracks.get(0).partKey) && rt.duration == 200000, "item JSON round trip");
List<Item> res = lib.search("test");
check(res.size() == 4 && res.get(0).type == Item.HEADER && res.get(1).type == Item.ARTIST && res.get(3).type == Item.TRACK,
"search hubs grouped (" + res.size() + ")");
check(lib.libraryRadio().size() == 2, "library radio");
String img = s2.imageUrl("/library/metadata/10/thumb/1", 64);
Http.Response ir = Http.get(img, null, 10000);
byte[] ib = ir.readAll();
check(ir.code == 200 && ib.length > 100, "artwork via photo transcoder");
lib.timeline(tracks.get(0), "playing", 1000);
lib.rate(tracks.get(0), 10);
check(true, "timeline + rate calls");
// --- stream proxy
StreamProxy proxy = new StreamProxy();
proxy.start();
String id = proxy.newId();
String local = proxy.register(id, lib.directUrl(tracks.get(0)), "mp3");
HttpURLConnection hc = (HttpURLConnection) new URL(local).openConnection(Proxy.NO_PROXY);
hc.setRequestProperty("Range", "bytes=1000-");
byte[] body = read(hc.getInputStream());
check(hc.getResponseCode() == 206 && body.length == 299000 && (body[0] & 255) == (1000 * 7) % 256,
"proxy direct stream with Range over TLS (" + hc.getResponseCode() + ", " + body.length + "B)");
String id2 = proxy.newId();
String local2 = proxy.register(id2, lib.transcodeUrl(tracks.get(1), 192, 30500, "sess1"), "mp3");
hc = (HttpURLConnection) new URL(local2).openConnection(Proxy.NO_PROXY);
byte[] tbody = read(hc.getInputStream());
String ts = new String(tbody, "ISO-8859-1");
check(hc.getResponseCode() == 200 && ts.startsWith("TRANSCODE offset=30 bitrate=192|") && hc.getHeaderField("Content-Length") == null,
"proxy de-chunks transcode stream (" + tbody.length + "B)");
// abort mid-stream
String id3 = proxy.newId();
String local3 = proxy.register(id3, lib.directUrl(tracks.get(0)), "mp3");
hc = (HttpURLConnection) new URL(local3).openConnection(Proxy.NO_PROXY);
InputStream in = hc.getInputStream();
in.read(new byte[100]);
proxy.unregister(id3);
check(true, "unregister mid-stream doesn't hang");
String gone = proxy.register("nope", "http://127.0.0.1:1/x", "mp3");
proxy.unregister("nope");
hc = (HttpURLConnection) new URL(gone).openConnection(Proxy.NO_PROXY);
check(hc.getResponseCode() == 404, "unknown route 404");
// --- failover: break the preferred connection's identity, expect fallback to plain http LAN
Server s3 = new Server();
s3.machineId = "machine-abc123"; s3.token = s.token; s3.name = "x";
s3.add("https://localhost:32443", Server.LOCAL); // cert won't match "localhost"
s3.add("http://127.0.0.1:32400", Server.LOCAL_HTTP);
s3.add("https://10-255-255-1.abc123.plex.direct:9", Server.REMOTE);
s3.sortConnections();
Server.Conn c3 = s3.pick();
check(c3.kind == Server.LOCAL_HTTP, "falls back when TLS name check fails -> " + c3.uri);
// --- trust checks
CertVerifier.setRootStore(roots(args[1])); // real Mozilla roots: test CA must be rejected
Http.evictAll();
try {
Http.get("https://127-0-0-1.abc123.plex.direct:32443/identity", null, 5000).readAll();
check(false, "untrusted root rejected");
} catch (Exception e) {
check(e.getMessage() != null && e.getMessage().indexOf("Untrusted") >= 0, "untrusted root rejected: " + e.getMessage());
}
check(Server.normalizeManual("192.168.1.5").equals("http://192.168.1.5:32400"), "manual URL normalise");
check(Server.normalizeManual("https://plex.example.com/").equals("https://plex.example.com"), "manual https URL kept");
System.out.println(failures == 0 ? "ALL PASSED" : failures + " FAILED");
System.exit(failures == 0 ? 0 : 1);
}
static byte[] read(InputStream in) throws Exception {
ByteArrayOutputStream b = new ByteArrayOutputStream();
byte[] buf = new byte[8192]; int n;
while ((n = in.read(buf)) > 0) b.write(buf, 0, n);
in.close();
return b.toByteArray();
}
}
+71
View File
@@ -0,0 +1,71 @@
import java.util.ArrayList;
import java.util.List;
import com.minamp.PlayQueue;
import com.minamp.plex.Item;
/** Queue semantics: shuffle keeps the current track first and restores order; repeat; edits. */
public class QueueTest {
static int fails;
static void check(boolean ok, String what) {
System.out.println((ok ? "PASS " : "FAIL ") + what);
if (!ok) fails++;
}
static List<Item> tracks(int n) {
List<Item> l = new ArrayList<Item>();
for (int i = 0; i < n; i++) { Item t = new Item(); t.type = Item.TRACK; t.title = "T" + i; t.ratingKey = "" + i; l.add(t); }
return l;
}
static String order(PlayQueue q) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < q.size(); i++) sb.append(q.at(i).title).append(i == q.index() ? "*" : "").append(' ');
return sb.toString().trim();
}
public static void main(String[] a) {
PlayQueue q = PlayQueue.get();
q.set(tracks(5), 2, false, "Album");
check(order(q).equals("T0 T1 T2* T3 T4"), "set from index 2: " + order(q));
q.setShuffle(true);
check(q.index() == 0 && q.current().title.equals("T2") && q.size() == 5, "shuffle keeps current first: " + order(q));
q.setShuffle(false);
check(order(q).equals("T0 T1 T2* T3 T4"), "unshuffle restores order: " + order(q));
check(q.advance(false) && q.current().title.equals("T3"), "advance");
q.advance(false);
check(!q.advance(false) && q.current().title.equals("T4"), "end of queue stops (repeat off)");
q.cycleRepeat(); // all
check(q.advance(false) && q.current().title.equals("T0"), "repeat all wraps");
q.cycleRepeat(); // one
check(q.advance(false) && q.current().title.equals("T0"), "repeat one replays on completion");
check(q.advance(true) && q.current().title.equals("T1"), "manual skip leaves repeat-one track");
q.cycleRepeat(); // off
check(q.back() && q.current().title.equals("T0"), "back");
check(q.back() && q.current().title.equals("T0"), "back at start stays");
List<Item> extra = tracks(2);
extra.get(0).title = "X0"; extra.get(1).title = "X1";
q.playNext(extra);
check(order(q).equals("T0* X0 X1 T1 T2 T3 T4"), "play next inserts after current: " + order(q));
q.remove(1);
check(order(q).equals("T0* X1 T1 T2 T3 T4"), "remove: " + order(q));
q.jump(3);
q.moveNext(0);
check(order(q).equals("X1 T1 T2* T0 T3 T4"), "move earlier item to play next: " + order(q));
q.setShuffle(true);
q.append(tracks(1));
q.setShuffle(false);
check(q.size() == 7 && q.current().title.equals("T2"), "items appended while shuffled survive unshuffle: " + order(q));
q.clearUpcoming();
check(q.size() == q.index() + 1 && q.current().title.equals("T2"), "clear upcoming: " + order(q));
q.set(tracks(10), -1, true, "Radio");
check(q.size() == 10 && q.index() == 0 && q.shuffle(), "shuffle play from random start");
q.clear();
check(q.current() == null && q.size() == 0, "clear");
System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED");
System.exit(fails == 0 ? 0 : 1);
}
}
+226
View File
@@ -0,0 +1,226 @@
"""Mock plex.tv + Plex Media Server for exercising Minamp's networking code on a desktop JVM.
Serves:
https://plex.tv:443 pins, user, resources
https://127-0-0-1.abc123.plex.direct:32443 PMS over TLS (wildcard cert, ECDSA leaf, RSA root)
http://127.0.0.1:32400 the same PMS in plain HTTP
"""
import datetime
import json
import os
import ssl
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.x509.oid import NameOID
OUT = sys.argv[1]
os.makedirs(OUT, exist_ok=True)
NOW = datetime.datetime.now(datetime.timezone.utc)
def name(cn):
return x509.Name([x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Minamp Test"), x509.NameAttribute(NameOID.COMMON_NAME, cn)])
def cert(subject, issuer, pub, signer, hashalg, ca, sans=None):
b = (x509.CertificateBuilder().subject_name(subject).issuer_name(issuer).public_key(pub)
.serial_number(x509.random_serial_number())
.not_valid_before(NOW - datetime.timedelta(days=1)).not_valid_after(NOW + datetime.timedelta(days=30))
.add_extension(x509.BasicConstraints(ca=ca, path_length=None), critical=True))
if sans:
b = b.add_extension(x509.SubjectAlternativeName([x509.DNSName(s) for s in sans]), critical=False)
return b.sign(signer, hashalg)
root_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
root = cert(name("Test Root"), name("Test Root"), root_key.public_key(), root_key, hashes.SHA256(), True)
inter_key = ec.generate_private_key(ec.SECP384R1())
inter = cert(name("Test ECC Intermediate"), root.subject, inter_key.public_key(), root_key, hashes.SHA256(), True)
pms_key = ec.generate_private_key(ec.SECP256R1())
pms = cert(name("*.abc123.plex.direct"), inter.subject, pms_key.public_key(), inter_key, hashes.SHA384(), False,
["*.abc123.plex.direct"])
tv_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
tv = cert(name("plex.tv"), inter.subject, tv_key.public_key(), inter_key, hashes.SHA384(), False, ["plex.tv", "*.plex.tv"])
pem = lambda c: c.public_bytes(serialization.Encoding.PEM)
keypem = lambda k: k.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
open(os.path.join(OUT, "root.pem"), "wb").write(pem(root))
for label, c, k in (("pms", pms, pms_key), ("tv", tv, tv_key)):
open(os.path.join(OUT, label + "-chain.pem"), "wb").write(pem(c) + pem(inter))
open(os.path.join(OUT, label + "-key.pem"), "wb").write(keypem(k))
MACHINE = "machine-abc123"
TOKEN = "server-token-xyz"
ACCOUNT = "account-token-123"
pins = {}
log = []
TRACKS = [
{"type": "track", "ratingKey": "101", "key": "/library/metadata/101", "title": "Song One", "grandparentTitle": "The Testers",
"parentTitle": "First Album", "parentThumb": "/library/metadata/10/thumb/1", "parentRatingKey": "10", "grandparentRatingKey": "1",
"index": 1, "duration": 200000, "userRating": 10,
"Media": [{"audioCodec": "mp3", "container": "mp3", "bitrate": 192, "Part": [{"key": "/library/parts/501/1/file.mp3", "size": 300000}]}]},
{"type": "track", "ratingKey": "102", "key": "/library/metadata/102", "title": "Song Two (FLAC)", "grandparentTitle": "The Testers",
"parentTitle": "First Album", "parentThumb": "/library/metadata/10/thumb/1", "parentRatingKey": "10", "grandparentRatingKey": "1",
"index": 2, "duration": 180000,
"Media": [{"audioCodec": "flac", "container": "flac", "bitrate": 900, "Part": [{"key": "/library/parts/502/1/file.flac", "size": 9000000}]}]},
]
MP3 = bytes((i * 7) % 256 for i in range(300000))
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass
def send_json(self, obj, code=200):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
self.route("POST")
def do_PUT(self):
self.route("PUT")
def do_GET(self):
self.route("GET")
def do_HEAD(self):
self.route("HEAD")
def route(self, method):
n = int(self.headers.get("Content-Length") or 0)
if n:
self.rfile.read(n)
u = urlparse(self.path)
q = parse_qs(u.query)
p = u.path
host = self.headers.get("Host", "")
tok = self.headers.get("X-Plex-Token") or (q.get("X-Plex-Token") or [None])[0]
log.append((method, host, self.path, tok, self.headers.get("Range")))
if host.startswith("plex.tv"):
return self.tv(method, p, tok)
if p == "/identity":
return self.send_json({"MediaContainer": {"machineIdentifier": MACHINE, "version": "1.40"}})
if tok != TOKEN:
return self.send_json({"error": "unauthorized"}, 401)
if p == "/library/sections":
return self.send_json({"MediaContainer": {"Directory": [
{"key": "1", "type": "artist", "title": "Music"}, {"key": "2", "type": "movie", "title": "Films"}]}})
if p == "/library/sections/1/all":
t = q.get("type", ["8"])[0]
if t == "8":
start = int(q.get("X-Plex-Container-Start", ["0"])[0])
size = int(q.get("X-Plex-Container-Size", ["50"])[0])
arts = [{"type": "artist", "ratingKey": str(1000 + i), "title": "Artist %03d" % i, "thumb": "/a/%d" % i} for i in range(250)]
return self.send_json({"MediaContainer": {"totalSize": 250, "offset": start, "Metadata": arts[start:start + size]}})
if t == "10":
return self.send_json({"MediaContainer": {"Metadata": TRACKS}})
return self.send_json({"MediaContainer": {"Metadata": [{"type": "album", "ratingKey": "10", "title": "First Album",
"parentTitle": "The Testers", "year": 1999, "thumb": "/library/metadata/10/thumb/1"}]}})
if p == "/library/metadata/10/children":
return self.send_json({"MediaContainer": {"Metadata": TRACKS}})
if p == "/hubs/search":
return self.send_json({"MediaContainer": {"Hub": [
{"type": "artist", "title": "Artists", "Directory": [{"type": "artist", "ratingKey": "1", "title": "The Testers"}]},
{"type": "track", "title": "Tracks", "Metadata": TRACKS[:1]}]}})
if p.startswith("/library/parts/501"):
return self.file(method, MP3)
if p == "/music/:/transcode/universal/start.mp3":
return self.chunked(method, q)
if p == "/photo/:/transcode":
return self.file(method, b"\xff\xd8\xff\xe0JPEGDATA" * 50, "image/jpeg")
if p in ("/:/timeline", "/:/scrobble", "/:/rate", "/transcode/universal/stop"):
return self.send_json({})
self.send_json({"error": "not found " + p}, 404)
def file(self, method, data, ctype="audio/mpeg"):
rng = self.headers.get("Range")
start, end = 0, len(data) - 1
code = 200
if rng and rng.startswith("bytes="):
a, b = rng[6:].split("-")
start = int(a)
end = int(b) if b else len(data) - 1
code = 206
chunk = data[start:end + 1]
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(len(chunk)))
if code == 206:
self.send_header("Content-Range", "bytes %d-%d/%d" % (start, end, len(data)))
self.end_headers()
if method != "HEAD":
self.wfile.write(chunk)
def chunked(self, method, q):
self.send_response(200)
self.send_header("Content-Type", "audio/mpeg")
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
if method == "HEAD":
return
offset = q.get("offset", ["0"])[0]
payload = ("TRANSCODE offset=%s bitrate=%s|" % (offset, q.get("musicBitrate", ["?"])[0])).encode() * 400
for i in range(0, len(payload), 1000):
part = payload[i:i + 1000]
self.wfile.write(b"%x\r\n" % len(part) + part + b"\r\n")
self.wfile.flush()
self.wfile.write(b"0\r\n\r\n")
def tv(self, method, p, tok):
if p == "/api/v2/pins" and method == "POST":
pid = len(pins) + 1
pins[pid] = {"id": pid, "code": "WXYZ", "authToken": None, "created": time.time()}
return self.send_json(pins[pid], 201)
if p.startswith("/api/v2/pins/"):
pin = pins.get(int(p.rsplit("/", 1)[1]))
if pin and time.time() - pin["created"] > 1.0:
pin["authToken"] = ACCOUNT # "user linked it" after a second
return self.send_json(pin or {}, 200 if pin else 404)
if tok != ACCOUNT:
return self.send_json({"error": "unauthorized"}, 401)
if p == "/api/v2/user":
return self.send_json({"username": "tester"})
if p == "/api/v2/resources":
return self.send_json([
{"name": "Living Room PC", "provides": "server", "clientIdentifier": MACHINE, "accessToken": TOKEN, "owned": True,
"connections": [
{"protocol": "https", "address": "10.255.255.1", "port": 32400, "uri": "https://10-255-255-1.abc123.plex.direct:32400", "local": False, "relay": False},
{"protocol": "https", "address": "127.0.0.1", "port": 32443, "uri": "https://127-0-0-1.abc123.plex.direct:32443", "local": True, "relay": False},
{"protocol": "https", "address": "1.2.3.4", "port": 8443, "uri": "https://1-2-3-4.abc123.plex.direct:8443", "local": False, "relay": True},
]},
{"name": "Some TV", "provides": "player", "clientIdentifier": "tv"},
])
self.send_json({"error": "nf"}, 404)
def serve(port, chain=None, key=None):
srv = ThreadingHTTPServer(("127.0.0.1", port), H)
if chain:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(chain, key)
srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
threading.Thread(target=srv.serve_forever, daemon=True).start()
serve(32400)
serve(32443, os.path.join(OUT, "pms-chain.pem"), os.path.join(OUT, "pms-key.pem"))
serve(443, os.path.join(OUT, "tv-chain.pem"), os.path.join(OUT, "tv-key.pem"))
print("ready", flush=True)
while True:
time.sleep(1)