New launcher icon (the Podcasts Radio pack, master in art/icon-master.png). The status-bar icon was cut out of the old headphones artwork; make_icons.py now draws a white radio silhouette. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
462 lines
20 KiB
Java
462 lines
20 KiB
Java
package com.miniabs;
|
|
|
|
import java.io.BufferedInputStream;
|
|
import java.io.IOException;
|
|
import java.io.InputStream;
|
|
import java.io.InputStreamReader;
|
|
import java.io.OutputStream;
|
|
import java.io.Reader;
|
|
import java.net.HttpURLConnection;
|
|
import java.net.InetSocketAddress;
|
|
import java.net.ProtocolException;
|
|
import java.net.Socket;
|
|
import java.net.URL;
|
|
import java.net.URLEncoder;
|
|
|
|
import javax.net.ssl.HttpsURLConnection;
|
|
import javax.net.ssl.SSLException;
|
|
import javax.net.ssl.SSLSocket;
|
|
|
|
import org.json.JSONArray;
|
|
import org.json.JSONException;
|
|
import org.json.JSONObject;
|
|
|
|
import android.content.Context;
|
|
import android.os.Build;
|
|
|
|
/** Audiobookshelf REST client (API as of server 2.x, including the 2.26+ token system). */
|
|
final class Api {
|
|
static final String VERSION = "1.9";
|
|
|
|
static class ApiException extends IOException {
|
|
final int code;
|
|
|
|
ApiException(int code, String msg) {
|
|
super(msg);
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
/** Sign-in is no longer valid; the user must log in again. */
|
|
static class AuthException extends IOException {
|
|
AuthException(String msg) {
|
|
super(msg);
|
|
}
|
|
}
|
|
|
|
private static final Object REFRESH_LOCK = new Object();
|
|
|
|
static {
|
|
// Froyo's HttpURLConnection reuses dead pooled sockets; turn pooling off.
|
|
System.setProperty("http.keepAlive", "false");
|
|
}
|
|
|
|
private Api() {
|
|
}
|
|
|
|
// ------------------------------------------------------------------ plumbing
|
|
|
|
static HttpURLConnection open(Context c, String url, String method) throws IOException {
|
|
Trust.install(c);
|
|
HttpURLConnection conn = new NetConnection(new URL(url));
|
|
conn.setConnectTimeout(15000);
|
|
conn.setReadTimeout(30000);
|
|
conn.setUseCaches(false);
|
|
conn.setInstanceFollowRedirects(true);
|
|
setMethod(conn, method);
|
|
conn.setRequestProperty("User-Agent", "Podlite/" + VERSION + " (Android " + Build.VERSION.RELEASE + ")");
|
|
return conn;
|
|
}
|
|
|
|
private static void setMethod(HttpURLConnection conn, String method) throws ProtocolException {
|
|
conn.setRequestMethod(method);
|
|
}
|
|
|
|
/**
|
|
* PATCH: Audiobookshelf uses it for "mark as played" and for saving progress without a session.
|
|
* (Android 2.x's own HttpURLConnection can't send it; the bundled client can.)
|
|
*/
|
|
private static Object rawPatch(Context c, String path, JSONObject body, boolean mayRefresh) throws IOException {
|
|
Prefs p = Prefs.get(c);
|
|
if (mayRefresh) ensureFresh(c, 120);
|
|
String used = p.token();
|
|
HttpURLConnection conn = open(c, p.server() + path, "PATCH");
|
|
conn.setInstanceFollowRedirects(false);
|
|
conn.setRequestProperty("Authorization", "Bearer " + used);
|
|
conn.setRequestProperty("Content-Type", "application/json");
|
|
conn.setRequestProperty("Accept", "application/json");
|
|
conn.setDoOutput(true);
|
|
int code;
|
|
try {
|
|
OutputStream os = conn.getOutputStream();
|
|
os.write((body == null ? "{}" : body.toString()).getBytes("UTF-8"));
|
|
code = conn.getResponseCode();
|
|
InputStream in = code >= 400 ? conn.getErrorStream() : conn.getInputStream();
|
|
if (in != null) {
|
|
byte[] rest = new byte[1024];
|
|
while (in.read(rest) > 0) { } // read the reply so the connection can be reused
|
|
}
|
|
} finally {
|
|
conn.disconnect();
|
|
}
|
|
if (code == 401) {
|
|
if (mayRefresh && p.canRefresh()) {
|
|
refreshIf(c, used, 0);
|
|
return rawPatch(c, path, body, false);
|
|
}
|
|
throw new AuthException("A túa sesión caducou. Inicia sesión de novo.");
|
|
}
|
|
if (code >= 400) throw new ApiException(code, "");
|
|
if (code >= 300) throw new ApiException(code, "O servidor redirixiu a petición; comproba o enderezo do servidor.");
|
|
return "OK";
|
|
}
|
|
|
|
/** Old Android throws instead of returning 401 when the server sends no WWW-Authenticate header. */
|
|
static int responseCode(HttpURLConnection conn) throws IOException {
|
|
try {
|
|
return conn.getResponseCode();
|
|
} catch (IOException e) {
|
|
String m = e.getMessage();
|
|
if (m != null && m.toLowerCase().contains("authentication challenge")) return 401;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
private static void writeBody(HttpURLConnection conn, JSONObject body) throws IOException {
|
|
byte[] b = (body == null ? "{}" : body.toString()).getBytes("UTF-8");
|
|
conn.setDoOutput(true);
|
|
conn.setRequestProperty("Content-Type", "application/json");
|
|
conn.setFixedLengthStreamingMode(b.length);
|
|
OutputStream os = conn.getOutputStream();
|
|
try {
|
|
os.write(b);
|
|
} finally {
|
|
os.close();
|
|
}
|
|
}
|
|
|
|
private static Object readBody(HttpURLConnection conn, int maxString, String[] deepSkip) throws IOException {
|
|
InputStream is = new BufferedInputStream(conn.getInputStream(), 8192);
|
|
try {
|
|
String type = conn.getContentType();
|
|
Reader r = new InputStreamReader(is, "UTF-8");
|
|
if (type != null && type.contains("json")) return Json.parse(r, maxString, deepSkip);
|
|
StringBuilder sb = new StringBuilder();
|
|
char[] b = new char[512];
|
|
int n;
|
|
while ((n = r.read(b)) > 0 && sb.length() < 4000) sb.append(b, 0, n);
|
|
return sb.toString();
|
|
} finally {
|
|
is.close();
|
|
}
|
|
}
|
|
|
|
private static String errorText(HttpURLConnection conn) {
|
|
try {
|
|
InputStream es = conn.getErrorStream();
|
|
if (es == null) return "";
|
|
Reader r = new InputStreamReader(es, "UTF-8");
|
|
StringBuilder sb = new StringBuilder();
|
|
char[] b = new char[256];
|
|
int n;
|
|
while ((n = r.read(b)) > 0 && sb.length() < 200) sb.append(b, 0, n);
|
|
es.close();
|
|
String t = sb.toString().trim();
|
|
if (t.startsWith("<")) return ""; // an HTML error page is no use on a tiny screen
|
|
return t.length() > 120 ? t.substring(0, 120) : t;
|
|
} catch (Exception e) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
static Object call(Context c, String method, String path, JSONObject body) throws IOException {
|
|
return call(c, method, path, body, 2000, null, true);
|
|
}
|
|
|
|
static Object call(Context c, String method, String path, JSONObject body, int maxString, String[] deepSkip,
|
|
boolean mayRefresh) throws IOException {
|
|
Prefs p = Prefs.get(c);
|
|
if (!p.loggedIn()) throw new AuthException("Inicia sesión.");
|
|
if ("PATCH".equals(method)) return rawPatch(c, path, body, mayRefresh);
|
|
if (mayRefresh) ensureFresh(c, 120);
|
|
String used = p.token();
|
|
HttpURLConnection conn = open(c, p.server() + path, method);
|
|
try {
|
|
conn.setRequestProperty("Accept", "application/json");
|
|
conn.setRequestProperty("Authorization", "Bearer " + used);
|
|
if (body != null || "POST".equals(method)) writeBody(conn, body);
|
|
int code = responseCode(conn);
|
|
if (code == 401) {
|
|
conn.disconnect();
|
|
if (mayRefresh && p.canRefresh()) {
|
|
refreshIf(c, used, 0);
|
|
return call(c, method, path, body, maxString, deepSkip, false);
|
|
}
|
|
throw new AuthException("A túa sesión caducou. Inicia sesión de novo.");
|
|
}
|
|
if (code >= 400) throw new ApiException(code, errorText(conn));
|
|
return readBody(conn, maxString, deepSkip);
|
|
} finally {
|
|
conn.disconnect();
|
|
}
|
|
}
|
|
|
|
static JSONObject obj(Object o) throws IOException {
|
|
if (o instanceof JSONObject) return (JSONObject) o;
|
|
throw new IOException("Resposta inesperada do servidor");
|
|
}
|
|
|
|
// ------------------------------------------------------------------ sign-in
|
|
|
|
/** Checks the address points at an Audiobookshelf server. Returns its version, or "" if unknown. */
|
|
static String status(Context c, String server) throws IOException {
|
|
HttpURLConnection conn = open(c, server + "/status", "GET");
|
|
try {
|
|
int code = responseCode(conn);
|
|
if (code == 404) throw new IOException("Non hai ningún servidor de Audiobookshelf neste enderezo.");
|
|
if (code >= 400) throw new ApiException(code, errorText(conn));
|
|
Object o = readBody(conn, 200, null);
|
|
if (!(o instanceof JSONObject)) throw new IOException("Non hai ningún servidor de Audiobookshelf neste enderezo.");
|
|
JSONObject s = (JSONObject) o;
|
|
if (s.has("isInit") && !s.optBoolean("isInit", true)) {
|
|
throw new IOException("Este servidor aínda non está configurado. Remata a configuración nun navegador.");
|
|
}
|
|
return Util.str(s, "serverVersion");
|
|
} finally {
|
|
conn.disconnect();
|
|
}
|
|
}
|
|
|
|
/** Logs in with username and password. Returns the user's default library id (may be empty). */
|
|
static String login(Context c, String server, String user, String pass) throws IOException {
|
|
HttpURLConnection conn = open(c, server + "/login", "POST");
|
|
try {
|
|
conn.setRequestProperty("Accept", "application/json");
|
|
conn.setRequestProperty("x-return-tokens", "true");
|
|
JSONObject body = new JSONObject();
|
|
try {
|
|
body.put("username", user).put("password", pass);
|
|
} catch (JSONException ignored) {
|
|
}
|
|
writeBody(conn, body);
|
|
int code = responseCode(conn);
|
|
if (code == 401) throw new ApiException(401, "Usuario ou contrasinal incorrectos.");
|
|
if (code == 429) throw new ApiException(429, "Demasiados intentos. Agarda un minuto e téntao de novo.");
|
|
if (code >= 400) throw new ApiException(code, errorText(conn));
|
|
JSONObject o = obj(readBody(conn, 500, null));
|
|
JSONObject u = o.optJSONObject("user");
|
|
if (u == null) throw new IOException("Resposta inesperada do servidor");
|
|
String access = Util.str(u, "accessToken");
|
|
String refresh = Util.str(u, "refreshToken");
|
|
String legacy = Util.str(u, "token");
|
|
Prefs p = Prefs.get(c);
|
|
if (access.length() > 0) p.setAuth(user, Prefs.MODE_JWT, access, refresh);
|
|
else if (legacy.length() > 0) p.setAuth(user, Prefs.MODE_LEGACY, legacy, "");
|
|
else throw new IOException("O servidor non devolveu ningún token de sesión.");
|
|
return Util.str(o, "userDefaultLibraryId");
|
|
} finally {
|
|
conn.disconnect();
|
|
}
|
|
}
|
|
|
|
/** Signs in with an API key by checking it against /api/me. Returns the username. */
|
|
static String loginWithKey(Context c, String key) throws IOException {
|
|
Prefs p = Prefs.get(c);
|
|
p.setAuth("", Prefs.MODE_KEY, key, "");
|
|
try {
|
|
JSONObject me = obj(call(c, "GET", "/api/me", null, 100, null, false));
|
|
String name = Util.str(me, "username");
|
|
p.setAuth(name, Prefs.MODE_KEY, key, "");
|
|
return name;
|
|
} catch (IOException e) {
|
|
p.logout();
|
|
if (e instanceof AuthException) throw new ApiException(401, "Non se aceptou esa chave da API.");
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/** Refreshes the access token if it has less than minSeconds left. */
|
|
static void ensureFresh(Context c, long minSeconds) throws IOException {
|
|
Prefs p = Prefs.get(c);
|
|
if (!p.canRefresh()) return;
|
|
if (p.tokenSecondsLeft() >= minSeconds) return;
|
|
refreshIf(c, null, minSeconds);
|
|
}
|
|
|
|
/** Gets a new access token now (after a 401, or before reconnecting a stream). */
|
|
static void refresh(Context c, boolean force) throws IOException {
|
|
refreshIf(c, force ? Prefs.get(c).token() : null, 120);
|
|
}
|
|
|
|
/**
|
|
* stale: the token the server just rejected. If another thread has already replaced it,
|
|
* there's nothing to do. With no stale token, refreshes only if fewer than minSeconds are left.
|
|
*/
|
|
private static void refreshIf(Context c, String stale, long minSeconds) throws IOException {
|
|
synchronized (REFRESH_LOCK) {
|
|
Prefs p = Prefs.get(c);
|
|
if (!p.canRefresh()) throw new AuthException("A túa sesión caducou. Inicia sesión de novo.");
|
|
if (stale != null) {
|
|
if (!stale.equals(p.token())) return;
|
|
} else if (p.tokenSecondsLeft() >= minSeconds) {
|
|
return;
|
|
}
|
|
HttpURLConnection conn = open(c, p.server() + "/auth/refresh", "POST");
|
|
try {
|
|
conn.setRequestProperty("Accept", "application/json");
|
|
conn.setRequestProperty("x-refresh-token", p.refreshToken());
|
|
writeBody(conn, null);
|
|
int code = responseCode(conn);
|
|
if (code == 401 || code == 403) {
|
|
throw new AuthException("A túa sesión caducou. Inicia sesión de novo.");
|
|
}
|
|
if (code >= 400) throw new ApiException(code, errorText(conn));
|
|
JSONObject u = obj(readBody(conn, 500, null)).optJSONObject("user");
|
|
String access = u == null ? "" : Util.str(u, "accessToken");
|
|
if (access.length() == 0) throw new AuthException("A túa sesión caducou. Inicia sesión de novo.");
|
|
p.setTokens(access, Util.str(u, "refreshToken"));
|
|
} finally {
|
|
conn.disconnect();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ URLs
|
|
|
|
static String enc(String s) {
|
|
try {
|
|
return URLEncoder.encode(s, "UTF-8");
|
|
} catch (Exception e) {
|
|
return s;
|
|
}
|
|
}
|
|
|
|
/** Appends the access token as a query parameter (MediaPlayer on Android 2.x can't send headers). */
|
|
static String withToken(Context c, String url) {
|
|
return url + (url.indexOf('?') >= 0 ? "&" : "?") + "token=" + enc(Prefs.get(c).token());
|
|
}
|
|
|
|
/** Turns a server-relative content URL into a full URL, handling servers hosted under a sub-path. */
|
|
static String absolute(Context c, String contentUrl) {
|
|
if (contentUrl.startsWith("http://") || contentUrl.startsWith("https://")) return contentUrl;
|
|
String server = Prefs.get(c).server();
|
|
try {
|
|
URL u = new URL(server);
|
|
String base = u.getPath();
|
|
if (base.length() > 1 && contentUrl.startsWith(base + "/")) {
|
|
String origin = u.getProtocol() + "://" + u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : "");
|
|
return origin + contentUrl;
|
|
}
|
|
} catch (Exception ignored) {
|
|
}
|
|
return server + (contentUrl.startsWith("/") ? "" : "/") + contentUrl;
|
|
}
|
|
|
|
static String coverUrl(Context c, String itemId, int width) {
|
|
return withToken(c, Prefs.get(c).server() + "/api/items/" + itemId + "/cover?format=jpeg&width=" + width);
|
|
}
|
|
|
|
static String[] downloadUrls(Context c, Episode e) {
|
|
String base = Prefs.get(c).server() + "/api/items/" + e.itemId + "/file/" + e.ino;
|
|
return new String[]{withToken(c, base + "/download"), withToken(c, base)};
|
|
}
|
|
|
|
// ------------------------------------------------------------------ data
|
|
|
|
static JSONArray libraries(Context c) throws IOException {
|
|
Object o = call(c, "GET", "/api/libraries", null);
|
|
if (o instanceof JSONArray) return (JSONArray) o;
|
|
JSONArray a = obj(o).optJSONArray("libraries");
|
|
return a == null ? new JSONArray() : a;
|
|
}
|
|
|
|
static JSONObject libraryItems(Context c, String lib, int page, int limit) throws IOException {
|
|
return obj(call(c, "GET", "/api/libraries/" + lib + "/items?minified=1&sort=media.metadata.title&limit="
|
|
+ limit + "&page=" + page, null, 300, null, true));
|
|
}
|
|
|
|
static JSONObject recentEpisodes(Context c, String lib, int page, int limit) throws IOException {
|
|
return obj(call(c, "GET", "/api/libraries/" + lib + "/recent-episodes?limit=" + limit + "&page=" + page,
|
|
null, 1200, new String[]{"episodes", "audioFiles", "tracks"}, true));
|
|
}
|
|
|
|
static JSONObject itemsInProgress(Context c) throws IOException {
|
|
return obj(call(c, "GET", "/api/me/items-in-progress?limit=15", null, 1200,
|
|
new String[]{"episodes"}, true));
|
|
}
|
|
|
|
/** A podcast with all its episodes. Show notes are cut to keep memory low. */
|
|
static JSONObject item(Context c, String itemId) throws IOException {
|
|
return obj(call(c, "GET", "/api/items/" + itemId, null, 800, null, true));
|
|
}
|
|
|
|
static JSONObject me(Context c) throws IOException {
|
|
return obj(call(c, "GET", "/api/me", null, 200, null, true));
|
|
}
|
|
|
|
private static JSONObject deviceInfo(Context c) throws JSONException {
|
|
return new JSONObject()
|
|
.put("deviceId", Prefs.get(c).deviceId())
|
|
.put("clientName", "Podlite")
|
|
.put("clientVersion", VERSION)
|
|
.put("manufacturer", Build.MANUFACTURER)
|
|
.put("model", Build.MODEL)
|
|
.put("sdkVersion", Build.VERSION.SDK_INT);
|
|
}
|
|
|
|
/** Opens a playback session. Asks for direct play: Android 2.x can't play HLS transcodes. */
|
|
static JSONObject startPlayback(Context c, Episode e) throws IOException {
|
|
JSONObject body = new JSONObject();
|
|
try {
|
|
JSONArray mimes = new JSONArray();
|
|
String[] ok = {"audio/mpeg", "audio/mp3", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/x-aac",
|
|
"audio/ogg", "audio/wav", "audio/x-wav", "audio/3gpp", "audio/amr"};
|
|
for (String m : ok) mimes.put(m);
|
|
body.put("deviceInfo", deviceInfo(c))
|
|
.put("forceDirectPlay", true)
|
|
.put("forceTranscode", false)
|
|
.put("supportedMimeTypes", mimes)
|
|
.put("mediaPlayer", "Podlite");
|
|
} catch (JSONException ignored) {
|
|
}
|
|
return obj(call(c, "POST", "/api/items/" + e.itemId + "/play/" + e.id, body, 200,
|
|
new String[]{"episodes"}, true));
|
|
}
|
|
|
|
private static JSONObject syncBody(double cur, double listened, double dur) {
|
|
JSONObject b = new JSONObject();
|
|
try {
|
|
b.put("currentTime", cur).put("timeListened", listened);
|
|
if (dur > 0) b.put("duration", dur);
|
|
} catch (JSONException ignored) {
|
|
}
|
|
return b;
|
|
}
|
|
|
|
static void syncSession(Context c, String sid, double cur, double listened, double dur) throws IOException {
|
|
call(c, "POST", "/api/session/" + sid + "/sync", syncBody(cur, listened, dur));
|
|
}
|
|
|
|
static void closeSession(Context c, String sid, double cur, double listened, double dur) throws IOException {
|
|
call(c, "POST", "/api/session/" + sid + "/close", syncBody(cur, listened, dur));
|
|
}
|
|
|
|
/** Saves progress without a session (offline catch-up, mark played/unplayed). */
|
|
static void setProgress(Context c, String itemId, String epId, double cur, double dur, Boolean finished)
|
|
throws IOException {
|
|
JSONObject b = new JSONObject();
|
|
try {
|
|
if (cur >= 0) {
|
|
b.put("currentTime", cur);
|
|
if (dur > 0) {
|
|
b.put("duration", dur);
|
|
b.put("progress", Math.min(1.0, cur / dur));
|
|
}
|
|
}
|
|
if (finished != null) b.put("isFinished", finished.booleanValue());
|
|
} catch (JSONException ignored) {
|
|
}
|
|
call(c, "PATCH", "/api/me/progress/" + itemId + "/" + epId, b);
|
|
}
|
|
}
|