Private
Public Access
Minamp 1.2
- Transcoding: send client identities Plex has a profile for (unknown ones get 400 "unable to find a matching profile"), with a fresh session id per variant - Stream proxy: fetch transcodes once into a temp file and serve every player request from it, so reconnects no longer restart the transcode (503s, tracks cut short); original files still pass Range requests through - Sign-in: remember the pending link code so it survives Minamp being closed while the code is entered elsewhere; add "I've entered it - check now" - Diagnostics: in-app connection log (DiagLog) with a streaming test and save to SD card; tokens are never logged Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a2ac901bd
commit
a4b875ef34
12 files changed
+701
-196
No files matched your search
@@ -0,0 +1,47 @@
|
||||
package com.minamp.net;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* In-memory diagnostics log (last few hundred lines): every request with its status and timing,
|
||||
* sign-in steps, stream attempts. Viewable in the app and savable to the SD card, so a problem
|
||||
* on a real server can be reported precisely. Tokens are never logged.
|
||||
*/
|
||||
public final class DiagLog {
|
||||
private static final int MAX = 400;
|
||||
private static final List<String> lines = new ArrayList<String>();
|
||||
private static final SimpleDateFormat fmt = new SimpleDateFormat("HH:mm:ss", Locale.US);
|
||||
|
||||
private DiagLog() {}
|
||||
|
||||
public static void log(String msg) {
|
||||
String line;
|
||||
synchronized (fmt) { line = fmt.format(new Date()) + " " + msg; }
|
||||
synchronized (lines) {
|
||||
lines.add(line);
|
||||
if (lines.size() > MAX) lines.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
public static String dump() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
synchronized (lines) {
|
||||
for (int i = 0; i < lines.size(); i++) sb.append(lines.get(i)).append('\n');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
synchronized (lines) { lines.clear(); }
|
||||
}
|
||||
|
||||
/** URL without the token value, safe to show and share. */
|
||||
public static String redact(String url) {
|
||||
if (url == null) return "";
|
||||
return url.replaceAll("(X-Plex-Token=)[^&]*", "$1***");
|
||||
}
|
||||
}
|
||||
@@ -283,7 +283,20 @@ public final class Http {
|
||||
|
||||
public static Response request(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs) throws IOException {
|
||||
for (int redirects = 0; ; redirects++) {
|
||||
Response r = requestOnce(method, url, headers, body, timeoutMs);
|
||||
long t0 = System.currentTimeMillis();
|
||||
Response r;
|
||||
try {
|
||||
r = requestOnce(method, url, headers, body, timeoutMs);
|
||||
} catch (IOException e) {
|
||||
DiagLog.log(method + " " + DiagLog.redact(url) + " -> FAILED after " + (System.currentTimeMillis() - t0) + "ms: " + Threads.describe(e));
|
||||
throw e;
|
||||
} catch (RuntimeException e) {
|
||||
DiagLog.log(method + " " + DiagLog.redact(url) + " -> FAILED: " + Threads.describe(e));
|
||||
throw e;
|
||||
}
|
||||
if (!url.contains("/photo/:/transcode")) { // artwork would drown out everything else
|
||||
DiagLog.log(method + " " + DiagLog.redact(url) + " -> " + r.code + " (" + (System.currentTimeMillis() - t0) + "ms)");
|
||||
}
|
||||
if ((r.code == 301 || r.code == 302 || r.code == 307 || r.code == 308) && redirects < 4
|
||||
&& ("GET".equals(method) || "HEAD".equals(method))) {
|
||||
String loc = r.header("location");
|
||||
|
||||
+290
-104
@@ -1,9 +1,12 @@
|
||||
package com.minamp.net;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
@@ -17,8 +20,13 @@ import java.util.Map;
|
||||
/**
|
||||
* Loopback HTTP server that hands the platform MediaPlayer a plain http://127.0.0.1 URL and
|
||||
* fetches the real stream through {@link Http} (our own TLS, plex.direct, relay...).
|
||||
* It also turns chunked transcoder output into a simple read-until-close response, which
|
||||
* Android 2.x's media stack copes with much better.
|
||||
*
|
||||
* Two kinds of route:
|
||||
* - pass-through (original files): each player request, Range included, is forwarded.
|
||||
* - buffered (transcodes): the server is asked exactly once; the bytes go to a temp file and
|
||||
* every player request (reconnects included) is served from it. Asking Plex's transcoder
|
||||
* twice for one session tears the first down, which shows up as 503s and cut-off tracks.
|
||||
* Each route may carry several alternative URLs; the first the server accepts wins.
|
||||
*/
|
||||
public final class StreamProxy implements Runnable {
|
||||
|
||||
@@ -27,14 +35,8 @@ public final class StreamProxy implements Runnable {
|
||||
private volatile boolean running;
|
||||
private int counter;
|
||||
private final Map<String, Route> routes = new HashMap<String, Route>();
|
||||
|
||||
/** One stream, with fallback URLs tried in order until the server accepts one. */
|
||||
private static final class Route {
|
||||
final List<String> urls;
|
||||
int chosen = -1; // index that worked; later requests (seeks) go straight to it
|
||||
Route(List<String> urls) { this.urls = urls; }
|
||||
}
|
||||
private final Map<String, List<Http.Response>> live = new HashMap<String, List<Http.Response>>();
|
||||
/** Where buffered streams are written (set by the app; temp dir otherwise). */
|
||||
public volatile File cacheDir;
|
||||
|
||||
public interface Listener {
|
||||
/** Every URL was refused (or the proxy failed); code is the last HTTP status, or 0. */
|
||||
@@ -44,6 +46,25 @@ public final class StreamProxy implements Runnable {
|
||||
}
|
||||
public volatile Listener listener;
|
||||
|
||||
private static final class Route {
|
||||
final String id;
|
||||
final List<String> urls;
|
||||
final boolean buffered;
|
||||
int chosen = -1; // index that worked
|
||||
final List<Http.Response> live = new ArrayList<Http.Response>();
|
||||
// buffered state (guarded by this)
|
||||
boolean started, done, cancelled;
|
||||
long written;
|
||||
int failCode;
|
||||
String failMessage;
|
||||
File file;
|
||||
Http.Response upstream;
|
||||
|
||||
Route(String id, List<String> urls, boolean buffered) {
|
||||
this.id = id; this.urls = urls; this.buffered = buffered;
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void start() throws IOException {
|
||||
if (running) return;
|
||||
server = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1"));
|
||||
@@ -55,45 +76,55 @@ public final class StreamProxy implements Runnable {
|
||||
public synchronized void shutdown() {
|
||||
running = false;
|
||||
try { if (server != null) server.close(); } catch (IOException ignored) {}
|
||||
synchronized (routes) {
|
||||
for (String id : new ArrayList<String>(live.keySet())) abortLocked(id);
|
||||
routes.clear();
|
||||
}
|
||||
List<String> ids;
|
||||
synchronized (routes) { ids = new ArrayList<String>(routes.keySet()); }
|
||||
for (int i = 0; i < ids.size(); i++) unregister(ids.get(i));
|
||||
}
|
||||
|
||||
public int port() { return server == null ? -1 : server.getLocalPort(); }
|
||||
|
||||
/** @return the local URL to give MediaPlayer, ending in a fake extension it may sniff. */
|
||||
public String register(String id, String remoteUrl, String ext) {
|
||||
List<String> l = new ArrayList<String>();
|
||||
l.add(remoteUrl);
|
||||
return register(id, l, ext);
|
||||
}
|
||||
|
||||
public String register(String id, List<String> remoteUrls, String ext) {
|
||||
synchronized (routes) {
|
||||
routes.put(id, new Route(new ArrayList<String>(remoteUrls)));
|
||||
}
|
||||
return "http://127.0.0.1:" + port() + "/s/" + id + "/stream." + ext;
|
||||
}
|
||||
|
||||
public String newId() {
|
||||
synchronized (routes) { return Integer.toString(++counter) + "x" + Long.toString(System.currentTimeMillis() % 100000, 36); }
|
||||
}
|
||||
|
||||
/** Forget a route and cut any upstream connections still feeding it. */
|
||||
public void unregister(String id) {
|
||||
if (id == null) return;
|
||||
synchronized (routes) {
|
||||
routes.remove(id);
|
||||
abortLocked(id);
|
||||
}
|
||||
public String register(String id, String remoteUrl, String ext) {
|
||||
List<String> l = new ArrayList<String>();
|
||||
l.add(remoteUrl);
|
||||
return register(id, l, ext, false);
|
||||
}
|
||||
|
||||
private void abortLocked(String id) {
|
||||
List<Http.Response> l = live.remove(id);
|
||||
if (l == null) return;
|
||||
for (int i = 0; i < l.size(); i++) Http.abort(l.get(i));
|
||||
/** @return the local URL to give MediaPlayer, ending in an extension it may sniff. */
|
||||
public String register(String id, List<String> remoteUrls, String ext, boolean buffered) {
|
||||
synchronized (routes) {
|
||||
routes.put(id, new Route(id, new ArrayList<String>(remoteUrls), buffered));
|
||||
}
|
||||
return "http://127.0.0.1:" + port() + "/s/" + id + "/stream." + ext;
|
||||
}
|
||||
|
||||
/** Forget a route, cut its upstream connections and delete its buffer file. */
|
||||
public void unregister(String id) {
|
||||
if (id == null) return;
|
||||
Route r;
|
||||
synchronized (routes) { r = routes.remove(id); }
|
||||
if (r == null) return;
|
||||
final File f;
|
||||
synchronized (r) {
|
||||
r.cancelled = true;
|
||||
Http.abort(r.upstream);
|
||||
for (int i = 0; i < r.live.size(); i++) Http.abort(r.live.get(i));
|
||||
r.live.clear();
|
||||
r.notifyAll();
|
||||
f = r.file;
|
||||
}
|
||||
if (f != null) {
|
||||
// give readers a moment to notice before the file goes away
|
||||
Threads.start(new Runnable() {
|
||||
public void run() {
|
||||
try { Thread.sleep(1500); } catch (InterruptedException ignored) {}
|
||||
f.delete();
|
||||
}
|
||||
}, "proxy-cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
public void run() {
|
||||
@@ -111,8 +142,9 @@ public final class StreamProxy implements Runnable {
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ request handling
|
||||
|
||||
private void handle(Socket s) {
|
||||
Http.Response up = null;
|
||||
String id = null;
|
||||
try {
|
||||
s.setSoTimeout(60000);
|
||||
@@ -129,7 +161,6 @@ public final class StreamProxy implements Runnable {
|
||||
String[] parts = reqLine.split(" ");
|
||||
String method = parts.length > 0 ? parts[0] : "GET";
|
||||
String path = parts.length > 1 ? parts[1] : "/";
|
||||
// /s/<id>/stream.mp3
|
||||
if (path.startsWith("/s/")) {
|
||||
int slash = path.indexOf('/', 3);
|
||||
id = slash < 0 ? path.substring(3) : path.substring(3, slash);
|
||||
@@ -137,58 +168,88 @@ public final class StreamProxy implements Runnable {
|
||||
Route route;
|
||||
synchronized (routes) { route = id == null ? null : routes.get(id); }
|
||||
if (route == null) {
|
||||
out.write("HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".getBytes("ISO-8859-1"));
|
||||
out.flush();
|
||||
writeEmpty(out, 404, "Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, String> h = new LinkedHashMap<String, String>();
|
||||
h.put("Accept", "*/*");
|
||||
String range = reqHeaders.get("range");
|
||||
if (range != null) h.put("Range", range);
|
||||
boolean head = "HEAD".equals(method);
|
||||
int first, last;
|
||||
synchronized (routes) {
|
||||
first = route.chosen >= 0 ? route.chosen : 0;
|
||||
last = route.chosen >= 0 ? route.chosen : route.urls.size() - 1;
|
||||
}
|
||||
String refusal = null;
|
||||
for (int i = first; i <= last; i++) {
|
||||
up = Http.request(head ? "HEAD" : "GET", route.urls.get(i), h, null, 30000);
|
||||
if (up.code < 400 || up.code == 416) {
|
||||
boolean firstTime;
|
||||
synchronized (routes) { firstTime = route.chosen < 0; route.chosen = i; }
|
||||
Listener l = listener;
|
||||
if (firstTime && l != null) l.onAccepted(id, i);
|
||||
break;
|
||||
}
|
||||
// refused: remember why, try the next variant
|
||||
String body = "";
|
||||
try { body = head ? "" : new String(up.readAll(), "UTF-8"); } catch (IOException ignored) {}
|
||||
refusal = "Server refused the stream (" + up.code + ")" + (body.trim().length() > 0 ? ": " + com.minamp.plex.Plex.snippet(body) : "");
|
||||
if (i < last) { up.close(); up = null; }
|
||||
}
|
||||
if (up.code >= 400 && up.code != 416) {
|
||||
Listener l = listener;
|
||||
if (l != null) l.onError(id, up.code, refusal);
|
||||
}
|
||||
synchronized (routes) {
|
||||
if (!routes.containsKey(id)) { Http.abort(up); return; }
|
||||
List<Http.Response> l = live.get(id);
|
||||
if (l == null) { l = new ArrayList<Http.Response>(); live.put(id, l); }
|
||||
l.add(up);
|
||||
}
|
||||
if (route.buffered) serveBuffered(route, out, head, reqHeaders.get("range"), path);
|
||||
else servePassThrough(route, out, head, reqHeaders.get("range"), path);
|
||||
} catch (IOException e) {
|
||||
// player went away, track skipped, or the network dropped: just close
|
||||
} catch (Throwable t) {
|
||||
DiagLog.log("proxy error: " + Threads.describe(t));
|
||||
Listener l = listener;
|
||||
if (l != null && id != null) l.onError(id, 0, Threads.describe(t));
|
||||
} finally {
|
||||
try { s.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeEmpty(OutputStream out, int code, String reason) throws IOException {
|
||||
out.write(("HTTP/1.0 " + code + " " + reason + "\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").getBytes("ISO-8859-1"));
|
||||
out.flush();
|
||||
}
|
||||
|
||||
private static final class RefusedException extends IOException {
|
||||
final int code;
|
||||
RefusedException(int code, String msg) { super(msg); this.code = code; }
|
||||
}
|
||||
|
||||
/** Tries the route's URLs in order; returns the accepted response or throws with the server's words. */
|
||||
private Http.Response openUpstream(Route route, String method, String range, int first, int last) throws IOException {
|
||||
Map<String, String> h = new LinkedHashMap<String, String>();
|
||||
h.put("Accept", "*/*");
|
||||
if (range != null) h.put("Range", range);
|
||||
String refusal = null;
|
||||
int code = 0;
|
||||
for (int i = first; i <= last; i++) {
|
||||
Http.Response up = Http.request(method, route.urls.get(i), h, null, 30000);
|
||||
if (up.code < 400 || up.code == 416) {
|
||||
Listener l = listener;
|
||||
boolean firstTime;
|
||||
synchronized (route) { firstTime = route.chosen < 0; route.chosen = i; }
|
||||
if (firstTime && l != null) l.onAccepted(route.id, i);
|
||||
return up;
|
||||
}
|
||||
String body = "";
|
||||
try { body = "HEAD".equals(method) ? "" : new String(up.readAll(), "UTF-8"); } catch (IOException ignored) {}
|
||||
up.close();
|
||||
code = up.code;
|
||||
refusal = "Server refused the stream (" + up.code + ")" + (body.trim().length() > 0 ? ": " + com.minamp.plex.Plex.snippet(body) : "");
|
||||
DiagLog.log("stream variant " + i + " refused: " + refusal);
|
||||
}
|
||||
throw new RefusedException(code, refusal);
|
||||
}
|
||||
|
||||
// ---- pass-through ----
|
||||
|
||||
private void servePassThrough(Route route, OutputStream out, boolean head, String range, String path) throws IOException {
|
||||
int first, last;
|
||||
synchronized (route) {
|
||||
first = route.chosen >= 0 ? route.chosen : 0;
|
||||
last = route.chosen >= 0 ? route.chosen : route.urls.size() - 1;
|
||||
}
|
||||
Http.Response up;
|
||||
try {
|
||||
up = openUpstream(route, head ? "HEAD" : "GET", range, first, last);
|
||||
} catch (RefusedException e) {
|
||||
Listener l = listener;
|
||||
if (l != null) l.onError(route.id, e.code, e.getMessage());
|
||||
writeEmpty(out, e.code, "Refused");
|
||||
return;
|
||||
}
|
||||
synchronized (route) {
|
||||
if (route.cancelled) { Http.abort(up); return; }
|
||||
route.live.add(up);
|
||||
}
|
||||
try {
|
||||
StringBuilder resp = new StringBuilder(256);
|
||||
boolean refused = up.code >= 400 && up.code != 416;
|
||||
boolean known = !refused && up.contentLength >= 0 && !up.chunked;
|
||||
boolean known = up.contentLength >= 0 && !up.chunked;
|
||||
resp.append(known ? "HTTP/1.1 " : "HTTP/1.0 ").append(up.code).append(' ').append(reason(up.code)).append("\r\n");
|
||||
String ct = up.header("content-type");
|
||||
if (ct == null || ct.startsWith("application/octet") || ct.startsWith("text/")) ct = guessType(path);
|
||||
if (up.code >= 400) ct = "text/plain";
|
||||
resp.append("Content-Type: ").append(ct).append("\r\n");
|
||||
if (known) resp.append("Content-Length: ").append(up.contentLength).append("\r\n");
|
||||
if (refused) resp.append("Content-Length: 0\r\n");
|
||||
String cr = up.header("content-range");
|
||||
if (cr != null) resp.append("Content-Range: ").append(cr).append("\r\n");
|
||||
String ar = up.header("accept-ranges");
|
||||
@@ -196,35 +257,160 @@ public final class StreamProxy implements Runnable {
|
||||
resp.append("Connection: close\r\n\r\n");
|
||||
out.write(resp.toString().getBytes("ISO-8859-1"));
|
||||
out.flush();
|
||||
if (head || up.code >= 400) { up.close(); up = null; return; }
|
||||
|
||||
if (head) { up.close(); return; }
|
||||
byte[] buf = new byte[16384];
|
||||
int n;
|
||||
while ((n = up.body.read(buf)) > 0) {
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
while ((n = up.body.read(buf)) > 0) out.write(buf, 0, n);
|
||||
out.flush();
|
||||
up.close();
|
||||
synchronized (routes) {
|
||||
List<Http.Response> l = live.get(id);
|
||||
if (l != null) l.remove(up);
|
||||
}
|
||||
up = null;
|
||||
} catch (IOException e) {
|
||||
// player went away, track skipped, or the network dropped: just close
|
||||
} catch (Throwable t) {
|
||||
Listener l = listener;
|
||||
if (l != null && id != null) l.onError(id, 0, Threads.describe(t));
|
||||
} finally {
|
||||
if (up != null) Http.abort(up);
|
||||
if (id != null && up != null) {
|
||||
synchronized (routes) {
|
||||
List<Http.Response> l = live.get(id);
|
||||
if (l != null) l.remove(up);
|
||||
Http.abort(up);
|
||||
synchronized (route) { route.live.remove(up); }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- buffered ----
|
||||
|
||||
private void startFetch(final Route route) {
|
||||
synchronized (route) {
|
||||
if (route.started) return;
|
||||
route.started = true;
|
||||
File dir = cacheDir != null ? cacheDir : new File(System.getProperty("java.io.tmpdir", "/tmp"));
|
||||
route.file = new File(dir, "stream-" + route.id + ".mp3");
|
||||
}
|
||||
Threads.start(new Runnable() {
|
||||
public void run() {
|
||||
Http.Response up = null;
|
||||
FileOutputStream fo = null;
|
||||
try {
|
||||
up = openUpstream(route, "GET", null, 0, route.urls.size() - 1);
|
||||
synchronized (route) {
|
||||
if (route.cancelled) return;
|
||||
route.upstream = up;
|
||||
}
|
||||
fo = new FileOutputStream(route.file);
|
||||
byte[] buf = new byte[16384];
|
||||
int n;
|
||||
while ((n = up.body.read(buf)) > 0) {
|
||||
fo.write(buf, 0, n);
|
||||
fo.flush();
|
||||
synchronized (route) {
|
||||
if (route.cancelled) break;
|
||||
route.written += n;
|
||||
route.notifyAll();
|
||||
}
|
||||
}
|
||||
up.close();
|
||||
synchronized (route) { route.done = true; route.notifyAll(); }
|
||||
} catch (RefusedException e) {
|
||||
boolean cancelled;
|
||||
synchronized (route) {
|
||||
route.failCode = e.code; route.failMessage = e.getMessage(); route.done = true;
|
||||
cancelled = route.cancelled;
|
||||
route.notifyAll();
|
||||
}
|
||||
Listener l = listener;
|
||||
if (l != null && !cancelled) l.onError(route.id, e.code, e.getMessage());
|
||||
} catch (Throwable t) {
|
||||
boolean cancelled;
|
||||
synchronized (route) {
|
||||
if (route.failMessage == null) route.failMessage = Threads.describe(t);
|
||||
route.done = true;
|
||||
cancelled = route.cancelled;
|
||||
route.notifyAll();
|
||||
}
|
||||
if (!cancelled) DiagLog.log("stream download stopped: " + Threads.describe(t));
|
||||
} finally {
|
||||
if (fo != null) try { fo.close(); } catch (IOException ignored) {}
|
||||
if (up != null) Http.abort(up);
|
||||
}
|
||||
}
|
||||
try { s.close(); } catch (IOException ignored) {}
|
||||
}, "proxy-fetch");
|
||||
}
|
||||
|
||||
private void serveBuffered(Route route, OutputStream out, boolean head, String range, String path) throws IOException {
|
||||
startFetch(route);
|
||||
long wantFrom = 0;
|
||||
boolean done;
|
||||
long written;
|
||||
File file;
|
||||
synchronized (route) {
|
||||
long deadline = System.currentTimeMillis() + 60000;
|
||||
while (!route.cancelled && route.written == 0 && !route.done) {
|
||||
long left = deadline - System.currentTimeMillis();
|
||||
if (left <= 0) break;
|
||||
try { route.wait(left); } catch (InterruptedException e) { return; }
|
||||
}
|
||||
if (route.cancelled) { writeEmpty(out, 404, "Gone"); return; }
|
||||
if (route.written == 0) {
|
||||
int code = route.failCode > 0 ? route.failCode : 503;
|
||||
writeEmpty(out, code, "Unavailable");
|
||||
return;
|
||||
}
|
||||
done = route.done;
|
||||
written = route.written;
|
||||
file = route.file;
|
||||
}
|
||||
// Ranges only once the whole stream is here (the length isn't known before that).
|
||||
if (range != null && done && range.startsWith("bytes=")) {
|
||||
try {
|
||||
String r = range.substring(6);
|
||||
int dash = r.indexOf('-');
|
||||
wantFrom = Long.parseLong(r.substring(0, dash).trim());
|
||||
} catch (Exception e) { wantFrom = 0; }
|
||||
if (wantFrom >= written) { writeEmpty(out, 416, "Range Not Satisfiable"); return; }
|
||||
}
|
||||
StringBuilder resp = new StringBuilder(200);
|
||||
if (done) {
|
||||
resp.append(wantFrom > 0 ? "HTTP/1.1 206 Partial Content\r\n" : "HTTP/1.1 200 OK\r\n");
|
||||
resp.append("Content-Length: ").append(written - wantFrom).append("\r\n");
|
||||
resp.append("Accept-Ranges: bytes\r\n");
|
||||
if (wantFrom > 0) resp.append("Content-Range: bytes ").append(wantFrom).append('-').append(written - 1).append('/').append(written).append("\r\n");
|
||||
} else {
|
||||
resp.append("HTTP/1.0 200 OK\r\n"); // length unknown: read until the connection closes
|
||||
}
|
||||
resp.append("Content-Type: ").append(guessType(path)).append("\r\nConnection: close\r\n\r\n");
|
||||
out.write(resp.toString().getBytes("ISO-8859-1"));
|
||||
out.flush();
|
||||
if (head) return;
|
||||
|
||||
RandomAccessFile raf = new RandomAccessFile(file, "r");
|
||||
try {
|
||||
long pos = wantFrom;
|
||||
byte[] buf = new byte[16384];
|
||||
while (true) {
|
||||
long avail;
|
||||
boolean finished;
|
||||
synchronized (route) {
|
||||
while (!route.cancelled && route.written <= pos && !route.done) {
|
||||
try { route.wait(5000); } catch (InterruptedException e) { return; }
|
||||
}
|
||||
if (route.cancelled) return;
|
||||
avail = route.written - pos;
|
||||
finished = route.done;
|
||||
}
|
||||
if (avail <= 0) {
|
||||
if (finished) break;
|
||||
continue;
|
||||
}
|
||||
raf.seek(pos);
|
||||
int n = raf.read(buf, 0, (int) Math.min(buf.length, avail));
|
||||
if (n <= 0) break;
|
||||
out.write(buf, 0, n);
|
||||
pos += n;
|
||||
}
|
||||
out.flush();
|
||||
} finally {
|
||||
raf.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** True once a buffered route has its whole stream on disk (seeks can stay local then). */
|
||||
public boolean isComplete(String id) {
|
||||
Route r;
|
||||
synchronized (routes) { r = id == null ? null : routes.get(id); }
|
||||
if (r == null) return false;
|
||||
synchronized (r) { return r.done && r.failMessage == null && r.written > 0; }
|
||||
}
|
||||
|
||||
private static String reason(int code) {
|
||||
|
||||
Reference in new issue
Block a user