Private
Public Access
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:
1 parent
948d1198ad
commit
0a2ac901bd
16 files changed
+417
-80
No files matched your search
+2
-2
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.minamp"
|
||||
android:versionCode="1"
|
||||
android:versionName="1.0"
|
||||
android:versionCode="2"
|
||||
android:versionName="1.1"
|
||||
android:installLocation="auto">
|
||||
|
||||
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)); }
|
||||
|
||||
@@ -44,10 +44,9 @@ public final class ImageLoader {
|
||||
memMax = (int) Math.min(max / 8, 4 * 1024 * 1024);
|
||||
mem = new LinkedHashMap<String, Bitmap>(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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<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";
|
||||
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<String> 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<String> 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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -135,13 +135,16 @@ public final class Http {
|
||||
|
||||
/** Drop all pooled connections (e.g. after the network changed). */
|
||||
public static void evictAll() {
|
||||
final List<Conn> doomed = new ArrayList<Conn>();
|
||||
synchronized (pool) {
|
||||
for (Iterator<List<Conn>> it = pool.values().iterator(); it.hasNext();) {
|
||||
List<Conn> l = it.next();
|
||||
for (int i = 0; i < l.size(); i++) l.get(i).close();
|
||||
}
|
||||
for (Iterator<List<Conn>> 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 {
|
||||
|
||||
@@ -26,19 +26,29 @@ public final class StreamProxy implements Runnable {
|
||||
private Thread thread;
|
||||
private volatile boolean running;
|
||||
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>>();
|
||||
|
||||
/** 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<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, remoteUrl);
|
||||
routes.put(id, new Route(new ArrayList<String>(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<Http.Response> 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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<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. */
|
||||
@@ -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<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;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+44
-4
@@ -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<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
|
||||
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 {
|
||||
|
||||
+11
-1
@@ -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"<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)
|
||||
if p == "/photo/:/transcode":
|
||||
return self.file(method, b"\xff\xd8\xff\xe0JPEGDATA" * 50, "image/jpeg")
|
||||
|
||||
Reference in new issue
Block a user