Files
plexamp-lite/test/PlexTest.java
T
xockoandClaude Opus 5.5 0a2ac901bd Minamp 1.1
- Run network/TLS work on 256 KB-stack threads (net/Threads.java): Android 2.x's
  ~16 KB default stack overflowed during the bundled TLS handshake
- Close pooled TLS connections off the caller thread
- Try several Plex music-transcoder URL variants until the server accepts one,
  and remember the one that worked
- Accept pasted server URLs with paths; default to 443 for https
- Catch fatal errors and save a crash report (shown on next launch and written
  to Minamp-error.txt on the SD card)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:59:58 +02:00

208 lines
11 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; }
};
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<String> bad = new java.util.ArrayList<String>();
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();
}
}