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; } }; String id2 = proxy.newId(); String local2 = proxy.register(id2, lib.transcodeUrls(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)"); check(accepted[0] == 1 && refused[0] == null, "refused transcode variant skipped, second accepted (index " + accepted[0] + ")"); Library.preferredVariant = Library.variantOrder()[accepted[0]]; check(Library.variantOrder()[0] == 1, "accepted variant is tried first next time"); // every variant refused -> error carries the server's words Library.preferredVariant = 0; String id4 = proxy.newId(); java.util.List bad = new java.util.ArrayList(); bad.add(lib.transcodeUrls(tracks.get(1), 192, 0, "s4").get(0)); String local4 = proxy.register(id4, bad, "mp3"); 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, "refusal reported with server text: " + refused[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); // --- 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(); } }