Private
Public Access
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:
1 parent
13f29e26c4
commit
a37711afc0
10 files changed
+160
-23
No files matched your search
+3
-3
@@ -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" />
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+10
@@ -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
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user