Files
podlite/src/com/miniabs/Api.java
T
XoséandClaude Opus 5.5 c673a0ba5d Podlite 1.9: radio icon
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>
2026-10-19 18:57:02 +02:00

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);
}
}