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"?>
<!-- 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"
package="com.minamp"
android:versionCode="15"
android:versionName="1.14"
android:versionCode="16"
android:versionName="1.14.1"
android:installLocation="internalOnly">
<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.
* 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`
+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
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`.
+1 -1
View File
@@ -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<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);
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 java.util.List<String> urls;
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);
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<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));
k = direct ? 0 : (max <= 0 ? 320 : max);
for (String u : urls) {
+32 -16
View File
@@ -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();
}
}
+13
View File
@@ -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<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
* 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; }
}