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 <noreply@anthropic.com>
This commit is contained in:
XoséandClaude Opus 5.5 committed 2026-11-01 19:09:53 +01:00
1 parent 13f29e26c4
commit a37711afc0
10 files changed
+160 -23

No files matched your search

+3 -3
View File
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- build.sh makes one APK per Android level from this file: it sets minSdkVersion and targetSdkVersion for each, <!-- build.sh makes one APK per Android level from this file: it sets minSdkVersion and targetSdkVersion for each,
and versionCode = the one below x 100 + the level (15 -> 1508 for Android 2.2, 1516 for 4.1). --> and versionCode = the one below x 100 + the level (16 -> 1608 for Android 2.2, 1616 for 4.1). -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.minamp" package="com.minamp"
android:versionCode="15" android:versionCode="16"
android:versionName="1.14" android:versionName="1.14.1"
android:installLocation="internalOnly"> android:installLocation="internalOnly">
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" /> <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
+6 -1
View File
@@ -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. 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. * 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 * **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. 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 * 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 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/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 * `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`). (`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 * `test/PreferHomeTest.java` + `test/identity_server.py`: after being away, the home address wins
again as soon as it answers. again as soon as it answers.
* `test/NetTest.java`: the bundled TLS + HTTP client against real servers; `test/HandshakeProfile.java` * `test/NetTest.java`: the bundled TLS + HTTP client against real servers; `test/HandshakeProfile.java`
+2
View File
@@ -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 - 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. Galaxy Mini apps use: changes there affect every app.
- Installs to internal storage (SD-card installs get killed during USB sharing). - 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 - `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, 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`. same key; both APKs are listed in `../tenda-lite/repo/apps.json`.
+1 -1
View File
@@ -341,7 +341,7 @@ public final class Downloads {
if (!direct) part.delete(); // a transcode can't be resumed if (!direct) part.delete(); // a transcode can't be resumed
String session = direct ? null : "minamp-dl-" + Long.toString(System.currentTimeMillis(), 36); String session = direct ? null : "minamp-dl-" + Long.toString(System.currentTimeMillis(), 36);
List<String> urls; List<String> urls;
if (direct) { urls = new ArrayList<String>(); urls.add(lib.directUrl(t)); } if (direct) urls = lib.downloadUrls(t);
else urls = lib.transcodeUrls(t, quality > 0 ? quality : 320, 0, session); else urls = lib.transcodeUrls(t, quality > 0 ? quality : 320, 0, session);
int[] order = direct ? null : Library.variantOrder(); int[] order = direct ? null : Library.variantOrder();
+3 -2
View File
@@ -341,7 +341,8 @@ public class PlayerService extends Service implements Engine.Listener, AudioMana
final long startAt = (direct || download) ? 0 : (posMs / 1000) * 1000; final long startAt = (direct || download) ? 0 : (posMs / 1000) * 1000;
final java.util.List<String> urls; final java.util.List<String> urls;
final int[] order = direct ? null : Library.variantOrder(); final int[] order = direct ? null : Library.variantOrder();
if (direct) { urls = new java.util.ArrayList<String>(); urls.add(lib.directUrl(it)); } if (direct && download) urls = lib.downloadUrls(it);
else if (direct) { urls = new java.util.ArrayList<String>(); urls.add(lib.directUrl(it)); }
else urls = lib.transcodeUrls(it, max, startAt, sess); else urls = lib.transcodeUrls(it, max, startAt, sess);
final String ext = direct ? extFor(it) : "mp3"; final String ext = direct ? extFor(it) : "mp3";
File file = null; File file = null;
@@ -422,7 +423,7 @@ public class PlayerService extends Service implements Engine.Listener, AudioMana
int max = app.currentMaxKbps(); int max = app.currentMaxKbps();
boolean direct = Library.canDirectPlay(next, max); boolean direct = Library.canDirectPlay(next, max);
java.util.List<String> urls; java.util.List<String> urls;
if (direct) { urls = new java.util.ArrayList<String>(); 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)); else urls = lib.transcodeUrls(next, max, 0, "minamp-ahead-" + Long.toString(System.currentTimeMillis(), 36));
k = direct ? 0 : (max <= 0 ? 320 : max); k = direct ? 0 : (max <= 0 ? 320 : max);
for (String u : urls) { for (String u : urls) {
+32 -16
View File
@@ -442,19 +442,25 @@ public final class Http {
// ------------------------------------------------------------------ body streams // ------------------------------------------------------------------ body streams
private static final class FixedStream extends InputStream { 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) { FixedStream(Conn c, long len, boolean keepAlive) {
this.c = c; this.left = len; this.keepAlive = keepAlive; this.c = c; this.left = len; this.keepAlive = keepAlive;
if (len == 0) finish(true); if (len == 0) finish(true);
} }
private void finish(boolean complete) { private synchronized void finish(boolean complete) {
if (closed) return; if (closed) return;
closed = true; closed = true;
if (complete && keepAlive) release(c); else c.close(); 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 { @Override public int read() throws IOException {
byte[] one = new byte[1]; byte[] one = new byte[1];
int n = read(one, 0, 1); int n = read(one, 0, 1);
@@ -481,16 +487,22 @@ public final class Http {
private static final class ChunkedStream extends InputStream { private static final class ChunkedStream extends InputStream {
private final Conn c; private final boolean keepAlive; 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; } 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; if (closed) return;
closed = true; closed = true;
if (complete && keepAlive) release(c); else c.close(); 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 { private boolean nextChunk() throws IOException {
if (eof) return false; if (eof) return false;
String line = readLine(c.in); String line = readLine(c.in);
@@ -542,7 +554,7 @@ public final class Http {
} }
private static final class EofStream extends InputStream { 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; } EofStream(Conn c) { this.c = c; }
@Override public int read() throws IOException { @Override public int read() throws IOException {
@@ -559,21 +571,25 @@ public final class Http {
return n; return n;
} }
@Override public void close() { @Override public synchronized void close() {
if (!closed) { closed = true; c.close(); } if (!closed) { closed = true; c.close(); }
} }
}
/** Close one in-flight response's socket from another thread (used to abort streams). */ synchronized void abort() {
public static void abort(Response r) { if (closed) return;
if (r == null) return; closed = true;
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) {
try { c.socket.close(); } catch (Exception ignored) {} 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();
}
} }
+13
View File
@@ -202,6 +202,19 @@ public final class Library {
return server.url(t.partKey); 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<String> downloadUrls(Item t) throws IOException {
List<String> l = new ArrayList<String>();
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 * 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 * device gets a bare 400 ("unable to find a matching profile"). These are request shapes
+81
View File
@@ -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);
}
}
+10
View File
@@ -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
+9
View File
@@ -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; }
}