From a37711afc0f4a4b7433617d9609ae0b9ee381202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xos=C3=A9?= Date: Sun, 1 Nov 2026 19:09:53 +0100 Subject: [PATCH] Plexamp Lite 1.14.1: read-ahead no longer cut off ("Unexpected end of stream") Two causes, both fixed: - Plex keeps one file stream per device and cuts the one in progress when another starts, so the read-ahead and the player cut each other off. Whole-file fetches (read-ahead, downloads, download-first) now ask for the file as a download (?download=1), which Plex leaves out of that rule; the plain address follows for servers that refuse downloads. - Http.abort closed the socket of a response already read to the end, whose connection was back in the pool and could be carrying the read-ahead. abort now leaves finished responses alone (test/PoolAbortTest.java). Co-Authored-By: Claude Opus 5.5 --- AndroidManifest.xml | 6 +-- README.md | 7 ++- spec.md | 2 + src/com/minamp/Downloads.java | 2 +- src/com/minamp/PlayerService.java | 5 +- src/com/minamp/net/Http.java | 48 ++++++++++++------ src/com/minamp/plex/Library.java | 13 +++++ test/PoolAbortTest.java | 81 +++++++++++++++++++++++++++++++ test/run_pool_abort.sh | 10 ++++ test/stub/android/util/Log.java | 9 ++++ 10 files changed, 160 insertions(+), 23 deletions(-) create mode 100644 test/PoolAbortTest.java create mode 100755 test/run_pool_abort.sh create mode 100644 test/stub/android/util/Log.java diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 7e1c628..2de7d7b 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,10 +1,10 @@ + and versionCode = the one below x 100 + the level (16 -> 1608 for Android 2.2, 1616 for 4.1). --> diff --git a/README.md b/README.md index 09d1455..b22edcb 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,10 @@ clock has been reset to before 2024 the certificate date check is skipped rather Bluetooth share one radio, and a lighter stream keeps the speaker from stuttering. * Seeking in a transcoded track restarts the transcode at the new position. * **Read-ahead:** while a track plays, the next two in the queue are fetched whole in the background - (one at a time) and then played from the file; without an SD card only the next one is kept. Android 2.x's player keeps only a few seconds of stream buffer, so on weak + (one at a time) and then played from the file; without an SD card only the next one is kept. Plex keeps one + file stream per device and cuts the one in progress when another starts, so whole-file fetches (read-ahead, + downloads, *Download tracks before playing*) ask for the file as a download (`?download=1`), which Plex leaves + out of that rule; otherwise the read-ahead and the player cut each other off. Android 2.x's player keeps only a few seconds of stream buffer, so on weak Wi-Fi shared with a Bluetooth speaker it used to go silent for seconds at a time. * If a stream fails, Plexamp Lite re-picks the connection and resumes; if it fails again it downloads the whole track first and plays from the SD card. "Download tracks before playing" forces that @@ -156,6 +159,8 @@ Tests (desktop JVM, no device): * `test/QueueTest.java`: shuffle/unshuffle, repeat modes, play next, remove, move, clear. * `test/LyricsTest.java`: LRC (several stamps a line, offsets, word stamps) and plain-text lyrics (`javac -d /tmp/lt src/com/minamp/util/Lrc.java test/LyricsTest.java && java -cp /tmp/lt LyricsTest`). +* `test/PoolAbortTest.java` (`test/run_pool_abort.sh`): aborting a response that's already finished leaves + its pooled connection, now carrying another request, alone. * `test/PreferHomeTest.java` + `test/identity_server.py`: after being away, the home address wins again as soon as it answers. * `test/NetTest.java`: the bundled TLS + HTTP client against real servers; `test/HandshakeProfile.java` diff --git a/spec.md b/spec.md index 02180f1..8b77efa 100644 --- a/spec.md +++ b/spec.md @@ -48,6 +48,8 @@ over a Bluetooth speaker. - This repo's `tools/setup_toolchain.sh`, `make_roots.py` and `signapk.py` prepare the toolchain all the Galaxy Mini apps use: changes there affect every app. - Installs to internal storage (SD-card installs get killed during USB sharing). +- Whole-file fetches of a part (read-ahead, downloads) use `Library.downloadUrls` (`?download=1` first): Plex + cuts a device's file stream when the same device starts another, except for downloads. - `platform/api8` and `platform/api16` keep the same `Platform` methods; shared code calls no API the API 8 jar lacks (the api8 compile and Dalvik check catch it). Each build's `versionCode` is the manifest's × 100 + level, same key; both APKs are listed in `../tenda-lite/repo/apps.json`. diff --git a/src/com/minamp/Downloads.java b/src/com/minamp/Downloads.java index 6a3acbd..e8795f8 100644 --- a/src/com/minamp/Downloads.java +++ b/src/com/minamp/Downloads.java @@ -341,7 +341,7 @@ public final class Downloads { if (!direct) part.delete(); // a transcode can't be resumed String session = direct ? null : "minamp-dl-" + Long.toString(System.currentTimeMillis(), 36); List urls; - if (direct) { urls = new ArrayList(); urls.add(lib.directUrl(t)); } + if (direct) urls = lib.downloadUrls(t); else urls = lib.transcodeUrls(t, quality > 0 ? quality : 320, 0, session); int[] order = direct ? null : Library.variantOrder(); diff --git a/src/com/minamp/PlayerService.java b/src/com/minamp/PlayerService.java index 0a9e6a9..de025f0 100644 --- a/src/com/minamp/PlayerService.java +++ b/src/com/minamp/PlayerService.java @@ -341,7 +341,8 @@ public class PlayerService extends Service implements Engine.Listener, AudioMana final long startAt = (direct || download) ? 0 : (posMs / 1000) * 1000; final java.util.List urls; final int[] order = direct ? null : Library.variantOrder(); - if (direct) { urls = new java.util.ArrayList(); urls.add(lib.directUrl(it)); } + if (direct && download) urls = lib.downloadUrls(it); + else if (direct) { urls = new java.util.ArrayList(); urls.add(lib.directUrl(it)); } else urls = lib.transcodeUrls(it, max, startAt, sess); final String ext = direct ? extFor(it) : "mp3"; File file = null; @@ -422,7 +423,7 @@ public class PlayerService extends Service implements Engine.Listener, AudioMana int max = app.currentMaxKbps(); boolean direct = Library.canDirectPlay(next, max); java.util.List urls; - if (direct) { urls = new java.util.ArrayList(); urls.add(lib.directUrl(next)); ext = extFor(next); } + if (direct) { urls = lib.downloadUrls(next); ext = extFor(next); } else urls = lib.transcodeUrls(next, max, 0, "minamp-ahead-" + Long.toString(System.currentTimeMillis(), 36)); k = direct ? 0 : (max <= 0 ? 320 : max); for (String u : urls) { diff --git a/src/com/minamp/net/Http.java b/src/com/minamp/net/Http.java index 6050a93..6468b76 100644 --- a/src/com/minamp/net/Http.java +++ b/src/com/minamp/net/Http.java @@ -442,19 +442,25 @@ public final class Http { // ------------------------------------------------------------------ body streams private static final class FixedStream extends InputStream { - private final Conn c; private long left; private final boolean keepAlive; private boolean closed; + private final Conn c; private long left; private final boolean keepAlive; private volatile boolean closed; FixedStream(Conn c, long len, boolean keepAlive) { this.c = c; this.left = len; this.keepAlive = keepAlive; if (len == 0) finish(true); } - private void finish(boolean complete) { + private synchronized void finish(boolean complete) { if (closed) return; closed = true; if (complete && keepAlive) release(c); else c.close(); } + synchronized void abort() { + if (closed) return; // done: the connection may already be back in the pool, serving someone else + closed = true; + try { c.socket.close(); } catch (Exception ignored) {} + } + @Override public int read() throws IOException { byte[] one = new byte[1]; int n = read(one, 0, 1); @@ -481,16 +487,22 @@ public final class Http { private static final class ChunkedStream extends InputStream { private final Conn c; private final boolean keepAlive; - private long chunkLeft = 0; private boolean eof; private boolean closed; + private long chunkLeft = 0; private boolean eof; private volatile boolean closed; ChunkedStream(Conn c, boolean keepAlive) { this.c = c; this.keepAlive = keepAlive; } - private void finish(boolean complete) { + private synchronized void finish(boolean complete) { if (closed) return; closed = true; if (complete && keepAlive) release(c); else c.close(); } + synchronized void abort() { + if (closed) return; // done: the connection may already be back in the pool, serving someone else + closed = true; + try { c.socket.close(); } catch (Exception ignored) {} + } + private boolean nextChunk() throws IOException { if (eof) return false; String line = readLine(c.in); @@ -542,7 +554,7 @@ public final class Http { } private static final class EofStream extends InputStream { - private final Conn c; private boolean closed; + private final Conn c; private volatile boolean closed; EofStream(Conn c) { this.c = c; } @Override public int read() throws IOException { @@ -559,21 +571,25 @@ public final class Http { return n; } - @Override public void close() { + @Override public synchronized void close() { if (!closed) { closed = true; c.close(); } } - } - /** Close one in-flight response's socket from another thread (used to abort streams). */ - public static void abort(Response r) { - if (r == null) return; - InputStream b = r.body; - Conn c = null; - if (b instanceof FixedStream) c = ((FixedStream) b).c; - else if (b instanceof ChunkedStream) c = ((ChunkedStream) b).c; - else if (b instanceof EofStream) c = ((EofStream) b).c; - if (c != null) { + synchronized void abort() { + if (closed) return; + closed = true; try { c.socket.close(); } catch (Exception ignored) {} } } + + /** Close one in-flight response's socket from another thread (used to abort streams). A response that has + * already been read to the end is left alone: its connection went back to the pool and may be carrying + * another request by now (closing it cut read-ahead downloads short: "Unexpected end of stream"). */ + public static void abort(Response r) { + if (r == null) return; + InputStream b = r.body; + if (b instanceof FixedStream) ((FixedStream) b).abort(); + else if (b instanceof ChunkedStream) ((ChunkedStream) b).abort(); + else if (b instanceof EofStream) ((EofStream) b).abort(); + } } diff --git a/src/com/minamp/plex/Library.java b/src/com/minamp/plex/Library.java index bd05137..3c88996 100644 --- a/src/com/minamp/plex/Library.java +++ b/src/com/minamp/plex/Library.java @@ -202,6 +202,19 @@ public final class Library { return server.url(t.partKey); } + /** + * For fetching a whole file while something may be playing (read-ahead, downloads). Plex keeps one file stream + * per device: a new request for a file cuts the one in progress, so the read-ahead and the player cut each + * other off ("Unexpected end of stream"). A request marked as a download is outside that rule. The plain + * address follows, for a server that refuses downloads (a shared library can). + */ + public List downloadUrls(Item t) throws IOException { + List l = new ArrayList(); + l.add(server.url(t.partKey + (t.partKey.indexOf('?') >= 0 ? "&" : "?") + "download=1")); + l.add(directUrl(t)); + return l; + } + /** * Plex only transcodes for a client identity it has a profile for: an unknown platform or * device gets a bare 400 ("unable to find a matching profile"). These are request shapes diff --git a/test/PoolAbortTest.java b/test/PoolAbortTest.java new file mode 100644 index 0000000..c8c36c1 --- /dev/null +++ b/test/PoolAbortTest.java @@ -0,0 +1,81 @@ +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; + +import com.minamp.net.Http; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +/** + * Http.abort on a response that was already read to the end must not close its connection: by then it's back in + * the pool and may be carrying the next request. (The stream proxy aborts every response it served once done; + * that used to cut the read-ahead of the next track short with "Unexpected end of stream".) + * Run: test/run_pool_abort.sh + */ +public class PoolAbortTest { + static int fails; + + static void check(boolean ok, String what) { + System.out.println((ok ? "PASS " : "FAIL ") + what); + if (!ok) fails++; + } + + public static void main(String[] a) throws Exception { + HttpServer srv = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + srv.createContext("/", new HttpHandler() { + public void handle(HttpExchange x) throws java.io.IOException { + int size = x.getRequestURI().getPath().startsWith("/big") ? 2 * 1024 * 1024 : 1000; + x.sendResponseHeaders(200, size); + OutputStream o = x.getResponseBody(); + byte[] chunk = new byte[16384]; + for (int sent = 0; sent < size; sent += chunk.length) { + o.write(chunk, 0, Math.min(chunk.length, size - sent)); + o.flush(); + if (size > 1000) try { Thread.sleep(2); } catch (InterruptedException ignored) {} + } + o.close(); + } + }); + srv.start(); + String base = "http://127.0.0.1:" + srv.getAddress().getPort(); + + // 1. the player's response, read to the end: its connection goes back to the pool + Http.Response first = Http.get(base + "/small", null, 5000); + check(first.readAll().length == 1000, "first response read whole"); + // 2. the read-ahead's request takes that pooled connection + Http.Response second = Http.get(base + "/big", null, 5000); + InputStream in = second.body; + byte[] buf = new byte[16384]; + long got = 0; + int n = in.read(buf); + got += n; + // 3. the proxy cleans up the first response + Http.abort(first); + String err = ""; + try { + while ((n = in.read(buf)) > 0) got += n; + } catch (java.io.IOException e) { + err = " (" + e.getMessage() + ")"; + } + check(got == 2 * 1024 * 1024, "aborting a finished response leaves the next request alone: " + got + " bytes" + err); + + // an unfinished response is still cut off + Http.Response third = Http.get(base + "/big", null, 5000); + third.body.read(buf); + Http.abort(third); + boolean cut = false; + try { + long left = 2 * 1024 * 1024; + while ((n = third.body.read(buf)) > 0) left -= n; + cut = left > 0; + } catch (java.io.IOException e) { + cut = true; + } + check(cut, "aborting a response still being read cuts it off"); + + srv.stop(0); + System.out.println(fails == 0 ? "all passed" : fails + " failed"); + System.exit(fails == 0 ? 0 : 1); + } +} diff --git a/test/run_pool_abort.sh b/test/run_pool_abort.sh new file mode 100755 index 0000000..e0b3e58 --- /dev/null +++ b/test/run_pool_abort.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Runs test/PoolAbortTest.java on this computer (JDK 11+; Bouncy Castle from the toolchain, Android's Log stubbed). +set -euo pipefail +cd "$(dirname "$0")/.." +T=${TOOLS:-$HOME/android-toolchain} +J=${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}/bin +out=$(mktemp -d) +trap 'rm -rf "$out"' EXIT +"$J/javac" -nowarn -d "$out" -sourcepath "src:$T/bcsrc:test/stub" test/PoolAbortTest.java 2>&1 | grep -v '^Note:' || true +"$J/java" -cp "$out" PoolAbortTest diff --git a/test/stub/android/util/Log.java b/test/stub/android/util/Log.java new file mode 100644 index 0000000..ce423f9 --- /dev/null +++ b/test/stub/android/util/Log.java @@ -0,0 +1,9 @@ +package android.util; + +/** Desktop stand-in for Android's Log, so the network code runs in tests on the computer. */ +public final class Log { + private Log() {} + public static int i(String tag, String msg) { return 0; } + public static int w(String tag, String msg) { return 0; } + public static int e(String tag, String msg) { return 0; } +}