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 find(byte[] subjectDer) { String h = CertVerifier.sha1Hex(subjectDer); List out = new ArrayList(); 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 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 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 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 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 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 bad = new java.util.ArrayList(); 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(); } }