diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 18a875a..b4fe4ad 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,8 +1,8 @@ diff --git a/src/com/minamp/App.java b/src/com/minamp/App.java index 8aca2ed..30cfc92 100644 --- a/src/com/minamp/App.java +++ b/src/com/minamp/App.java @@ -48,6 +48,7 @@ public class App extends Application { instance = this; handler = new Handler(); prefs = getSharedPreferences("minamp", MODE_PRIVATE); + installCrashLogger(); // Android 2.2's HttpURLConnection pool is buggy; we use our own client, but be safe. System.setProperty("http.keepAlive", "false"); @@ -64,6 +65,7 @@ public class App extends Application { try { Plex.version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName; } catch (Exception ignored) {} CertVerifier.setRootStore(new AssetRoots(this)); + Library.preferredVariant = Math.max(0, Math.min(Library.VARIANTS - 1, prefs.getInt("tcVariant", 0))); images = new ImageLoader(this); loadServer(); PlayQueue.get().restore(this); @@ -76,6 +78,59 @@ public class App extends Application { }, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); } + // ------------------------------------------------------------------ crash reports + + private static final String CRASH_FILE = "crash.txt"; + + private void installCrashLogger() { + final Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { + public void uncaughtException(Thread t, Throwable e) { + logError("crash in " + t.getName(), e); + if (previous != null) previous.uncaughtException(t, e); + } + }); + } + + /** Saves a stack trace to app storage (shown on next launch) and to the SD card. */ + public static void logError(String where, Throwable e) { + App app = instance; + if (app == null) return; + try { + java.io.StringWriter sw = new java.io.StringWriter(); + java.io.PrintWriter pw = new java.io.PrintWriter(sw); + pw.println("Minamp " + Plex.version + " on " + Build.MODEL + ", Android " + Build.VERSION.RELEASE); + pw.println(new java.util.Date() + " - " + where); + e.printStackTrace(pw); + pw.flush(); + byte[] text = sw.toString().getBytes("UTF-8"); + java.io.FileOutputStream out = app.openFileOutput(CRASH_FILE, MODE_PRIVATE); + out.write(text); + out.close(); + try { + if (android.os.Environment.MEDIA_MOUNTED.equals(android.os.Environment.getExternalStorageState())) { + java.io.File f = new java.io.File(android.os.Environment.getExternalStorageDirectory(), "Minamp-error.txt"); + java.io.FileOutputStream o2 = new java.io.FileOutputStream(f); + o2.write(text); + o2.close(); + } + } catch (Throwable ignored) {} + } catch (Throwable ignored) {} + } + + /** The last saved error report, or null. Reading it clears it. */ + public String takeCrashReport() { + try { + java.io.File f = getFileStreamPath(CRASH_FILE); + if (!f.exists()) return null; + String s = new String(readAll(new java.io.FileInputStream(f)), "UTF-8"); + f.delete(); + return s; + } catch (Exception e) { + return null; + } + } + // ------------------------------------------------------------------ server/library public void loadServer() { diff --git a/src/com/minamp/BaseActivity.java b/src/com/minamp/BaseActivity.java index 5637a68..310f33a 100644 --- a/src/com/minamp/BaseActivity.java +++ b/src/com/minamp/BaseActivity.java @@ -3,7 +3,6 @@ package com.minamp; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import android.app.Activity; import android.app.AlertDialog; @@ -25,7 +24,7 @@ import com.minamp.plex.Server; /** Shared bits: background tasks, mini player, item actions, settings menu. */ public abstract class BaseActivity extends Activity implements PlayerService.Listener, PlayQueue.Listener { - private static final ExecutorService POOL = Executors.newFixedThreadPool(2); + private static final ExecutorService POOL = com.minamp.net.Threads.pool("task", 2); protected App app; protected boolean destroyed; @@ -54,15 +53,18 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis app.handler.post(new Runnable() { public void run() { if (!destroyed) t.done(r); } }); } catch (final Exception e) { app.handler.post(new Runnable() { public void run() { if (!destroyed) t.fail(e); } }); - } catch (final OutOfMemoryError e) { - app.images.clearMemory(); - app.handler.post(new Runnable() { public void run() { if (!destroyed) t.fail(new Exception("Out of memory")); } }); + } catch (final Throwable e) { + // Errors (out of memory, stack overflow, a class the old VM can't load) must not kill the app + if (e instanceof OutOfMemoryError) app.images.clearMemory(); + App.logError("task", e); + final Exception wrapped = new Exception(com.minamp.net.Threads.describe(e)); + app.handler.post(new Runnable() { public void run() { if (!destroyed) t.fail(wrapped); } }); } } }); } - protected void toast(String s) { Toast.makeText(this, s, Toast.LENGTH_SHORT).show(); } + protected void toast(String s) { Toast.makeText(this, s, s != null && s.length() > 40 ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT).show(); } protected static String msg(Exception e) { return PlayerService.message(e); } @@ -74,6 +76,25 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis app = App.get(); } + /** If the last run hit an error, show it so it can be reported (also saved as Minamp-error.txt on the SD card). */ + protected void showCrashReportIfAny() { + final String report = app.takeCrashReport(); + if (report == null) return; + android.widget.ScrollView sv = new android.widget.ScrollView(this); + TextView tv = new TextView(this); + tv.setText(report); + tv.setTextSize(10); + tv.setTextColor(0xFFDDDDDD); + int p = Ui.dp(this, 8); + tv.setPadding(p, p, p, p); + sv.addView(tv); + new AlertDialog.Builder(this) + .setTitle("Minamp hit an error last time") + .setView(sv) + .setPositiveButton("OK", null) + .show(); + } + @Override protected void onResume() { super.onResume(); diff --git a/src/com/minamp/HomeActivity.java b/src/com/minamp/HomeActivity.java index b217534..7462af6 100644 --- a/src/com/minamp/HomeActivity.java +++ b/src/com/minamp/HomeActivity.java @@ -48,6 +48,7 @@ public class HomeActivity extends BaseActivity { return; } setContentView(R.layout.activity_home); + showCrashReportIfAny(); setupTopBar(app.sectionTitle(), connectionLine(), false); barAction(R.id.btn_action, Glyph.SEARCH, new View.OnClickListener() { public void onClick(View v) { startActivity(new Intent(HomeActivity.this, SearchActivity.class)); } diff --git a/src/com/minamp/ImageLoader.java b/src/com/minamp/ImageLoader.java index 3c8857c..8d4d234 100644 --- a/src/com/minamp/ImageLoader.java +++ b/src/com/minamp/ImageLoader.java @@ -44,10 +44,9 @@ public final class ImageLoader { memMax = (int) Math.min(max / 8, 4 * 1024 * 1024); mem = new LinkedHashMap(32, 0.75f, true); for (int i = 0; i < 2; i++) { - Thread t = new Thread("img-" + i) { + Thread t = com.minamp.net.Threads.create(new Runnable() { public void run() { loop(); } - }; - t.setDaemon(true); + }, "img-" + i); t.setPriority(Thread.MIN_PRIORITY + 1); t.start(); } @@ -121,7 +120,7 @@ public final class ImageLoader { if (j.view != null && !j.key.equals(j.view.getTag(R.id.tag_image))) continue; Bitmap b = fromMem(j.key); if (b == null) { - try { b = fetch(j); } catch (Throwable t) { b = null; } + try { b = fetch(j); } catch (OutOfMemoryError oom) { clearMemory(); b = null; } catch (Throwable t) { b = null; } if (b != null) { synchronized (mem) { Bitmap old = mem.put(j.key, b); diff --git a/src/com/minamp/PlayQueue.java b/src/com/minamp/PlayQueue.java index f0b770f..4a73d72 100644 --- a/src/com/minamp/PlayQueue.java +++ b/src/com/minamp/PlayQueue.java @@ -232,7 +232,7 @@ public final class PlayQueue { .put("shuffle", Boolean.valueOf(shuffle)).put("title", title).put("pos", Long.valueOf(resumePositionMs)); final String data = o.toString(); saving = false; - new Thread("save-queue") { + com.minamp.net.Threads.start(new Runnable() { public void run() { synchronized (PlayQueue.class) { try { @@ -244,6 +244,6 @@ public final class PlayQueue { } catch (Exception ignored) {} } } - }.start(); + }, "save-queue"); } } diff --git a/src/com/minamp/PlayerService.java b/src/com/minamp/PlayerService.java index a0f8d09..92c76e8 100644 --- a/src/com/minamp/PlayerService.java +++ b/src/com/minamp/PlayerService.java @@ -7,7 +7,6 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import android.app.Notification; import android.app.NotificationManager; @@ -109,8 +108,8 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList } // ---- instance ---- - private final ExecutorService worker = Executors.newSingleThreadExecutor(); - private final ExecutorService reporter = Executors.newSingleThreadExecutor(); + private final ExecutorService worker = com.minamp.net.Threads.pool("player", 1); + private final ExecutorService reporter = com.minamp.net.Threads.pool("report", 1); private StreamProxy proxy; private MediaPlayer mp; private boolean prepared; @@ -125,6 +124,9 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList private String session; private String routeId; private int retries; + private int[] variantOrder; // transcode variant behind each URL of the current route + private String refusal; // what the server said when it refused the current stream + private int refusalCode; private int failuresInRow; private boolean forceDownload; private boolean scrobbled; @@ -150,6 +152,20 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL, "minamp"); wifiLock.setReferenceCounted(false); proxy = new StreamProxy(); + proxy.listener = new StreamProxy.Listener() { + public void onError(final String id, final int code, final String message) { + post(new Runnable() { + public void run() { + if (id.equals(routeId)) { refusal = message; refusalCode = code; } + } + }); + } + public void onAccepted(final String id, final int index) { + post(new Runnable() { + public void run() { if (id.equals(routeId)) rememberVariant(index); } + }); + } + }; try { proxy.start(); } catch (IOException e) { /* reported when playback starts */ } try { TelephonyManager tm = (TelephonyManager) getSystemService(TELEPHONY_SERVICE); @@ -254,23 +270,29 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList final String sess = direct ? null : "minamp-" + Long.toString(System.currentTimeMillis(), 36); // Streamed transcodes start at the offset; downloads always fetch the whole track and seek in the file. final long startAt = (direct || download) ? 0 : (posMs / 1000) * 1000; - final String url = direct ? lib.directUrl(it) : lib.transcodeUrl(it, max, startAt, sess); + final java.util.List urls; + final int[] order = direct ? null : Library.variantOrder(); + if (direct) { urls = new java.util.ArrayList(); urls.add(lib.directUrl(it)); } + else urls = lib.transcodeUrls(it, max, startAt, sess); final String ext = direct ? extFor(it) : "mp3"; File file = null; - if (download) file = downloadTo(url, g, it); + if (download) file = downloadTo(urls, order, g, it); final File ff = file; post(new Runnable() { public void run() { if (g != gen) return; transcoding = !direct; session = sess; + variantOrder = order; kbps = direct ? 0 : (max <= 0 ? 320 : max); - begin(url, ext, ff, startAt, (direct || download) ? posMs : 0); + begin(urls, ext, ff, startAt, (direct || download) ? posMs : 0); } }); - } catch (final Exception e) { + } catch (final Throwable e) { + if (!(e instanceof Exception)) App.logError("player", e); + final String m = com.minamp.net.Threads.describe(e); post(new Runnable() { - public void run() { if (g == gen) trackFailed(message(e)); } + public void run() { if (g == gen) trackFailed(m); } }); } } @@ -284,13 +306,28 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList return "mp3"; } - private File downloadTo(String url, int g, Item it) throws IOException { + private File downloadTo(java.util.List urls, final int[] order, int g, Item it) throws IOException { File base = null; if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) base = getExternalCacheDir(); if (base == null) base = getCacheDir(); File f = new File(base, "play-" + (g % 2) + ".tmp"); - Http.Response r = Http.get(url, Plex.headers(null), 30000); - if (r.code != 200) { r.close(); throw new IOException("Server said " + r.code); } + Http.Response r = null; + String why = null; + for (int i = 0; i < urls.size(); i++) { + r = Http.get(urls.get(i), Plex.headers(null), 30000); + if (r.code == 200) { + if (order != null) { + final int idx = i; + post(new Runnable() { public void run() { if (variantOrder == order) rememberVariant(idx); else rememberVariantOf(order, idx); } }); + } + break; + } + why = "Server refused the stream (" + r.code + ")"; + String body = new String(r.readAll(), "UTF-8"); + if (body.trim().length() > 0) why += ": " + Plex.snippet(body); + r = null; + } + if (r == null) throw new IOException(why); InputStream in = r.body; FileOutputStream out = new FileOutputStream(f); try { @@ -316,7 +353,20 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList return f; } - private void begin(String url, String ext, File file, long offset, long seekTo) { + private void rememberVariant(int index) { + rememberVariantOf(variantOrder, index); + } + + private void rememberVariantOf(int[] order, int index) { + if (order == null || index < 0 || index >= order.length) return; + int v = order[index]; + if (v != Library.preferredVariant) { + Library.preferredVariant = v; + App.get().prefs.edit().putInt("tcVariant", v).commit(); + } + } + + private void begin(java.util.List urls, String ext, File file, long offset, long seekTo) { try { mp = new MediaPlayer(); mp.setAudioStreamType(AudioManager.STREAM_MUSIC); @@ -335,7 +385,9 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList } else { if (proxy.port() < 0) proxy.start(); routeId = proxy.newId(); - mp.setDataSource(proxy.register(routeId, url, ext)); + refusal = null; + refusalCode = 0; + mp.setDataSource(proxy.register(routeId, urls, ext)); } setState(BUFFERING, "Buffering…"); mp.prepareAsync(); @@ -400,7 +452,12 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList private void trackFailed(String msg) { long pos = Math.max(currentPosition(), lastPos); - retries++; + if (refusal != null) msg = refusal; // the server's own reason beats "Playback error (1/-1004)" + boolean permanent = refusalCode >= 400 && refusalCode < 500 && refusalCode != 408 && refusalCode != 429 + || (msg != null && msg.startsWith("Server refused the stream (4")); + refusal = null; + refusalCode = 0; + retries = permanent ? 99 : retries + 1; App app = App.get(); if (retries == 1) { // Most failures on a phone are network changes: re-pick the connection and resume. @@ -658,7 +715,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList private void post(Runnable r) { App.get().handler.post(r); } private void toast(String m) { - if (m != null && m.length() > 0) Toast.makeText(this, m, Toast.LENGTH_SHORT).show(); + if (m != null && m.length() > 0) Toast.makeText(this, m, m.length() > 40 ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT).show(); } static String message(Exception e) { diff --git a/src/com/minamp/SetupActivity.java b/src/com/minamp/SetupActivity.java index 9e66fd1..9633f76 100644 --- a/src/com/minamp/SetupActivity.java +++ b/src/com/minamp/SetupActivity.java @@ -32,6 +32,7 @@ public class SetupActivity extends BaseActivity { protected void onCreate(Bundle b) { super.onCreate(b); setContentView(R.layout.activity_setup); + showCrashReportIfAny(); welcome = findViewById(R.id.page_welcome); pinPage = findViewById(R.id.page_pin); manual = findViewById(R.id.page_manual); diff --git a/src/com/minamp/net/Http.java b/src/com/minamp/net/Http.java index 4affafd..fed0365 100644 --- a/src/com/minamp/net/Http.java +++ b/src/com/minamp/net/Http.java @@ -135,13 +135,16 @@ public final class Http { /** Drop all pooled connections (e.g. after the network changed). */ public static void evictAll() { + final List doomed = new ArrayList(); synchronized (pool) { - for (Iterator> it = pool.values().iterator(); it.hasNext();) { - List l = it.next(); - for (int i = 0; i < l.size(); i++) l.get(i).close(); - } + for (Iterator> it = pool.values().iterator(); it.hasNext();) doomed.addAll(it.next()); pool.clear(); } + if (doomed.isEmpty()) return; + // closing TLS sends an encrypted alert: keep that off the (small-stack) caller thread + Threads.start(new Runnable() { + public void run() { for (int i = 0; i < doomed.size(); i++) doomed.get(i).close(); } + }, "http-evict"); } private static Conn connect(final Url u, int timeoutMs) throws IOException { diff --git a/src/com/minamp/net/StreamProxy.java b/src/com/minamp/net/StreamProxy.java index 6c0923b..aa1c715 100644 --- a/src/com/minamp/net/StreamProxy.java +++ b/src/com/minamp/net/StreamProxy.java @@ -26,19 +26,29 @@ public final class StreamProxy implements Runnable { private Thread thread; private volatile boolean running; private int counter; - private final Map routes = new HashMap(); + private final Map routes = new HashMap(); + + /** One stream, with fallback URLs tried in order until the server accepts one. */ + private static final class Route { + final List urls; + int chosen = -1; // index that worked; later requests (seeks) go straight to it + Route(List urls) { this.urls = urls; } + } private final Map> live = new HashMap>(); - /** Called with how many bytes have arrived for a route (for buffering UI / diagnostics). */ - public interface Listener { void onError(String routeId, String message); } + public interface Listener { + /** Every URL was refused (or the proxy failed); code is the last HTTP status, or 0. */ + void onError(String routeId, int code, String message); + /** URL #index of the route's list is the one the server accepted. */ + void onAccepted(String routeId, int index); + } public volatile Listener listener; public synchronized void start() throws IOException { if (running) return; server = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1")); running = true; - thread = new Thread(this, "stream-proxy"); - thread.setDaemon(true); + thread = Threads.create(this, "stream-proxy"); thread.start(); } @@ -55,8 +65,14 @@ public final class StreamProxy implements Runnable { /** @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 l = new ArrayList(); + l.add(remoteUrl); + return register(id, l, ext); + } + + public String register(String id, List remoteUrls, String ext) { synchronized (routes) { - routes.put(id, remoteUrl); + routes.put(id, new Route(new ArrayList(remoteUrls))); } return "http://127.0.0.1:" + port() + "/s/" + id + "/stream." + ext; } @@ -89,11 +105,9 @@ public final class StreamProxy implements Runnable { if (!running) return; continue; } - Thread t = new Thread("proxy-conn") { + Threads.start(new Runnable() { public void run() { handle(s); } - }; - t.setDaemon(true); - t.start(); + }, "proxy-conn"); } } @@ -120,9 +134,9 @@ public final class StreamProxy implements Runnable { int slash = path.indexOf('/', 3); id = slash < 0 ? path.substring(3) : path.substring(3, slash); } - String target; - synchronized (routes) { target = id == null ? null : routes.get(id); } - if (target == null) { + 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(); return; @@ -133,7 +147,31 @@ public final class StreamProxy implements Runnable { String range = reqHeaders.get("range"); if (range != null) h.put("Range", range); boolean head = "HEAD".equals(method); - up = Http.request(head ? "HEAD" : "GET", target, h, null, 30000); + 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 l = live.get(id); @@ -142,13 +180,15 @@ public final class StreamProxy implements Runnable { } StringBuilder resp = new StringBuilder(256); - boolean known = up.contentLength >= 0 && !up.chunked; + boolean refused = up.code >= 400 && up.code != 416; + boolean known = !refused && 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"); @@ -156,11 +196,7 @@ 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 (up.code >= 400) { - Listener l = listener; - if (l != null) l.onError(id, "Server said " + up.code); - } - if (head) { up.close(); return; } + if (head || up.code >= 400) { up.close(); up = null; return; } byte[] buf = new byte[16384]; int n; @@ -176,6 +212,9 @@ public final class StreamProxy implements Runnable { 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) { diff --git a/src/com/minamp/net/Threads.java b/src/com/minamp/net/Threads.java new file mode 100644 index 0000000..c96be3b --- /dev/null +++ b/src/com/minamp/net/Threads.java @@ -0,0 +1,43 @@ +package com.minamp.net; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; + +/** + * Background threads with big stacks. Android 2.x gives new threads ~16 KB of stack, and the + * bundled TLS handshake + certificate parsing needs far more; overflowing it kills the app. + * 256 KB is the most Dalvik allows. + */ +public final class Threads { + public static final long STACK = 256 * 1024; + + private Threads() {} + + public static Thread create(Runnable r, String name) { + Thread t = new Thread(null, r, name, STACK); + t.setDaemon(true); + return t; + } + + public static void start(Runnable r, String name) { + create(r, name).start(); + } + + public static ExecutorService pool(final String name, int n) { + ThreadFactory f = new ThreadFactory() { + private int count; + public synchronized Thread newThread(Runnable r) { return create(r, name + "-" + (++count)); } + }; + return n == 1 ? Executors.newSingleThreadExecutor(f) : Executors.newFixedThreadPool(n, f); + } + + /** Readable one-liner for any failure, including Errors such as StackOverflowError. */ + public static String describe(Throwable t) { + String m = t.getMessage(); + String cls = t.getClass().getSimpleName(); + if (m == null || m.length() == 0) return cls; + if (t instanceof java.io.IOException) return m; + return cls + ": " + m; + } +} diff --git a/src/com/minamp/plex/Library.java b/src/com/minamp/plex/Library.java index f972b61..6a984ef 100644 --- a/src/com/minamp/plex/Library.java +++ b/src/com/minamp/plex/Library.java @@ -36,7 +36,7 @@ public final class Library { } public String recentlyPlayedPath(int size) { - return "/library/sections/" + section + "/all?type=9&sort=lastViewedAt:desc&viewCount>>=0" + page(0, size); + return "/library/sections/" + section + "/all?type=9&sort=lastViewedAt:desc&viewCount%3E%3E=0" + page(0, size); } public String playlistsPath() { @@ -84,7 +84,7 @@ public final class Library { /** Tracks rated four stars or better (and loved tracks), shuffled. */ public List topRatedRadio() throws IOException { - return server.items("/library/sections/" + section + "/all?type=10&userRating>>=7&sort=random" + page(0, 100)); + return server.items("/library/sections/" + section + "/all?type=10&userRating%3E%3E=7&sort=random" + page(0, 100)); } /** Tracks from albums released in a random decade that the library actually has. */ @@ -172,15 +172,56 @@ public final class Library { return server.url(t.partKey); } - /** MP3 transcode over plain progressive HTTP, starting at offsetMs. */ - public String transcodeUrl(Item t, int kbps, long offsetMs, String session) throws IOException { + /** + * 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. + */ + public static final int VARIANTS = 4; + public static volatile int preferredVariant = 0; + + public static int[] variantOrder() { + int[] order = new int[VARIANTS]; + order[0] = preferredVariant; + for (int i = 0, k = 1; i < VARIANTS; i++) if (i != preferredVariant) order[k++] = i; + return order; + } + + /** MP3 transcode URLs over plain progressive HTTP starting at offsetMs, in {@link #variantOrder()} order. */ + public java.util.List transcodeUrls(Item t, int kbps, long offsetMs, String session) throws IOException { + int[] order = variantOrder(); + java.util.List out = new ArrayList(); + for (int i = 0; i < order.length; i++) out.add(transcodeUrl(t, kbps, offsetMs, session, order[i])); + return out; + } + + String transcodeUrl(Item t, int kbps, long offsetMs, String session, int variant) throws IOException { if (kbps <= 0) kbps = 320; - String profile = "add-transcode-target(type=musicProfile&context=streaming&protocol=http&container=mp3&audioCodec=mp3)" - + "+add-limitation(scope=musicCodec&scopeName=mp3&type=upperBound&name=audio.bitrate&value=" + kbps + ")" - + "+add-limitation(scope=musicCodec&scopeName=mp3&type=upperBound&name=audio.channels&value=2)"; - String path = "/music/:/transcode/universal/start.mp3" + 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"; + 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 + ")"; + break; + case 1: // generic client profile + platform = "Generic"; + profile = target + "+add-limitation(scope=musicCodec&scopeName=mp3&type=upperBound&name=audio.bitrate&value=" + kbps + ")"; + break; + case 2: // older servers' audio endpoint + platform = "Chrome"; + profile = target; + base = "/audio/:/transcode/universal/start.mp3"; + break; + default: // no profile tweaks at all; bitrate from musicBitrate only + platform = "Chrome"; + profile = null; + } + String path = base + "?path=" + Plex.enc("/library/metadata/" + t.ratingKey) - + "&mediaIndex=0&partIndex=0&protocol=http&hasMDE=1&fastSeek=1" + + "&mediaIndex=0&partIndex=0&protocol=http&hasMDE=1&fastSeek=1©ts=1" + "&directPlay=0&directStream=0&directStreamAudio=0" + "&musicBitrate=" + kbps + "&maxAudioBitrate=" + kbps + "&offset=" + (offsetMs / 1000) @@ -190,9 +231,9 @@ public final class Library { + "&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=Generic" + + "&X-Plex-Platform=" + platform + "&X-Plex-Device=" + Plex.enc(Plex.device) - + "&X-Plex-Client-Profile-Extra=" + Plex.enc(profile); + + (profile != null ? "&X-Plex-Client-Profile-Extra=" + Plex.enc(profile) : ""); return server.url(path); } diff --git a/src/com/minamp/plex/Plex.java b/src/com/minamp/plex/Plex.java index b86551c..c2702e5 100644 --- a/src/com/minamp/plex/Plex.java +++ b/src/com/minamp/plex/Plex.java @@ -49,8 +49,11 @@ public final class Plex { static Json.Obj fetchJson(String method, String url, String token, int timeout) throws IOException { Http.Response r = Http.request(method, url, headers(token), "POST".equals(method) ? new byte[0] : null, timeout); String body = r.readString(); - if (r.code == 401) throw new PlexException(401, "Not authorized"); - if (r.code < 200 || r.code >= 300) throw new PlexException(r.code, "HTTP " + r.code); + 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); + throw new PlexException(r.code, "HTTP " + r.code + " on " + describePath(url) + (snippet.length() > 0 ? ": " + snippet : "")); + } if (body.trim().length() == 0) return new Json.Obj(); Object o; try { o = Json.parse(body); } catch (IllegalArgumentException e) { throw new IOException("Bad response from server"); } @@ -60,6 +63,22 @@ public final class Plex { return wrap; } + /** "/library/sections/1/all" without host, query or token: enough to tell which call failed. */ + static String describePath(String url) { + int start = url.indexOf("://"); + start = start < 0 ? 0 : url.indexOf('/', start + 3); + if (start < 0) return url; + int q = url.indexOf('?', start); + return q < 0 ? url.substring(start) : url.substring(start, q); + } + + /** First words of an error body (HTML or JSON), for error messages. */ + public static String snippet(String body) { + if (body == null) return ""; + String s = body.replaceAll("<[^>]*>", " ").replaceAll("\\s+", " ").trim(); + return s.length() > 120 ? s.substring(0, 120) + "\u2026" : s; + } + public static final class PlexException extends IOException { public final int code; public PlexException(int code, String msg) { super(msg); this.code = code; } diff --git a/src/com/minamp/plex/Server.java b/src/com/minamp/plex/Server.java index 68eab9f..17788fa 100644 --- a/src/com/minamp/plex/Server.java +++ b/src/com/minamp/plex/Server.java @@ -54,10 +54,19 @@ public final class Server { String s = input.trim(); while (s.endsWith("/")) s = s.substring(0, s.length() - 1); String lower = s.toLowerCase(); - if (lower.startsWith("http://") || lower.startsWith("https://")) return s; // explicit: keep as typed - // bare host or IP: assume plain HTTP on the default Plex port - if (s.indexOf(':') < 0 && s.indexOf('/') < 0) s = s + ":32400"; - return "http://" + s; + String scheme = "http://"; + if (lower.startsWith("https://")) { scheme = "https://"; s = s.substring(8); } + else if (lower.startsWith("http://")) { s = s.substring(7); } + // drop anything after host[:port] (people paste ".../web/index.html#!/...") + int cut = s.length(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '/' || c == '?' || c == '#') { cut = i; break; } + } + s = s.substring(0, cut); + // no port: Plex's default for http, the standard 443 for https (reverse proxies) + if (s.indexOf(':') < 0 && scheme.equals("http://")) s = s + ":32400"; + return scheme + s; } public Conn active() { return active; } @@ -107,7 +116,7 @@ public final class Server { for (int i = 0; i < n; i++) { final int idx = i; final Conn c = connections.get(i); - Thread t = new Thread("probe-" + i) { + Runnable probe = new Runnable() { public void run() { boolean ok = false; try { @@ -118,8 +127,8 @@ public final class Server { String id = mc == null ? "" : mc.optString("machineIdentifier"); ok = machineId.length() == 0 || machineId.equals(id); if (!ok) errors[idx] = "different server answered"; - } catch (Exception e) { - errors[idx] = String.valueOf(e.getMessage()); + } catch (Throwable e) { + errors[idx] = com.minamp.net.Threads.describe(e); } synchronized (lock) { state[idx] = ok ? 1 : 2; @@ -127,8 +136,7 @@ public final class Server { } } }; - t.setDaemon(true); - t.start(); + com.minamp.net.Threads.start(probe, "probe-" + i); } long deadline = System.currentTimeMillis() + 12000; synchronized (lock) { diff --git a/test/PlexTest.java b/test/PlexTest.java index 62c18b0..24aa0e2 100644 --- a/test/PlexTest.java +++ b/test/PlexTest.java @@ -43,7 +43,20 @@ public class PlexTest { } } - public static void main(String[] args) throws Exception { + public static void main(final String[] args) throws Exception { + // run everything on the same kind of thread the app uses (256 KB stack, Dalvik's max) + final Throwable[] err = {null}; + Thread t = com.minamp.net.Threads.create(new Runnable() { + public void run() { try { realMain(args); } catch (Throwable e) { err[0] = e; } } + }, "test"); + t.start(); + t.join(); + if (err[0] != null) { err[0].printStackTrace(); failures++; } + System.out.println(failures == 0 ? "ALL PASSED" : failures + " FAILED"); + System.exit(failures == 0 ? 0 : 1); + } + + static void realMain(String[] args) throws Exception { CertVerifier.setRootStore(roots(args[0])); Plex.clientId = "test-client"; @@ -91,6 +104,7 @@ public class PlexTest { check(res.size() == 4 && res.get(0).type == Item.HEADER && res.get(1).type == Item.ARTIST && res.get(3).type == Item.TRACK, "search hubs grouped (" + res.size() + ")"); check(lib.libraryRadio().size() == 2, "library radio"); + check(lib.topRatedRadio().size() == 1, "'>>' filter reaches the server percent-encoded and decoded intact"); String img = s2.imageUrl("/library/metadata/10/thumb/1", 64); Http.Response ir = Http.get(img, null, 10000); byte[] ib = ir.readAll(); @@ -110,13 +124,31 @@ public class PlexTest { check(hc.getResponseCode() == 206 && body.length == 299000 && (body[0] & 255) == (1000 * 7) % 256, "proxy direct stream with Range over TLS (" + hc.getResponseCode() + ", " + body.length + "B)"); + final int[] accepted = {-1}; + final String[] refused = {null}; + proxy.listener = new StreamProxy.Listener() { + public void onError(String id, int code, String m) { refused[0] = code + " " + m; } + public void onAccepted(String id, int index) { accepted[0] = index; } + }; String id2 = proxy.newId(); - String local2 = proxy.register(id2, lib.transcodeUrl(tracks.get(1), 192, 30500, "sess1"), "mp3"); + String local2 = proxy.register(id2, lib.transcodeUrls(tracks.get(1), 192, 30500, "sess1"), "mp3"); hc = (HttpURLConnection) new URL(local2).openConnection(Proxy.NO_PROXY); byte[] tbody = read(hc.getInputStream()); String ts = new String(tbody, "ISO-8859-1"); check(hc.getResponseCode() == 200 && ts.startsWith("TRANSCODE offset=30 bitrate=192|") && hc.getHeaderField("Content-Length") == null, "proxy de-chunks transcode stream (" + tbody.length + "B)"); + check(accepted[0] == 1 && refused[0] == null, "refused transcode variant skipped, second accepted (index " + accepted[0] + ")"); + Library.preferredVariant = Library.variantOrder()[accepted[0]]; + check(Library.variantOrder()[0] == 1, "accepted variant is tried first next time"); + // every variant refused -> error carries the server's words + Library.preferredVariant = 0; + String id4 = proxy.newId(); + java.util.List bad = new java.util.ArrayList(); + bad.add(lib.transcodeUrls(tracks.get(1), 192, 0, "s4").get(0)); + String local4 = proxy.register(id4, bad, "mp3"); + hc = (HttpURLConnection) new URL(local4).openConnection(Proxy.NO_PROXY); + int c4 = hc.getResponseCode(); + check(c4 == 400 && refused[0] != null && refused[0].indexOf("400 Bad Request") >= 0, "refusal reported with server text: " + refused[0]); // abort mid-stream String id3 = proxy.newId(); @@ -153,8 +185,16 @@ public class PlexTest { check(Server.normalizeManual("192.168.1.5").equals("http://192.168.1.5:32400"), "manual URL normalise"); check(Server.normalizeManual("https://plex.example.com/").equals("https://plex.example.com"), "manual https URL kept"); - System.out.println(failures == 0 ? "ALL PASSED" : failures + " FAILED"); - System.exit(failures == 0 ? 0 : 1); + check(Server.normalizeManual("http://192.168.1.5:32400/web/index.html#!/").equals("http://192.168.1.5:32400"), "pasted web-app path stripped"); + check(Server.normalizeManual("http://192.168.1.5").equals("http://192.168.1.5:32400"), "explicit http gets Plex port"); + CertVerifier.setRootStore(roots(args[0])); + Http.evictAll(); + try { + s2.items("/library/nonexistent"); + check(false, "error names the failing call"); + } catch (Plex.PlexException e) { + check(e.getMessage().indexOf("/library/nonexistent") >= 0 && e.getMessage().indexOf("X-Plex-Token") < 0, "error names the failing call: " + e.getMessage()); + } } static byte[] read(InputStream in) throws Exception { diff --git a/test/mock_plex.py b/test/mock_plex.py index 2c14a60..ac40578 100644 --- a/test/mock_plex.py +++ b/test/mock_plex.py @@ -128,6 +128,8 @@ class H(BaseHTTPRequestHandler): arts = [{"type": "artist", "ratingKey": str(1000 + i), "title": "Artist %03d" % i, "thumb": "/a/%d" % i} for i in range(250)] return self.send_json({"MediaContainer": {"totalSize": 250, "offset": start, "Metadata": arts[start:start + size]}}) if t == "10": + if "userRating>>" in q or any(k.endswith(">") for k in q): + return self.send_json({"MediaContainer": {"Metadata": TRACKS[:1], "filterSeen": list(q.keys())}}) return self.send_json({"MediaContainer": {"Metadata": TRACKS}}) return self.send_json({"MediaContainer": {"Metadata": [{"type": "album", "ratingKey": "10", "title": "First Album", "parentTitle": "The Testers", "year": 1999, "thumb": "/library/metadata/10/thumb/1"}]}}) @@ -139,7 +141,15 @@ class H(BaseHTTPRequestHandler): {"type": "track", "title": "Tracks", "Metadata": TRACKS[:1]}]}}) if p.startswith("/library/parts/501"): return self.file(method, MP3) - if p == "/music/:/transcode/universal/start.mp3": + if p in ("/music/:/transcode/universal/start.mp3", "/audio/:/transcode/universal/start.mp3"): + extra = (q.get("X-Plex-Client-Profile-Extra") or [""])[0] + if "scopeName=*" in extra: # pretend this server rejects that variant + body = b"Bad Request

400 Bad Request

" + self.send_response(400) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + return self.wfile.write(body) return self.chunked(method, q) if p == "/photo/:/transcode": return self.file(method, b"\xff\xd8\xff\xe0JPEGDATA" * 50, "image/jpeg")