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"?>
|
<?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" />
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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