Files
plexamp-lite/test/PlexTest.java
xockoandClaude Opus 5.5 6fd17bc9c5 Minamp 1.4: faster connecting
- Remember the connection that worked and try it alone first next start
- Prefer plain HTTP on the home network (each TLS handshake costs seconds on a
  600 MHz phone); new setting "Encrypt at home too" to keep HTTPS
- Try secure addresses one at a time instead of in parallel, so they don't
  all time out together
- TLS 1.2 with session resumption: reconnects skip the full handshake
- Cache certificate chains already verified; changing the trusted roots
  clears that cache and all TLS sessions
- README: updated connection order; test: session resumption check and
  HandshakeProfile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:16:09 +02:00

296 lines
17 KiB
Java

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(final String[] args) throws Exception {
// run everything on the same kind of thread the app uses (256 KB stack, Dalvik's max)
final Throwable[] err = {null};
Thread t = com.minamp.net.Threads.create(new Runnable() {
public void run() { try { realMain(args); } catch (Throwable e) { err[0] = e; } }
}, "test");
t.start();
t.join();
if (err[0] != null) { err[0].printStackTrace(); failures++; }
System.out.println(failures == 0 ? "ALL PASSED" : failures + " FAILED");
System.exit(failures == 0 ? 0 : 1);
}
static void realMain(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");
check(lib.topRatedRadio().size() == 1, "'>>' filter reaches the server percent-encoded and decoded intact");
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)");
final int[] accepted = {-1};
final String[] refused = {null};
proxy.listener = new StreamProxy.Listener() {
public void onError(String id, int code, String m) { refused[0] = code + " " + m; }
public void onAccepted(String id, int index) { accepted[0] = index; }
};
java.io.File cache = new java.io.File(System.getProperty("java.io.tmpdir"), "minamp-test-cache");
cache.mkdirs();
proxy.cacheDir = cache;
Library.preferredVariant = 0;
String id2 = proxy.newId();
String local2 = proxy.register(id2, lib.transcodeUrls(tracks.get(1), 192, 30500, "sess1"), "mp3", true);
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|"),
"buffered transcode stream served (" + tbody.length + "B)");
check(accepted[0] == 0 && refused[0] == null, "web-player identity accepted first time (variant " + accepted[0] + ")");
// the media player reconnecting must not start the transcode again (that's the 503)
hc = (HttpURLConnection) new URL(local2).openConnection(Proxy.NO_PROXY);
byte[] again = read(hc.getInputStream());
check(hc.getResponseCode() == 200 && again.length == tbody.length && refused[0] == null,
"reconnect served from the buffer, no second transcode (" + again.length + "B)");
hc = (HttpURLConnection) new URL(local2).openConnection(Proxy.NO_PROXY);
hc.setRequestProperty("Range", "bytes=100-");
byte[] part = read(hc.getInputStream());
check(hc.getResponseCode() == 206 && part.length == tbody.length - 100 && proxy.isComplete(id2),
"seek within a finished transcode via Range (" + part.length + "B)");
check(!lib.transcodeUrls(tracks.get(1), 192, 0, "x").get(0).contains("X-Plex-Device=")
&& lib.transcodeUrls(tracks.get(1), 192, 0, "x").get(0).contains("session=x-v0"),
"no phone model in transcode identity; per-variant session ids");
String dec = lib.decision(tracks.get(1), 192, 0);
check(dec.indexOf("Conversion OK") >= 0, "decision check reads the server's verdict: " + dec);
check(lib.decision(tracks.get(1), 192, 2).indexOf("Conversion OK") >= 0, "speaker-profile variant also accepted by profile matching");
// a server that refuses everything: error carries the server's own words
String id4 = proxy.newId();
java.util.List<String> bad = new java.util.ArrayList<String>();
bad.add(lib.transcodeUrls(tracks.get(1), 192, 0, "s4").get(0).replace("X-Plex-Platform=Chrome", "X-Plex-Platform=Android"));
String local4 = proxy.register(id4, bad, "mp3", true);
hc = (HttpURLConnection) new URL(local4).openConnection(Proxy.NO_PROXY);
int c4 = hc.getResponseCode();
check(c4 == 400 && refused[0] != null && refused[0].indexOf("400 Bad Request") >= 0, "unrecognised identity -> refusal with server text: " + refused[0]);
// the pre-fix request shape (phone model as device) is exactly what a real server refuses
String old = lib.transcodeUrls(tracks.get(1), 192, 0, "s5").get(0).replace("X-Plex-Platform=Chrome", "X-Plex-Platform=Chrome&X-Plex-Device=GT-S5570");
Http.Response or = Http.get(old, null, 10000);
or.readAll();
check(or.code == 400, "mock reproduces the 400 for the old phone-model identity");
Library.preferredVariant = 0;
// 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);
// --- TLS 1.2 session resumption: the second connection must skip the full handshake
com.minamp.net.DiagLog.clear();
Http.evictAll();
Thread.sleep(300);
Http.get("https://127-0-0-1.abc123.plex.direct:32443/identity", null, 10000).readAll();
Http.evictAll();
Thread.sleep(300);
long tr = System.currentTimeMillis();
Http.get("https://127-0-0-1.abc123.plex.direct:32443/identity", null, 10000).readAll();
tr = System.currentTimeMillis() - tr;
String dl = com.minamp.net.DiagLog.dump();
check(dl.indexOf("TLS resumed") >= 0, "second TLS connection resumed the session (" + tr + "ms)");
// --- picking: last good first, and a dead preferred address doesn't block a working one
Server s5 = new Server();
s5.machineId = "machine-abc123"; s5.token = s.token; s5.name = "x";
s5.add("https://10-255-255-1.abc123.plex.direct:9", Server.LOCAL); // unreachable
s5.add("http://127.0.0.1:32400", Server.LOCAL_HTTP);
s5.add("https://127-0-0-1.abc123.plex.direct:32443", Server.REMOTE);
s5.sortConnections();
long tp = System.currentTimeMillis();
Server.Conn c5 = s5.pick();
tp = System.currentTimeMillis() - tp;
check(c5.kind == Server.LOCAL_HTTP && tp < 3000, "plain home address chosen straight away (" + tp + "ms), not stuck behind HTTPS");
Server s6 = Server.fromJson(Json.parseObj(s5.toJson().toString()));
check(s6.lastGoodUri.equals("http://127.0.0.1:32400"), "working address remembered across restarts");
s6.lastGoodUri = "https://127-0-0-1.abc123.plex.direct:32443";
com.minamp.net.DiagLog.clear();
Server.Conn c6 = s6.pick();
check(c6.kind == Server.REMOTE && com.minamp.net.DiagLog.dump().indexOf("127.0.0.1:32400") < 0, "last good connection tried first, nothing else probed");
Server s7 = new Server();
s7.machineId = "machine-abc123"; s7.token = s.token; s7.name = "x";
s7.add("http://10.255.255.1:32400", Server.LOCAL_HTTP); // away from home: LAN dead
s7.add("https://127-0-0-1.abc123.plex.direct:32443", Server.REMOTE);
s7.add("https://127-0-0-1.abc123.plex.direct:32443/relay", Server.RELAY);
s7.sortConnections();
Server.Conn c7 = s7.pick();
check(c7.kind == Server.REMOTE, "away from home: falls through to remote (" + Server.kindLabel(c7.kind) + ")");
// --- manual LAN address without a token (server allows this network without sign-in)
Server lan = new Server();
lan.name = "manual"; lan.token = "";
lan.add(Server.normalizeManual("127.0.0.1"), Server.MANUAL);
lan.pick();
check(lan.isLocal(), "manual private address counts as home (original quality)");
check(lan.url("/library/parts/501/1/file.mp3").indexOf("X-Plex-Token") < 0, "no empty X-Plex-Token on media URLs");
Library lanLib = new Library(lan, "1");
Item lanTrack = lanLib.tracksOf(album).get(0);
String lid = proxy.newId();
String lurl = proxy.register(lid, java.util.Collections.singletonList(lanLib.directUrl(lanTrack)), "mp3", false, lan.mediaHeaders());
hc = (HttpURLConnection) new URL(lurl).openConnection(Proxy.NO_PROXY);
byte[] lb = read(hc.getInputStream());
check(hc.getResponseCode() == 200 && lb.length == 300000, "token-less LAN: original MP3 streams (" + hc.getResponseCode() + ")");
Item flac = lanLib.tracksOf(album).get(1);
String lid2 = proxy.newId();
String lurl2 = proxy.register(lid2, lanLib.transcodeUrls(flac, 192, 0, "lan1"), "mp3", true, lan.mediaHeaders());
hc = (HttpURLConnection) new URL(lurl2).openConnection(Proxy.NO_PROXY);
byte[] lt = read(hc.getInputStream());
check(hc.getResponseCode() == 200 && new String(lt, "ISO-8859-1").startsWith("TRANSCODE"), "token-less LAN: FLAC converts to MP3");
// and the old behaviour reproduces the phone's log exactly
Http.Response o1 = Http.get(lanLib.directUrl(lanTrack) + "?X-Plex-Token=", null, 10000); o1.readAll();
Http.Response o2 = Http.get(lanLib.transcodeUrls(flac, 192, 0, "lan2").get(0) + "&X-Plex-Token=", null, 10000); o2.readAll();
check(o1.code == 503 && o2.code == 400, "old empty-token URLs reproduce the log's 503 and 400");
check(lanLib.checkPart(lanTrack).indexOf("exists=") >= 0 || lanLib.checkPart(lanTrack).length() > 0, "file check runs: " + lanLib.checkPart(lanTrack));
// --- 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");
check(Server.normalizeManual("http://192.168.1.5:32400/web/index.html#!/").equals("http://192.168.1.5:32400"), "pasted web-app path stripped");
check(Server.normalizeManual("http://192.168.1.5").equals("http://192.168.1.5:32400"), "explicit http gets Plex port");
CertVerifier.setRootStore(roots(args[0]));
Http.evictAll();
try {
s2.items("/library/nonexistent");
check(false, "error names the failing call");
} catch (Plex.PlexException e) {
check(e.getMessage().indexOf("/library/nonexistent") >= 0 && e.getMessage().indexOf("X-Plex-Token") < 0, "error names the failing call: " + e.getMessage());
}
}
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();
}
}