Private
Public Access
Import minamp 1.0 source
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
0df8124629
194 files changed
+7319
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user