Minamp 1.1

- Run network/TLS work on 256 KB-stack threads (net/Threads.java): Android 2.x's
  ~16 KB default stack overflowed during the bundled TLS handshake
- Close pooled TLS connections off the caller thread
- Try several Plex music-transcoder URL variants until the server accepts one,
  and remember the one that worked
- Accept pasted server URLs with paths; default to 443 for https
- Catch fatal errors and save a crash report (shown on next launch and written
  to Minamp-error.txt on the SD card)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xockoandClaude Opus 5.5 committed 2026-10-14 19:59:58 +02:00
1 parent 948d1198ad
commit 0a2ac901bd
16 files changed
+417 -80

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<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="1" android:versionCode="2"
android:versionName="1.0" android:versionName="1.1"
android:installLocation="auto"> android:installLocation="auto">
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" /> <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
+55
View File
@@ -48,6 +48,7 @@ public class App extends Application {
instance = this; instance = this;
handler = new Handler(); handler = new Handler();
prefs = getSharedPreferences("minamp", MODE_PRIVATE); prefs = getSharedPreferences("minamp", MODE_PRIVATE);
installCrashLogger();
// Android 2.2's HttpURLConnection pool is buggy; we use our own client, but be safe. // Android 2.2's HttpURLConnection pool is buggy; we use our own client, but be safe.
System.setProperty("http.keepAlive", "false"); 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) {} try { Plex.version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName; } catch (Exception ignored) {}
CertVerifier.setRootStore(new AssetRoots(this)); CertVerifier.setRootStore(new AssetRoots(this));
Library.preferredVariant = Math.max(0, Math.min(Library.VARIANTS - 1, prefs.getInt("tcVariant", 0)));
images = new ImageLoader(this); images = new ImageLoader(this);
loadServer(); loadServer();
PlayQueue.get().restore(this); PlayQueue.get().restore(this);
@@ -76,6 +78,59 @@ public class App extends Application {
}, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); }, 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 // ------------------------------------------------------------------ server/library
public void loadServer() { public void loadServer() {
+27 -6
View File
@@ -3,7 +3,6 @@ package com.minamp;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import android.app.Activity; import android.app.Activity;
import android.app.AlertDialog; import android.app.AlertDialog;
@@ -25,7 +24,7 @@ import com.minamp.plex.Server;
/** Shared bits: background tasks, mini player, item actions, settings menu. */ /** Shared bits: background tasks, mini player, item actions, settings menu. */
public abstract class BaseActivity extends Activity implements PlayerService.Listener, PlayQueue.Listener { 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 App app;
protected boolean destroyed; 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); } }); app.handler.post(new Runnable() { public void run() { if (!destroyed) t.done(r); } });
} catch (final Exception e) { } catch (final Exception e) {
app.handler.post(new Runnable() { public void run() { if (!destroyed) t.fail(e); } }); app.handler.post(new Runnable() { public void run() { if (!destroyed) t.fail(e); } });
} catch (final OutOfMemoryError e) { } catch (final Throwable e) {
app.images.clearMemory(); // Errors (out of memory, stack overflow, a class the old VM can't load) must not kill the app
app.handler.post(new Runnable() { public void run() { if (!destroyed) t.fail(new Exception("Out of memory")); } }); 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); } 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(); 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 @Override
protected void onResume() { protected void onResume() {
super.onResume(); super.onResume();
+1
View File
@@ -48,6 +48,7 @@ public class HomeActivity extends BaseActivity {
return; return;
} }
setContentView(R.layout.activity_home); setContentView(R.layout.activity_home);
showCrashReportIfAny();
setupTopBar(app.sectionTitle(), connectionLine(), false); setupTopBar(app.sectionTitle(), connectionLine(), false);
barAction(R.id.btn_action, Glyph.SEARCH, new View.OnClickListener() { barAction(R.id.btn_action, Glyph.SEARCH, new View.OnClickListener() {
public void onClick(View v) { startActivity(new Intent(HomeActivity.this, SearchActivity.class)); } public void onClick(View v) { startActivity(new Intent(HomeActivity.this, SearchActivity.class)); }
+3 -4
View File
@@ -44,10 +44,9 @@ public final class ImageLoader {
memMax = (int) Math.min(max / 8, 4 * 1024 * 1024); memMax = (int) Math.min(max / 8, 4 * 1024 * 1024);
mem = new LinkedHashMap<String, Bitmap>(32, 0.75f, true); mem = new LinkedHashMap<String, Bitmap>(32, 0.75f, true);
for (int i = 0; i < 2; i++) { 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(); } public void run() { loop(); }
}; }, "img-" + i);
t.setDaemon(true);
t.setPriority(Thread.MIN_PRIORITY + 1); t.setPriority(Thread.MIN_PRIORITY + 1);
t.start(); 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; if (j.view != null && !j.key.equals(j.view.getTag(R.id.tag_image))) continue;
Bitmap b = fromMem(j.key); Bitmap b = fromMem(j.key);
if (b == null) { 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) { if (b != null) {
synchronized (mem) { synchronized (mem) {
Bitmap old = mem.put(j.key, b); Bitmap old = mem.put(j.key, b);
+2 -2
View File
@@ -232,7 +232,7 @@ public final class PlayQueue {
.put("shuffle", Boolean.valueOf(shuffle)).put("title", title).put("pos", Long.valueOf(resumePositionMs)); .put("shuffle", Boolean.valueOf(shuffle)).put("title", title).put("pos", Long.valueOf(resumePositionMs));
final String data = o.toString(); final String data = o.toString();
saving = false; saving = false;
new Thread("save-queue") { com.minamp.net.Threads.start(new Runnable() {
public void run() { public void run() {
synchronized (PlayQueue.class) { synchronized (PlayQueue.class) {
try { try {
@@ -244,6 +244,6 @@ public final class PlayQueue {
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
} }
}.start(); }, "save-queue");
} }
} }
+72 -15
View File
@@ -7,7 +7,6 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import android.app.Notification; import android.app.Notification;
import android.app.NotificationManager; import android.app.NotificationManager;
@@ -109,8 +108,8 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
} }
// ---- instance ---- // ---- instance ----
private final ExecutorService worker = Executors.newSingleThreadExecutor(); private final ExecutorService worker = com.minamp.net.Threads.pool("player", 1);
private final ExecutorService reporter = Executors.newSingleThreadExecutor(); private final ExecutorService reporter = com.minamp.net.Threads.pool("report", 1);
private StreamProxy proxy; private StreamProxy proxy;
private MediaPlayer mp; private MediaPlayer mp;
private boolean prepared; private boolean prepared;
@@ -125,6 +124,9 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private String session; private String session;
private String routeId; private String routeId;
private int retries; 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 int failuresInRow;
private boolean forceDownload; private boolean forceDownload;
private boolean scrobbled; private boolean scrobbled;
@@ -150,6 +152,20 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL, "minamp"); wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL, "minamp");
wifiLock.setReferenceCounted(false); wifiLock.setReferenceCounted(false);
proxy = new StreamProxy(); 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 { proxy.start(); } catch (IOException e) { /* reported when playback starts */ }
try { try {
TelephonyManager tm = (TelephonyManager) getSystemService(TELEPHONY_SERVICE); 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); 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. // 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 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<String> urls;
final int[] order = direct ? null : Library.variantOrder();
if (direct) { urls = new java.util.ArrayList<String>(); urls.add(lib.directUrl(it)); }
else urls = lib.transcodeUrls(it, max, startAt, sess);
final String ext = direct ? extFor(it) : "mp3"; final String ext = direct ? extFor(it) : "mp3";
File file = null; File file = null;
if (download) file = downloadTo(url, g, it); if (download) file = downloadTo(urls, order, g, it);
final File ff = file; final File ff = file;
post(new Runnable() { post(new Runnable() {
public void run() { public void run() {
if (g != gen) return; if (g != gen) return;
transcoding = !direct; transcoding = !direct;
session = sess; session = sess;
variantOrder = order;
kbps = direct ? 0 : (max <= 0 ? 320 : max); 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() { 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"; return "mp3";
} }
private File downloadTo(String url, int g, Item it) throws IOException { private File downloadTo(java.util.List<String> urls, final int[] order, int g, Item it) throws IOException {
File base = null; File base = null;
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) base = getExternalCacheDir(); if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) base = getExternalCacheDir();
if (base == null) base = getCacheDir(); if (base == null) base = getCacheDir();
File f = new File(base, "play-" + (g % 2) + ".tmp"); File f = new File(base, "play-" + (g % 2) + ".tmp");
Http.Response r = Http.get(url, Plex.headers(null), 30000); Http.Response r = null;
if (r.code != 200) { r.close(); throw new IOException("Server said " + r.code); } 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; InputStream in = r.body;
FileOutputStream out = new FileOutputStream(f); FileOutputStream out = new FileOutputStream(f);
try { try {
@@ -316,7 +353,20 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
return f; 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<String> urls, String ext, File file, long offset, long seekTo) {
try { try {
mp = new MediaPlayer(); mp = new MediaPlayer();
mp.setAudioStreamType(AudioManager.STREAM_MUSIC); mp.setAudioStreamType(AudioManager.STREAM_MUSIC);
@@ -335,7 +385,9 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
} else { } else {
if (proxy.port() < 0) proxy.start(); if (proxy.port() < 0) proxy.start();
routeId = proxy.newId(); routeId = proxy.newId();
mp.setDataSource(proxy.register(routeId, url, ext)); refusal = null;
refusalCode = 0;
mp.setDataSource(proxy.register(routeId, urls, ext));
} }
setState(BUFFERING, "Buffering…"); setState(BUFFERING, "Buffering…");
mp.prepareAsync(); mp.prepareAsync();
@@ -400,7 +452,12 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private void trackFailed(String msg) { private void trackFailed(String msg) {
long pos = Math.max(currentPosition(), lastPos); 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(); App app = App.get();
if (retries == 1) { if (retries == 1) {
// Most failures on a phone are network changes: re-pick the connection and resume. // 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 post(Runnable r) { App.get().handler.post(r); }
private void toast(String m) { 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) { static String message(Exception e) {
+1
View File
@@ -32,6 +32,7 @@ public class SetupActivity extends BaseActivity {
protected void onCreate(Bundle b) { protected void onCreate(Bundle b) {
super.onCreate(b); super.onCreate(b);
setContentView(R.layout.activity_setup); setContentView(R.layout.activity_setup);
showCrashReportIfAny();
welcome = findViewById(R.id.page_welcome); welcome = findViewById(R.id.page_welcome);
pinPage = findViewById(R.id.page_pin); pinPage = findViewById(R.id.page_pin);
manual = findViewById(R.id.page_manual); manual = findViewById(R.id.page_manual);
+7 -4
View File
@@ -135,13 +135,16 @@ public final class Http {
/** Drop all pooled connections (e.g. after the network changed). */ /** Drop all pooled connections (e.g. after the network changed). */
public static void evictAll() { public static void evictAll() {
final List<Conn> doomed = new ArrayList<Conn>();
synchronized (pool) { synchronized (pool) {
for (Iterator<List<Conn>> it = pool.values().iterator(); it.hasNext();) { for (Iterator<List<Conn>> it = pool.values().iterator(); it.hasNext();) doomed.addAll(it.next());
List<Conn> l = it.next();
for (int i = 0; i < l.size(); i++) l.get(i).close();
}
pool.clear(); 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 { private static Conn connect(final Url u, int timeoutMs) throws IOException {
+59 -20
View File
@@ -26,19 +26,29 @@ public final class StreamProxy implements Runnable {
private Thread thread; private Thread thread;
private volatile boolean running; private volatile boolean running;
private int counter; private int counter;
private final Map<String, String> routes = new HashMap<String, String>(); 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>>(); private final Map<String, List<Http.Response>> live = new HashMap<String, List<Http.Response>>();
/** Called with how many bytes have arrived for a route (for buffering UI / diagnostics). */ public interface Listener {
public interface Listener { void onError(String routeId, String message); } /** 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 volatile Listener listener;
public synchronized void start() throws IOException { public synchronized void start() throws IOException {
if (running) return; if (running) return;
server = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1")); server = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1"));
running = true; running = true;
thread = new Thread(this, "stream-proxy"); thread = Threads.create(this, "stream-proxy");
thread.setDaemon(true);
thread.start(); 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. */ /** @return the local URL to give MediaPlayer, ending in a fake extension it may sniff. */
public String register(String id, String remoteUrl, String ext) { 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) { synchronized (routes) {
routes.put(id, remoteUrl); routes.put(id, new Route(new ArrayList<String>(remoteUrls)));
} }
return "http://127.0.0.1:" + port() + "/s/" + id + "/stream." + ext; return "http://127.0.0.1:" + port() + "/s/" + id + "/stream." + ext;
} }
@@ -89,11 +105,9 @@ public final class StreamProxy implements Runnable {
if (!running) return; if (!running) return;
continue; continue;
} }
Thread t = new Thread("proxy-conn") { Threads.start(new Runnable() {
public void run() { handle(s); } public void run() { handle(s); }
}; }, "proxy-conn");
t.setDaemon(true);
t.start();
} }
} }
@@ -120,9 +134,9 @@ public final class StreamProxy implements Runnable {
int slash = path.indexOf('/', 3); int slash = path.indexOf('/', 3);
id = slash < 0 ? path.substring(3) : path.substring(3, slash); id = slash < 0 ? path.substring(3) : path.substring(3, slash);
} }
String target; Route route;
synchronized (routes) { target = id == null ? null : routes.get(id); } synchronized (routes) { route = id == null ? null : routes.get(id); }
if (target == null) { 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.write("HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".getBytes("ISO-8859-1"));
out.flush(); out.flush();
return; return;
@@ -133,7 +147,31 @@ public final class StreamProxy implements Runnable {
String range = reqHeaders.get("range"); String range = reqHeaders.get("range");
if (range != null) h.put("Range", range); if (range != null) h.put("Range", range);
boolean head = "HEAD".equals(method); 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) { synchronized (routes) {
if (!routes.containsKey(id)) { Http.abort(up); return; } if (!routes.containsKey(id)) { Http.abort(up); return; }
List<Http.Response> l = live.get(id); List<Http.Response> l = live.get(id);
@@ -142,13 +180,15 @@ public final class StreamProxy implements Runnable {
} }
StringBuilder resp = new StringBuilder(256); 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"); 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"); String ct = up.header("content-type");
if (ct == null || ct.startsWith("application/octet") || ct.startsWith("text/")) ct = guessType(path); if (ct == null || ct.startsWith("application/octet") || ct.startsWith("text/")) ct = guessType(path);
if (up.code >= 400) ct = "text/plain"; if (up.code >= 400) ct = "text/plain";
resp.append("Content-Type: ").append(ct).append("\r\n"); resp.append("Content-Type: ").append(ct).append("\r\n");
if (known) resp.append("Content-Length: ").append(up.contentLength).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"); String cr = up.header("content-range");
if (cr != null) resp.append("Content-Range: ").append(cr).append("\r\n"); if (cr != null) resp.append("Content-Range: ").append(cr).append("\r\n");
String ar = up.header("accept-ranges"); String ar = up.header("accept-ranges");
@@ -156,11 +196,7 @@ public final class StreamProxy implements Runnable {
resp.append("Connection: close\r\n\r\n"); resp.append("Connection: close\r\n\r\n");
out.write(resp.toString().getBytes("ISO-8859-1")); out.write(resp.toString().getBytes("ISO-8859-1"));
out.flush(); out.flush();
if (up.code >= 400) { if (head || up.code >= 400) { up.close(); up = null; return; }
Listener l = listener;
if (l != null) l.onError(id, "Server said " + up.code);
}
if (head) { up.close(); return; }
byte[] buf = new byte[16384]; byte[] buf = new byte[16384];
int n; int n;
@@ -176,6 +212,9 @@ public final class StreamProxy implements Runnable {
up = null; up = null;
} catch (IOException e) { } catch (IOException e) {
// player went away, track skipped, or the network dropped: just close // 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 { } finally {
if (up != null) Http.abort(up); if (up != null) Http.abort(up);
if (id != null && up != null) { if (id != null && up != null) {
+43
View File
@@ -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;
}
}
+52 -11
View File
@@ -36,7 +36,7 @@ public final class Library {
} }
public String recentlyPlayedPath(int size) { 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() { public String playlistsPath() {
@@ -84,7 +84,7 @@ public final class Library {
/** Tracks rated four stars or better (and loved tracks), shuffled. */ /** Tracks rated four stars or better (and loved tracks), shuffled. */
public List<Item> topRatedRadio() throws IOException { public List<Item> 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. */ /** 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); 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<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]));
return out;
}
String transcodeUrl(Item t, int kbps, long offsetMs, String session, int variant) throws IOException {
if (kbps <= 0) kbps = 320; if (kbps <= 0) kbps = 320;
String profile = "add-transcode-target(type=musicProfile&context=streaming&protocol=http&container=mp3&audioCodec=mp3)" String target = "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 + ")" String profile;
+ "+add-limitation(scope=musicCodec&scopeName=mp3&type=upperBound&name=audio.channels&value=2)"; String platform;
String path = "/music/:/transcode/universal/start.mp3" 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) + "?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&copyts=1"
+ "&directPlay=0&directStream=0&directStreamAudio=0" + "&directPlay=0&directStream=0&directStreamAudio=0"
+ "&musicBitrate=" + kbps + "&maxAudioBitrate=" + kbps + "&musicBitrate=" + kbps + "&maxAudioBitrate=" + kbps
+ "&offset=" + (offsetMs / 1000) + "&offset=" + (offsetMs / 1000)
@@ -190,9 +231,9 @@ public final class Library {
+ "&X-Plex-Client-Identifier=" + Plex.enc(Plex.clientId) + "&X-Plex-Client-Identifier=" + Plex.enc(Plex.clientId)
+ "&X-Plex-Product=" + Plex.enc(Plex.product) + "&X-Plex-Product=" + Plex.enc(Plex.product)
+ "&X-Plex-Version=" + Plex.enc(Plex.version) + "&X-Plex-Version=" + Plex.enc(Plex.version)
+ "&X-Plex-Platform=Generic" + "&X-Plex-Platform=" + platform
+ "&X-Plex-Device=" + Plex.enc(Plex.device) + "&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); return server.url(path);
} }
+21 -2
View File
@@ -49,8 +49,11 @@ public final class Plex {
static Json.Obj fetchJson(String method, String url, String token, int timeout) throws IOException { 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); Http.Response r = Http.request(method, url, headers(token), "POST".equals(method) ? new byte[0] : null, timeout);
String body = r.readString(); String body = r.readString();
if (r.code == 401) throw new PlexException(401, "Not authorized"); if (r.code == 401) throw new PlexException(401, "Not authorized (401) on " + describePath(url));
if (r.code < 200 || r.code >= 300) throw new PlexException(r.code, "HTTP " + r.code); 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(); if (body.trim().length() == 0) return new Json.Obj();
Object o; Object o;
try { o = Json.parse(body); } catch (IllegalArgumentException e) { throw new IOException("Bad response from server"); } 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; 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 static final class PlexException extends IOException {
public final int code; public final int code;
public PlexException(int code, String msg) { super(msg); this.code = code; } public PlexException(int code, String msg) { super(msg); this.code = code; }
+17 -9
View File
@@ -54,10 +54,19 @@ public final class Server {
String s = input.trim(); String s = input.trim();
while (s.endsWith("/")) s = s.substring(0, s.length() - 1); while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
String lower = s.toLowerCase(); String lower = s.toLowerCase();
if (lower.startsWith("http://") || lower.startsWith("https://")) return s; // explicit: keep as typed String scheme = "http://";
// bare host or IP: assume plain HTTP on the default Plex port if (lower.startsWith("https://")) { scheme = "https://"; s = s.substring(8); }
if (s.indexOf(':') < 0 && s.indexOf('/') < 0) s = s + ":32400"; else if (lower.startsWith("http://")) { s = s.substring(7); }
return "http://" + s; // 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; } public Conn active() { return active; }
@@ -107,7 +116,7 @@ public final class Server {
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
final int idx = i; final int idx = i;
final Conn c = connections.get(i); final Conn c = connections.get(i);
Thread t = new Thread("probe-" + i) { Runnable probe = new Runnable() {
public void run() { public void run() {
boolean ok = false; boolean ok = false;
try { try {
@@ -118,8 +127,8 @@ public final class Server {
String id = mc == null ? "" : mc.optString("machineIdentifier"); String id = mc == null ? "" : mc.optString("machineIdentifier");
ok = machineId.length() == 0 || machineId.equals(id); ok = machineId.length() == 0 || machineId.equals(id);
if (!ok) errors[idx] = "different server answered"; if (!ok) errors[idx] = "different server answered";
} catch (Exception e) { } catch (Throwable e) {
errors[idx] = String.valueOf(e.getMessage()); errors[idx] = com.minamp.net.Threads.describe(e);
} }
synchronized (lock) { synchronized (lock) {
state[idx] = ok ? 1 : 2; state[idx] = ok ? 1 : 2;
@@ -127,8 +136,7 @@ public final class Server {
} }
} }
}; };
t.setDaemon(true); com.minamp.net.Threads.start(probe, "probe-" + i);
t.start();
} }
long deadline = System.currentTimeMillis() + 12000; long deadline = System.currentTimeMillis() + 12000;
synchronized (lock) { synchronized (lock) {
+44 -4
View File
@@ -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])); CertVerifier.setRootStore(roots(args[0]));
Plex.clientId = "test-client"; 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, 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() + ")"); "search hubs grouped (" + res.size() + ")");
check(lib.libraryRadio().size() == 2, "library radio"); 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); String img = s2.imageUrl("/library/metadata/10/thumb/1", 64);
Http.Response ir = Http.get(img, null, 10000); Http.Response ir = Http.get(img, null, 10000);
byte[] ib = ir.readAll(); byte[] ib = ir.readAll();
@@ -110,13 +124,31 @@ public class PlexTest {
check(hc.getResponseCode() == 206 && body.length == 299000 && (body[0] & 255) == (1000 * 7) % 256, 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)"); "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 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); hc = (HttpURLConnection) new URL(local2).openConnection(Proxy.NO_PROXY);
byte[] tbody = read(hc.getInputStream()); byte[] tbody = read(hc.getInputStream());
String ts = new String(tbody, "ISO-8859-1"); String ts = new String(tbody, "ISO-8859-1");
check(hc.getResponseCode() == 200 && ts.startsWith("TRANSCODE offset=30 bitrate=192|") && hc.getHeaderField("Content-Length") == null, check(hc.getResponseCode() == 200 && ts.startsWith("TRANSCODE offset=30 bitrate=192|") && hc.getHeaderField("Content-Length") == null,
"proxy de-chunks transcode stream (" + tbody.length + "B)"); "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<String> bad = new java.util.ArrayList<String>();
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 // abort mid-stream
String id3 = proxy.newId(); 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("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"); 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"); check(Server.normalizeManual("http://192.168.1.5:32400/web/index.html#!/").equals("http://192.168.1.5:32400"), "pasted web-app path stripped");
System.exit(failures == 0 ? 0 : 1); 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 { static byte[] read(InputStream in) throws Exception {
+11 -1
View File
@@ -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)] 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]}}) return self.send_json({"MediaContainer": {"totalSize": 250, "offset": start, "Metadata": arts[start:start + size]}})
if t == "10": 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": TRACKS}})
return self.send_json({"MediaContainer": {"Metadata": [{"type": "album", "ratingKey": "10", "title": "First Album", return self.send_json({"MediaContainer": {"Metadata": [{"type": "album", "ratingKey": "10", "title": "First Album",
"parentTitle": "The Testers", "year": 1999, "thumb": "/library/metadata/10/thumb/1"}]}}) "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]}]}}) {"type": "track", "title": "Tracks", "Metadata": TRACKS[:1]}]}})
if p.startswith("/library/parts/501"): if p.startswith("/library/parts/501"):
return self.file(method, MP3) 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"<html><head><title>Bad Request</title></head><body><h1>400 Bad Request</h1></body></html>"
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) return self.chunked(method, q)
if p == "/photo/:/transcode": if p == "/photo/:/transcode":
return self.file(method, b"\xff\xd8\xff\xe0JPEGDATA" * 50, "image/jpeg") return self.file(method, b"\xff\xd8\xff\xe0JPEGDATA" * 50, "image/jpeg")