Minamp 1.2

- Transcoding: send client identities Plex has a profile for (unknown ones get
  400 "unable to find a matching profile"), with a fresh session id per variant
- Stream proxy: fetch transcodes once into a temp file and serve every player
  request from it, so reconnects no longer restart the transcode (503s, tracks
  cut short); original files still pass Range requests through
- Sign-in: remember the pending link code so it survives Minamp being closed
  while the code is entered elsewhere; add "I've entered it - check now"
- Diagnostics: in-app connection log (DiagLog) with a streaming test and save
  to SD card; tokens are never logged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xockoandClaude Opus 5.5 committed 2026-10-14 20:38:17 +02:00
1 parent 0a2ac901bd
commit a4b875ef34
12 files changed
+701 -196

No files matched your search

+105 -1
View File
@@ -397,6 +397,7 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
"Library: " + app.sectionTitle(),
"Server: " + (s == null ? "-" : s.name),
"Connection: " + conn,
"Connection log & streaming test",
"Sign out",
};
new AlertDialog.Builder(this)
@@ -424,7 +425,8 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
});
}
break;
case 6: confirmSignOut(); break;
case 6: showDiagnostics(); break;
case 7: confirmSignOut(); break;
}
}
}).show();
@@ -432,6 +434,108 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
protected void onConnectionChanged() {}
// ------------------------------------------------------------------ diagnostics
/** Connection log with "test streaming" and "save to SD card", for reporting problems. */
protected void showDiagnostics() {
final android.widget.ScrollView sv = new android.widget.ScrollView(this);
final TextView tv = new TextView(this);
tv.setTextSize(9);
tv.setTextColor(0xFFDDDDDD);
tv.setTypeface(android.graphics.Typeface.MONOSPACE);
int p = Ui.dp(this, 6);
tv.setPadding(p, p, p, p);
String log = com.minamp.net.DiagLog.dump();
tv.setText(log.length() > 0 ? log : "Nothing logged yet.");
sv.addView(tv);
AlertDialog.Builder b = new AlertDialog.Builder(this)
.setTitle("Connection log")
.setView(sv)
.setNegativeButton("Save to SD card", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) { saveDiagnostics(); }
})
.setPositiveButton("Close", null);
if (app.library != null) {
b.setNeutralButton("Test streaming", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) { testStreaming(); }
});
}
AlertDialog dlg = b.create();
dlg.show();
sv.post(new Runnable() { public void run() { sv.fullScroll(View.FOCUS_DOWN); } });
}
private void saveDiagnostics() {
try {
if (!android.os.Environment.MEDIA_MOUNTED.equals(android.os.Environment.getExternalStorageState())) {
toast("No SD card available");
return;
}
java.io.File f = new java.io.File(android.os.Environment.getExternalStorageDirectory(), "Minamp-log.txt");
java.io.FileOutputStream o = new java.io.FileOutputStream(f);
String head = "Minamp " + com.minamp.plex.Plex.version + " on " + android.os.Build.MODEL
+ ", Android " + android.os.Build.VERSION.RELEASE + "\n";
o.write((head + com.minamp.net.DiagLog.dump()).getBytes("UTF-8"));
o.close();
toast("Saved as Minamp-log.txt on the SD card");
} catch (Exception e) {
toast("Couldn't save: " + msg(e));
}
}
/** Asks the server about every transcode variant for one track, and logs exactly what it says. */
private void testStreaming() {
final Library lib = lib();
final Item current = PlayQueue.get().current();
toast("Testing\u2026 (about 30 s)");
run(new Task<Object>() {
public Object work() throws Exception {
com.minamp.net.DiagLog.log("=== streaming test ===");
Item t = current;
if (t == null) {
List<Item> r = lib.libraryRadio();
if (r.isEmpty()) throw new Exception("no tracks in the library");
t = r.get(0);
}
lib.ensureMedia(t);
com.minamp.net.DiagLog.log("track: " + t.title + " [" + t.codec + "/" + t.container + " " + t.bitrate + " kbps] via "
+ (lib.server.active() == null ? "?" : Server.kindLabel(lib.server.active().kind)));
int kbps = app.currentMaxKbps();
if (kbps <= 0) kbps = 320;
for (int v = 0; v < Library.VARIANTS; v++) {
com.minamp.net.DiagLog.log("variant " + v + " (" + Library.VARIANT_NAMES[v] + ") decision: " + lib.decision(t, kbps, v));
String session = "minamp-test-" + System.currentTimeMillis() + "-" + v;
String url = lib.transcodeUrl(t, kbps, 0, session, v);
com.minamp.net.Http.Response r = null;
try {
r = com.minamp.net.Http.get(url, null, 30000);
if (r.code < 400) {
byte[] buf = new byte[4096];
int n = r.body.read(buf);
boolean mp3 = n > 3 && ((buf[0] == 'I' && buf[1] == 'D' && buf[2] == '3') || ((buf[0] & 0xFF) == 0xFF && (buf[1] & 0xE0) == 0xE0));
com.minamp.net.DiagLog.log("variant " + v + " start: " + r.code + ", " + r.header("content-type")
+ ", first bytes " + (mp3 ? "look like MP3" : "are NOT MP3"));
} else {
com.minamp.net.DiagLog.log("variant " + v + " start: " + r.code + " " + com.minamp.plex.Plex.snippet(r.readString()));
}
} catch (Exception e) {
com.minamp.net.DiagLog.log("variant " + v + " start failed: " + com.minamp.net.Threads.describe(e));
} finally {
com.minamp.net.Http.abort(r);
lib.stopTranscode(session);
}
}
com.minamp.net.DiagLog.log("=== end of test ===");
return null;
}
public void done(Object o) { showDiagnostics(); }
public void fail(Exception e) {
com.minamp.net.DiagLog.log("streaming test failed: " + msg(e));
showDiagnostics();
}
});
}
private static String qualityLabel(int kbps) {
for (int i = 0; i < App.QUALITIES.length; i++) if (App.QUALITIES[i] == kbps) return App.QUALITY_LABELS[i];
return kbps + " kbps";
+16 -5
View File
@@ -125,6 +125,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private String routeId;
private int retries;
private int[] variantOrder; // transcode variant behind each URL of the current route
private int acceptedVariant = -1;
private String refusal; // what the server said when it refused the current stream
private int refusalCode;
private int failuresInRow;
@@ -166,6 +167,11 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
});
}
};
File cache = null;
try {
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) cache = getExternalCacheDir();
} catch (Exception ignored) {}
proxy.cacheDir = cache != null ? cache : getCacheDir();
try { proxy.start(); } catch (IOException e) { /* reported when playback starts */ }
try {
TelephonyManager tm = (TelephonyManager) getSystemService(TELEPHONY_SERVICE);
@@ -360,6 +366,8 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private void rememberVariantOf(int[] order, int index) {
if (order == null || index < 0 || index >= order.length) return;
int v = order[index];
if (order == variantOrder) acceptedVariant = v;
com.minamp.net.DiagLog.log("server accepted transcode variant " + v + " (" + Library.VARIANT_NAMES[v] + ")");
if (v != Library.preferredVariant) {
Library.preferredVariant = v;
App.get().prefs.edit().putInt("tcVariant", v).commit();
@@ -387,7 +395,9 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
routeId = proxy.newId();
refusal = null;
refusalCode = 0;
mp.setDataSource(proxy.register(routeId, urls, ext));
acceptedVariant = -1;
// transcodes are fetched once and buffered, so player reconnects can't restart them
mp.setDataSource(proxy.register(routeId, urls, ext, transcoding));
}
setState(BUFFERING, "Buffering…");
mp.prepareAsync();
@@ -520,7 +530,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (track == null) return;
if (ms < 0) ms = 0;
if (track.duration > 0 && ms > track.duration - 1000) ms = Math.max(0, track.duration - 1000);
if (mp != null && prepared && (!transcoding || fromFile)) {
if (mp != null && prepared && (!transcoding || fromFile || proxy.isComplete(routeId))) {
try { mp.seekTo((int) ms); } catch (Exception ignored) {}
lastPos = ms;
notifyListeners();
@@ -551,12 +561,13 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private void releasePlayer() {
if (routeId != null) { proxy.unregister(routeId); routeId = null; }
if (session != null) {
final String s = session;
if (session != null && acceptedVariant >= 0) {
final String s = Library.variantSession(session, acceptedVariant);
final App app = App.get();
reporter.execute(new Runnable() { public void run() { if (app.library != null) app.library.stopTranscode(s); } });
session = null;
}
session = null;
acceptedVariant = -1;
if (mp != null) {
try { mp.reset(); } catch (Exception ignored) {}
try { mp.release(); } catch (Exception ignored) {}
+87 -31
View File
@@ -47,7 +47,9 @@ public class SetupActivity extends BaseActivity {
findViewById(R.id.btn_signin).setOnClickListener(new View.OnClickListener() { public void onClick(View v) { startPin(); } });
findViewById(R.id.btn_manual).setOnClickListener(new View.OnClickListener() { public void onClick(View v) { show(manual); } });
findViewById(R.id.btn_pin_cancel).setOnClickListener(new View.OnClickListener() { public void onClick(View v) { pinSeq++; show(welcome); } });
findViewById(R.id.btn_pin_cancel).setOnClickListener(new View.OnClickListener() { public void onClick(View v) { pinSeq++; clearPendingPin(); show(welcome); } });
findViewById(R.id.btn_pin_check).setOnClickListener(new View.OnClickListener() { public void onClick(View v) { checkPinNow(pinSeq); } });
findViewById(R.id.btn_setup_log).setOnClickListener(new View.OnClickListener() { public void onClick(View v) { showDiagnostics(); } });
findViewById(R.id.btn_manual_back).setOnClickListener(new View.OnClickListener() { public void onClick(View v) { show(welcome); } });
findViewById(R.id.btn_manual_go).setOnClickListener(new View.OnClickListener() { public void onClick(View v) { connectManual(); } });
findViewById(R.id.btn_choose_back).setOnClickListener(new View.OnClickListener() {
@@ -60,7 +62,7 @@ public class SetupActivity extends BaseActivity {
loadSections(app.server, null);
} else if (mode == MODE_SERVER && account != null) {
loadServers(account);
} else {
} else if (!resumePendingPin()) {
show(welcome);
}
}
@@ -96,21 +98,21 @@ public class SetupActivity extends BaseActivity {
// ------------------------------------------------------------------ PIN sign-in
private int pinChecks;
private boolean pinCheckRunning;
private void startPin() {
showBusy("Contacting plex.tv…\n(the first secure connection takes a few seconds on this phone)");
clearPendingPin();
showBusy("Contacting plex.tv\u2026\n(the first secure connection takes a few seconds on this phone)");
final int my = ++pinSeq;
run(new Task<Plex.Pin>() {
public Plex.Pin work() throws Exception { return Plex.createPin(); }
public void done(Plex.Pin p) {
if (my != pinSeq) return;
pinId = p.id;
pinStarted = System.currentTimeMillis();
StringBuilder spaced = new StringBuilder();
for (int i = 0; i < p.code.length(); i++) { if (i > 0) spaced.append(' '); spaced.append(p.code.charAt(i)); }
pinCode.setText(spaced.toString());
pinStatus.setText("Waiting for you to link…");
show(pinPage);
pollPin(my);
// Remember the code: the phone may close Minamp while you type it in elsewhere.
app.prefs.edit().putLong("pinId", p.id).putString("pinCode", p.code)
.putLong("pinStart", System.currentTimeMillis()).commit();
showPin(p.id, p.code, System.currentTimeMillis());
}
public void fail(Exception e) {
if (my != pinSeq) return;
@@ -120,30 +122,84 @@ public class SetupActivity extends BaseActivity {
});
}
private void pollPin(final int my) {
/** Resume waiting for a code created earlier (Minamp was closed while the user typed it in). */
private boolean resumePendingPin() {
long id = app.prefs.getLong("pinId", 0);
String code = app.prefs.getString("pinCode", null);
long start = app.prefs.getLong("pinStart", 0);
if (id == 0 || code == null || System.currentTimeMillis() - start > 14 * 60 * 1000) {
clearPendingPin();
return false;
}
com.minamp.net.DiagLog.log("resuming sign-in code " + code);
showPin(id, code, start);
return true;
}
private void clearPendingPin() {
app.prefs.edit().remove("pinId").remove("pinCode").remove("pinStart").commit();
}
private void showPin(long id, String code, long started) {
pinId = id;
pinStarted = started;
pinChecks = 0;
StringBuilder spaced = new StringBuilder();
for (int i = 0; i < code.length(); i++) { if (i > 0) spaced.append(' '); spaced.append(code.charAt(i)); }
pinCode.setText(spaced.toString());
pinStatus.setText("Waiting for you to link\u2026");
show(pinPage);
int my = ++pinSeq;
checkPinNow(my);
}
@Override
protected void onResume() {
super.onResume();
// back from typing the code somewhere else: check straight away
if (pinPage != null && pinPage.getVisibility() == View.VISIBLE && !pinCheckRunning) checkPinNow(pinSeq);
}
private void schedulePinCheck(final int my) {
app.handler.postDelayed(new Runnable() {
public void run() {
if (my != pinSeq || destroyed) return;
if (System.currentTimeMillis() - pinStarted > 14 * 60 * 1000) {
pinStatus.setText("Code expired — go back and try again");
public void run() { checkPinNow(my); }
}, 3000);
}
private void checkPinNow(final int my) {
if (my != pinSeq || destroyed || pinCheckRunning) return;
long age = System.currentTimeMillis() - pinStarted;
if (age > 14 * 60 * 1000) {
pinStatus.setText("Code expired \u2014 press Cancel and sign in again");
clearPendingPin();
return;
}
pinCheckRunning = true;
run(new Task<String>() {
public String work() throws Exception { return Plex.checkPin(pinId); }
public void done(String token) {
pinCheckRunning = false;
if (my != pinSeq) return;
pinChecks++;
if (token == null) {
long left = (14 * 60 * 1000 - (System.currentTimeMillis() - pinStarted)) / 60000;
pinStatus.setText("Not linked yet \u00b7 checked " + pinChecks + "\u00d7 \u00b7 " + left + " min left");
schedulePinCheck(my);
return;
}
SetupActivity.this.run(new Task<String>() {
public String work() throws Exception { return Plex.checkPin(pinId); }
public void done(String token) {
if (my != pinSeq) return;
if (token == null) { pollPin(my); return; }
pinSeq++;
signedIn(token);
}
public void fail(Exception e) {
if (my != pinSeq) return;
pinStatus.setText("Retrying… (" + msg(e) + ")");
pollPin(my);
}
});
pinStatus.setText("Linked!");
pinSeq++;
clearPendingPin();
signedIn(token);
}
}, 2500);
public void fail(Exception e) {
pinCheckRunning = false;
if (my != pinSeq) return;
pinChecks++;
pinStatus.setText("Check " + pinChecks + " failed: " + msg(e) + " \u2014 retrying");
schedulePinCheck(my);
}
});
}
private void signedIn(final String account) {
+47
View File
@@ -0,0 +1,47 @@
package com.minamp.net;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/**
* In-memory diagnostics log (last few hundred lines): every request with its status and timing,
* sign-in steps, stream attempts. Viewable in the app and savable to the SD card, so a problem
* on a real server can be reported precisely. Tokens are never logged.
*/
public final class DiagLog {
private static final int MAX = 400;
private static final List<String> lines = new ArrayList<String>();
private static final SimpleDateFormat fmt = new SimpleDateFormat("HH:mm:ss", Locale.US);
private DiagLog() {}
public static void log(String msg) {
String line;
synchronized (fmt) { line = fmt.format(new Date()) + " " + msg; }
synchronized (lines) {
lines.add(line);
if (lines.size() > MAX) lines.remove(0);
}
}
public static String dump() {
StringBuilder sb = new StringBuilder();
synchronized (lines) {
for (int i = 0; i < lines.size(); i++) sb.append(lines.get(i)).append('\n');
}
return sb.toString();
}
public static void clear() {
synchronized (lines) { lines.clear(); }
}
/** URL without the token value, safe to show and share. */
public static String redact(String url) {
if (url == null) return "";
return url.replaceAll("(X-Plex-Token=)[^&]*", "$1***");
}
}
+14 -1
View File
@@ -283,7 +283,20 @@ public final class Http {
public static Response request(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs) throws IOException {
for (int redirects = 0; ; redirects++) {
Response r = requestOnce(method, url, headers, body, timeoutMs);
long t0 = System.currentTimeMillis();
Response r;
try {
r = requestOnce(method, url, headers, body, timeoutMs);
} catch (IOException e) {
DiagLog.log(method + " " + DiagLog.redact(url) + " -> FAILED after " + (System.currentTimeMillis() - t0) + "ms: " + Threads.describe(e));
throw e;
} catch (RuntimeException e) {
DiagLog.log(method + " " + DiagLog.redact(url) + " -> FAILED: " + Threads.describe(e));
throw e;
}
if (!url.contains("/photo/:/transcode")) { // artwork would drown out everything else
DiagLog.log(method + " " + DiagLog.redact(url) + " -> " + r.code + " (" + (System.currentTimeMillis() - t0) + "ms)");
}
if ((r.code == 301 || r.code == 302 || r.code == 307 || r.code == 308) && redirects < 4
&& ("GET".equals(method) || "HEAD".equals(method))) {
String loc = r.header("location");
+290 -104
View File
@@ -1,9 +1,12 @@
package com.minamp.net;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
@@ -17,8 +20,13 @@ import java.util.Map;
/**
* Loopback HTTP server that hands the platform MediaPlayer a plain http://127.0.0.1 URL and
* fetches the real stream through {@link Http} (our own TLS, plex.direct, relay...).
* It also turns chunked transcoder output into a simple read-until-close response, which
* Android 2.x's media stack copes with much better.
*
* Two kinds of route:
* - pass-through (original files): each player request, Range included, is forwarded.
* - buffered (transcodes): the server is asked exactly once; the bytes go to a temp file and
* every player request (reconnects included) is served from it. Asking Plex's transcoder
* twice for one session tears the first down, which shows up as 503s and cut-off tracks.
* Each route may carry several alternative URLs; the first the server accepts wins.
*/
public final class StreamProxy implements Runnable {
@@ -27,14 +35,8 @@ public final class StreamProxy implements Runnable {
private volatile boolean running;
private int counter;
private final Map<String, Route> routes = new HashMap<String, Route>();
/** One stream, with fallback URLs tried in order until the server accepts one. */
private static final class Route {
final List<String> urls;
int chosen = -1; // index that worked; later requests (seeks) go straight to it
Route(List<String> urls) { this.urls = urls; }
}
private final Map<String, List<Http.Response>> live = new HashMap<String, List<Http.Response>>();
/** Where buffered streams are written (set by the app; temp dir otherwise). */
public volatile File cacheDir;
public interface Listener {
/** Every URL was refused (or the proxy failed); code is the last HTTP status, or 0. */
@@ -44,6 +46,25 @@ public final class StreamProxy implements Runnable {
}
public volatile Listener listener;
private static final class Route {
final String id;
final List<String> urls;
final boolean buffered;
int chosen = -1; // index that worked
final List<Http.Response> live = new ArrayList<Http.Response>();
// buffered state (guarded by this)
boolean started, done, cancelled;
long written;
int failCode;
String failMessage;
File file;
Http.Response upstream;
Route(String id, List<String> urls, boolean buffered) {
this.id = id; this.urls = urls; this.buffered = buffered;
}
}
public synchronized void start() throws IOException {
if (running) return;
server = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1"));
@@ -55,45 +76,55 @@ public final class StreamProxy implements Runnable {
public synchronized void shutdown() {
running = false;
try { if (server != null) server.close(); } catch (IOException ignored) {}
synchronized (routes) {
for (String id : new ArrayList<String>(live.keySet())) abortLocked(id);
routes.clear();
}
List<String> ids;
synchronized (routes) { ids = new ArrayList<String>(routes.keySet()); }
for (int i = 0; i < ids.size(); i++) unregister(ids.get(i));
}
public int port() { return server == null ? -1 : server.getLocalPort(); }
/** @return the local URL to give MediaPlayer, ending in a fake extension it may sniff. */
public String register(String id, String remoteUrl, String ext) {
List<String> l = new ArrayList<String>();
l.add(remoteUrl);
return register(id, l, ext);
}
public String register(String id, List<String> remoteUrls, String ext) {
synchronized (routes) {
routes.put(id, new Route(new ArrayList<String>(remoteUrls)));
}
return "http://127.0.0.1:" + port() + "/s/" + id + "/stream." + ext;
}
public String newId() {
synchronized (routes) { return Integer.toString(++counter) + "x" + Long.toString(System.currentTimeMillis() % 100000, 36); }
}
/** Forget a route and cut any upstream connections still feeding it. */
public void unregister(String id) {
if (id == null) return;
synchronized (routes) {
routes.remove(id);
abortLocked(id);
}
public String register(String id, String remoteUrl, String ext) {
List<String> l = new ArrayList<String>();
l.add(remoteUrl);
return register(id, l, ext, false);
}
private void abortLocked(String id) {
List<Http.Response> l = live.remove(id);
if (l == null) return;
for (int i = 0; i < l.size(); i++) Http.abort(l.get(i));
/** @return the local URL to give MediaPlayer, ending in an extension it may sniff. */
public String register(String id, List<String> remoteUrls, String ext, boolean buffered) {
synchronized (routes) {
routes.put(id, new Route(id, new ArrayList<String>(remoteUrls), buffered));
}
return "http://127.0.0.1:" + port() + "/s/" + id + "/stream." + ext;
}
/** Forget a route, cut its upstream connections and delete its buffer file. */
public void unregister(String id) {
if (id == null) return;
Route r;
synchronized (routes) { r = routes.remove(id); }
if (r == null) return;
final File f;
synchronized (r) {
r.cancelled = true;
Http.abort(r.upstream);
for (int i = 0; i < r.live.size(); i++) Http.abort(r.live.get(i));
r.live.clear();
r.notifyAll();
f = r.file;
}
if (f != null) {
// give readers a moment to notice before the file goes away
Threads.start(new Runnable() {
public void run() {
try { Thread.sleep(1500); } catch (InterruptedException ignored) {}
f.delete();
}
}, "proxy-cleanup");
}
}
public void run() {
@@ -111,8 +142,9 @@ public final class StreamProxy implements Runnable {
}
}
// ------------------------------------------------------------------ request handling
private void handle(Socket s) {
Http.Response up = null;
String id = null;
try {
s.setSoTimeout(60000);
@@ -129,7 +161,6 @@ public final class StreamProxy implements Runnable {
String[] parts = reqLine.split(" ");
String method = parts.length > 0 ? parts[0] : "GET";
String path = parts.length > 1 ? parts[1] : "/";
// /s/<id>/stream.mp3
if (path.startsWith("/s/")) {
int slash = path.indexOf('/', 3);
id = slash < 0 ? path.substring(3) : path.substring(3, slash);
@@ -137,58 +168,88 @@ public final class StreamProxy implements Runnable {
Route route;
synchronized (routes) { route = id == null ? null : routes.get(id); }
if (route == null) {
out.write("HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".getBytes("ISO-8859-1"));
out.flush();
writeEmpty(out, 404, "Not Found");
return;
}
Map<String, String> h = new LinkedHashMap<String, String>();
h.put("Accept", "*/*");
String range = reqHeaders.get("range");
if (range != null) h.put("Range", range);
boolean head = "HEAD".equals(method);
int first, last;
synchronized (routes) {
first = route.chosen >= 0 ? route.chosen : 0;
last = route.chosen >= 0 ? route.chosen : route.urls.size() - 1;
}
String refusal = null;
for (int i = first; i <= last; i++) {
up = Http.request(head ? "HEAD" : "GET", route.urls.get(i), h, null, 30000);
if (up.code < 400 || up.code == 416) {
boolean firstTime;
synchronized (routes) { firstTime = route.chosen < 0; route.chosen = i; }
Listener l = listener;
if (firstTime && l != null) l.onAccepted(id, i);
break;
}
// refused: remember why, try the next variant
String body = "";
try { body = head ? "" : new String(up.readAll(), "UTF-8"); } catch (IOException ignored) {}
refusal = "Server refused the stream (" + up.code + ")" + (body.trim().length() > 0 ? ": " + com.minamp.plex.Plex.snippet(body) : "");
if (i < last) { up.close(); up = null; }
}
if (up.code >= 400 && up.code != 416) {
Listener l = listener;
if (l != null) l.onError(id, up.code, refusal);
}
synchronized (routes) {
if (!routes.containsKey(id)) { Http.abort(up); return; }
List<Http.Response> l = live.get(id);
if (l == null) { l = new ArrayList<Http.Response>(); live.put(id, l); }
l.add(up);
}
if (route.buffered) serveBuffered(route, out, head, reqHeaders.get("range"), path);
else servePassThrough(route, out, head, reqHeaders.get("range"), path);
} catch (IOException e) {
// player went away, track skipped, or the network dropped: just close
} catch (Throwable t) {
DiagLog.log("proxy error: " + Threads.describe(t));
Listener l = listener;
if (l != null && id != null) l.onError(id, 0, Threads.describe(t));
} finally {
try { s.close(); } catch (IOException ignored) {}
}
}
private static void writeEmpty(OutputStream out, int code, String reason) throws IOException {
out.write(("HTTP/1.0 " + code + " " + reason + "\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").getBytes("ISO-8859-1"));
out.flush();
}
private static final class RefusedException extends IOException {
final int code;
RefusedException(int code, String msg) { super(msg); this.code = code; }
}
/** Tries the route's URLs in order; returns the accepted response or throws with the server's words. */
private Http.Response openUpstream(Route route, String method, String range, int first, int last) throws IOException {
Map<String, String> h = new LinkedHashMap<String, String>();
h.put("Accept", "*/*");
if (range != null) h.put("Range", range);
String refusal = null;
int code = 0;
for (int i = first; i <= last; i++) {
Http.Response up = Http.request(method, route.urls.get(i), h, null, 30000);
if (up.code < 400 || up.code == 416) {
Listener l = listener;
boolean firstTime;
synchronized (route) { firstTime = route.chosen < 0; route.chosen = i; }
if (firstTime && l != null) l.onAccepted(route.id, i);
return up;
}
String body = "";
try { body = "HEAD".equals(method) ? "" : new String(up.readAll(), "UTF-8"); } catch (IOException ignored) {}
up.close();
code = up.code;
refusal = "Server refused the stream (" + up.code + ")" + (body.trim().length() > 0 ? ": " + com.minamp.plex.Plex.snippet(body) : "");
DiagLog.log("stream variant " + i + " refused: " + refusal);
}
throw new RefusedException(code, refusal);
}
// ---- pass-through ----
private void servePassThrough(Route route, OutputStream out, boolean head, String range, String path) throws IOException {
int first, last;
synchronized (route) {
first = route.chosen >= 0 ? route.chosen : 0;
last = route.chosen >= 0 ? route.chosen : route.urls.size() - 1;
}
Http.Response up;
try {
up = openUpstream(route, head ? "HEAD" : "GET", range, first, last);
} catch (RefusedException e) {
Listener l = listener;
if (l != null) l.onError(route.id, e.code, e.getMessage());
writeEmpty(out, e.code, "Refused");
return;
}
synchronized (route) {
if (route.cancelled) { Http.abort(up); return; }
route.live.add(up);
}
try {
StringBuilder resp = new StringBuilder(256);
boolean refused = up.code >= 400 && up.code != 416;
boolean known = !refused && up.contentLength >= 0 && !up.chunked;
boolean known = up.contentLength >= 0 && !up.chunked;
resp.append(known ? "HTTP/1.1 " : "HTTP/1.0 ").append(up.code).append(' ').append(reason(up.code)).append("\r\n");
String ct = up.header("content-type");
if (ct == null || ct.startsWith("application/octet") || ct.startsWith("text/")) ct = guessType(path);
if (up.code >= 400) ct = "text/plain";
resp.append("Content-Type: ").append(ct).append("\r\n");
if (known) resp.append("Content-Length: ").append(up.contentLength).append("\r\n");
if (refused) resp.append("Content-Length: 0\r\n");
String cr = up.header("content-range");
if (cr != null) resp.append("Content-Range: ").append(cr).append("\r\n");
String ar = up.header("accept-ranges");
@@ -196,35 +257,160 @@ public final class StreamProxy implements Runnable {
resp.append("Connection: close\r\n\r\n");
out.write(resp.toString().getBytes("ISO-8859-1"));
out.flush();
if (head || up.code >= 400) { up.close(); up = null; return; }
if (head) { up.close(); return; }
byte[] buf = new byte[16384];
int n;
while ((n = up.body.read(buf)) > 0) {
out.write(buf, 0, n);
}
while ((n = up.body.read(buf)) > 0) out.write(buf, 0, n);
out.flush();
up.close();
synchronized (routes) {
List<Http.Response> l = live.get(id);
if (l != null) l.remove(up);
}
up = null;
} catch (IOException e) {
// player went away, track skipped, or the network dropped: just close
} catch (Throwable t) {
Listener l = listener;
if (l != null && id != null) l.onError(id, 0, Threads.describe(t));
} finally {
if (up != null) Http.abort(up);
if (id != null && up != null) {
synchronized (routes) {
List<Http.Response> l = live.get(id);
if (l != null) l.remove(up);
Http.abort(up);
synchronized (route) { route.live.remove(up); }
}
}
// ---- buffered ----
private void startFetch(final Route route) {
synchronized (route) {
if (route.started) return;
route.started = true;
File dir = cacheDir != null ? cacheDir : new File(System.getProperty("java.io.tmpdir", "/tmp"));
route.file = new File(dir, "stream-" + route.id + ".mp3");
}
Threads.start(new Runnable() {
public void run() {
Http.Response up = null;
FileOutputStream fo = null;
try {
up = openUpstream(route, "GET", null, 0, route.urls.size() - 1);
synchronized (route) {
if (route.cancelled) return;
route.upstream = up;
}
fo = new FileOutputStream(route.file);
byte[] buf = new byte[16384];
int n;
while ((n = up.body.read(buf)) > 0) {
fo.write(buf, 0, n);
fo.flush();
synchronized (route) {
if (route.cancelled) break;
route.written += n;
route.notifyAll();
}
}
up.close();
synchronized (route) { route.done = true; route.notifyAll(); }
} catch (RefusedException e) {
boolean cancelled;
synchronized (route) {
route.failCode = e.code; route.failMessage = e.getMessage(); route.done = true;
cancelled = route.cancelled;
route.notifyAll();
}
Listener l = listener;
if (l != null && !cancelled) l.onError(route.id, e.code, e.getMessage());
} catch (Throwable t) {
boolean cancelled;
synchronized (route) {
if (route.failMessage == null) route.failMessage = Threads.describe(t);
route.done = true;
cancelled = route.cancelled;
route.notifyAll();
}
if (!cancelled) DiagLog.log("stream download stopped: " + Threads.describe(t));
} finally {
if (fo != null) try { fo.close(); } catch (IOException ignored) {}
if (up != null) Http.abort(up);
}
}
try { s.close(); } catch (IOException ignored) {}
}, "proxy-fetch");
}
private void serveBuffered(Route route, OutputStream out, boolean head, String range, String path) throws IOException {
startFetch(route);
long wantFrom = 0;
boolean done;
long written;
File file;
synchronized (route) {
long deadline = System.currentTimeMillis() + 60000;
while (!route.cancelled && route.written == 0 && !route.done) {
long left = deadline - System.currentTimeMillis();
if (left <= 0) break;
try { route.wait(left); } catch (InterruptedException e) { return; }
}
if (route.cancelled) { writeEmpty(out, 404, "Gone"); return; }
if (route.written == 0) {
int code = route.failCode > 0 ? route.failCode : 503;
writeEmpty(out, code, "Unavailable");
return;
}
done = route.done;
written = route.written;
file = route.file;
}
// Ranges only once the whole stream is here (the length isn't known before that).
if (range != null && done && range.startsWith("bytes=")) {
try {
String r = range.substring(6);
int dash = r.indexOf('-');
wantFrom = Long.parseLong(r.substring(0, dash).trim());
} catch (Exception e) { wantFrom = 0; }
if (wantFrom >= written) { writeEmpty(out, 416, "Range Not Satisfiable"); return; }
}
StringBuilder resp = new StringBuilder(200);
if (done) {
resp.append(wantFrom > 0 ? "HTTP/1.1 206 Partial Content\r\n" : "HTTP/1.1 200 OK\r\n");
resp.append("Content-Length: ").append(written - wantFrom).append("\r\n");
resp.append("Accept-Ranges: bytes\r\n");
if (wantFrom > 0) resp.append("Content-Range: bytes ").append(wantFrom).append('-').append(written - 1).append('/').append(written).append("\r\n");
} else {
resp.append("HTTP/1.0 200 OK\r\n"); // length unknown: read until the connection closes
}
resp.append("Content-Type: ").append(guessType(path)).append("\r\nConnection: close\r\n\r\n");
out.write(resp.toString().getBytes("ISO-8859-1"));
out.flush();
if (head) return;
RandomAccessFile raf = new RandomAccessFile(file, "r");
try {
long pos = wantFrom;
byte[] buf = new byte[16384];
while (true) {
long avail;
boolean finished;
synchronized (route) {
while (!route.cancelled && route.written <= pos && !route.done) {
try { route.wait(5000); } catch (InterruptedException e) { return; }
}
if (route.cancelled) return;
avail = route.written - pos;
finished = route.done;
}
if (avail <= 0) {
if (finished) break;
continue;
}
raf.seek(pos);
int n = raf.read(buf, 0, (int) Math.min(buf.length, avail));
if (n <= 0) break;
out.write(buf, 0, n);
pos += n;
}
out.flush();
} finally {
raf.close();
}
}
/** True once a buffered route has its whole stream on disk (seeks can stay local then). */
public boolean isComplete(String id) {
Route r;
synchronized (routes) { r = id == null ? null : routes.get(id); }
if (r == null) return false;
synchronized (r) { return r.done && r.failMessage == null && r.written > 0; }
}
private static String reason(int code) {
+70 -38
View File
@@ -173,11 +173,14 @@ public final class Library {
}
/**
* Plex servers differ in which form of the music transcoder request they accept (some answer
* 400 to one and stream fine with another), so there are several known variants. The proxy
* tries them in order; the one that worked is remembered and tried first next time.
* 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
* known to work against real servers, tried in order; the one that works is remembered.
* Every variant gets its own session id: re-using one tears down the previous transcode
* (503s, tracks cut short).
*/
public static final int VARIANTS = 4;
public static final String[] VARIANT_NAMES = {"web player", "web player + MP3 profile", "speaker profile", "generic audio"};
public static volatile int preferredVariant = 0;
public static int[] variantOrder() {
@@ -187,54 +190,83 @@ public final class Library {
return order;
}
public static String variantSession(String session, int variant) { return session + "-v" + variant; }
/** MP3 transcode URLs over plain progressive HTTP starting at offsetMs, in {@link #variantOrder()} order. */
public java.util.List<String> transcodeUrls(Item t, int kbps, long offsetMs, String session) throws IOException {
int[] order = variantOrder();
java.util.List<String> out = new ArrayList<String>();
for (int i = 0; i < order.length; i++) out.add(transcodeUrl(t, kbps, offsetMs, session, order[i]));
for (int i = 0; i < order.length; i++) out.add(transcodeUrl(t, kbps, offsetMs, variantSession(session, order[i]), order[i]));
return out;
}
String transcodeUrl(Item t, int kbps, long offsetMs, String session, int variant) throws IOException {
public String transcodeUrl(Item t, int kbps, long offsetMs, String session, int variant) throws IOException {
if (kbps <= 0) kbps = 320;
String target = "add-transcode-target(type=musicProfile&context=streaming&protocol=http&container=mp3&audioCodec=mp3)";
String profile;
String platform;
String base = "/music/:/transcode/universal/start.mp3";
String mp3Target = "add-transcode-target(replace=true&type=musicProfile&context=streaming&protocol=http&container=mp3&audioCodec=mp3)";
String bitrateLimit = "+add-limitation(scope=musicCodec&scopeName=mp3&type=upperBound&name=audio.bitrate&value=" + kbps
+ "&onlyTranscodes=true&replace=true)";
StringBuilder q = new StringBuilder();
String base;
q.append("path=").append(Plex.enc("/library/metadata/" + t.ratingKey));
switch (variant) {
case 0: // what Plex's own web player identifies as, with an explicit MP3 target
platform = "Chrome";
profile = target + "+add-limitation(scope=musicCodec&scopeName=*&type=upperBound&name=audio.bitrate&value=" + kbps + ")";
case 0:
// Plex Web's own identity and the minimal parameter set it uses for a progressive MP3
base = "/music/:/transcode/universal/start.mp3";
q.append("&protocol=http&mediaIndex=0&partIndex=0&audioCodec=mp3")
.append("&directPlay=0&directStream=1&fastSeek=1&musicBitrate=").append(kbps);
break;
case 1: // generic client profile
platform = "Generic";
profile = target + "+add-limitation(scope=musicCodec&scopeName=mp3&type=upperBound&name=audio.bitrate&value=" + kbps + ")";
case 1:
// same identity, forcing a real MP3 encode at our bitrate via a declared target
base = "/music/:/transcode/universal/start.mp3";
q.append("&protocol=http&mediaIndex=0&partIndex=0")
.append("&directPlay=0&directStream=0&fastSeek=1&musicBitrate=").append(kbps)
.append("&X-Plex-Client-Profile-Extra=").append(Plex.enc(mp3Target + bitrateLimit));
break;
case 2: // older servers' audio endpoint
platform = "Chrome";
profile = target;
base = "/audio/:/transcode/universal/start.mp3";
case 2:
// a networked-speaker identity Plex ships a profile for; output decided by the target
base = "/audio/:/transcode/universal/start";
q.append("&directPlay=0&directStream=0&musicBitrate=").append(kbps)
.append("&X-Plex-Client-Profile-Extra=").append(Plex.enc(mp3Target + bitrateLimit
+ "+add-limitation(scope=musicCodec&scopeName=mp3&type=upperBound&name=audio.samplingRate&value=48000&onlyTranscodes=true&replace=true)"));
break;
default: // no profile tweaks at all; bitrate from musicBitrate only
platform = "Chrome";
profile = null;
default:
base = "/audio/:/transcode/universal/start";
q.append("&protocol=http&directPlay=0&directStream=0&musicBitrate=").append(kbps)
.append("&X-Plex-Client-Profile-Extra=").append(Plex.enc(mp3Target));
}
if (offsetMs >= 1000) q.append("&offset=").append(offsetMs / 1000);
q.append("&session=").append(Plex.enc(session))
.append("&X-Plex-Session-Identifier=").append(Plex.enc(session))
.append("&X-Plex-Client-Identifier=").append(Plex.enc(Plex.clientId))
.append("&X-Plex-Product=").append(Plex.enc(Plex.product))
.append("&X-Plex-Version=").append(Plex.enc(Plex.version));
if (variant == 2) {
q.append("&X-Plex-Platform=Linux&X-Plex-Platform-Version=1.0")
.append("&X-Plex-Device=Sonos&X-Plex-Device-Name=").append(Plex.enc(Plex.deviceName))
.append("&X-Plex-Model=sonos");
} else {
// no X-Plex-Device: a phone model name matches no server profile and gets a 400
q.append("&X-Plex-Platform=Chrome");
}
return server.url(base + "?" + q);
}
/** Ask the server what it would do with a transcode request (for the diagnostics screen). */
public String decision(Item t, int kbps, int variant) {
try {
String url = transcodeUrl(t, kbps, 0, "minamp-check-" + System.currentTimeMillis(), variant);
url = url.replace("/start.mp3?", "/decision?").replace("/universal/start?", "/universal/decision?");
com.minamp.net.Http.Response r = com.minamp.net.Http.get(url, Plex.headers(null), 20000);
String body = r.readString();
if (r.code != 200) return "HTTP " + r.code + (Plex.snippet(body).length() > 0 ? ": " + Plex.snippet(body) : "");
Json.Obj mc = Json.parseObj(body).optObj("MediaContainer");
if (mc == null) return "200 (no details)";
return "general " + mc.optString("generalDecisionCode", "?") + " " + mc.optString("generalDecisionText", "")
+ " | transcode " + mc.optString("transcodeDecisionCode", "?") + " " + mc.optString("transcodeDecisionText", "")
+ " | direct play " + mc.optString("directPlayDecisionCode", "?") + " " + mc.optString("directPlayDecisionText", "");
} catch (Exception e) {
return com.minamp.net.Threads.describe(e);
}
String path = base
+ "?path=" + Plex.enc("/library/metadata/" + t.ratingKey)
+ "&mediaIndex=0&partIndex=0&protocol=http&hasMDE=1&fastSeek=1&copyts=1"
+ "&directPlay=0&directStream=0&directStreamAudio=0"
+ "&musicBitrate=" + kbps + "&maxAudioBitrate=" + kbps
+ "&offset=" + (offsetMs / 1000)
+ "&session=" + Plex.enc(session)
+ "&location=" + (server.isLocal() ? "lan" : "wan")
+ "&X-Plex-Session-Identifier=" + Plex.enc(session)
+ "&X-Plex-Client-Identifier=" + Plex.enc(Plex.clientId)
+ "&X-Plex-Product=" + Plex.enc(Plex.product)
+ "&X-Plex-Version=" + Plex.enc(Plex.version)
+ "&X-Plex-Platform=" + platform
+ "&X-Plex-Device=" + Plex.enc(Plex.device)
+ (profile != null ? "&X-Plex-Client-Profile-Extra=" + Plex.enc(profile) : "");
return server.url(path);
}
public void stopTranscode(String session) {
+1
View File
@@ -52,6 +52,7 @@ public final class Plex {
if (r.code == 401) throw new PlexException(401, "Not authorized (401) on " + describePath(url));
if (r.code < 200 || r.code >= 300) {
String snippet = snippet(body);
com.minamp.net.DiagLog.log(" server said: " + snippet);
throw new PlexException(r.code, "HTTP " + r.code + " on " + describePath(url) + (snippet.length() > 0 ? ": " + snippet : ""));
}
if (body.trim().length() == 0) return new Json.Obj();