Files
plexamp-lite/src/com/minamp/plex/Library.java
T
XoséandClaude Opus 5.5 13f29e26c4 Plexamp Lite 1.14: Android 4.1 build with ExoPlayer, lyrics, home-screen widget
- One build per Android level, as in Tenda: build/PlexampLite.apk (Android 2.2+, versionCode 1508) and
  build/PlexampLite-api16.apk (Android 4.1+, 1516), from shared src/ plus platform/api8 or platform/api16.
- Android 4.1 build: ExoPlayer 2.18.7 behind a small Engine interface (gapless hand-over to read-ahead and
  downloaded files, a longer buffer, MediaPlayer fallback for tracks it can't decode), a notification and
  lock screen with art and buttons, a resizable widget, Holo, a menu button without a Menu key, metered networks.
  R8 shrinks ExoPlayer and Guava (tools/fetch_exoplayer.sh, tools/r8-rules.pro).
- Both builds: lyrics in Now Playing as in the Mac app (timed lyrics follow and seek), a home-screen widget,
  media play/pause keys, the moon icon drawn without clipPath.
- PlexampLite-noreport on the SD card stops play reports, for tests against a real server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:41:26 +01:00

353 lines
17 KiB
Java

package com.minamp.plex;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import com.minamp.net.Http;
import com.minamp.util.Json;
/** Paths into one music library section, plus the playback-URL decisions. */
public final class Library {
public final Server server;
public final String section;
public Library(Server server, String section) {
this.server = server;
this.section = section;
}
private static String page(int start, int size) {
return "&X-Plex-Container-Start=" + start + "&X-Plex-Container-Size=" + size;
}
public String artistsPath(int start, int size) {
return "/library/sections/" + section + "/all?type=8&sort=titleSort" + page(start, size);
}
public String albumsPath(String sort, int start, int size) {
return "/library/sections/" + section + "/all?type=9&sort=" + sort + page(start, size);
}
public String recentlyAddedPath(int size) {
return "/library/sections/" + section + "/all?type=9&sort=addedAt:desc" + page(0, size);
}
public String recentlyPlayedPath(int size) {
return "/library/sections/" + section + "/all?type=9&sort=lastViewedAt:desc&viewCount%3E%3E=0" + page(0, size);
}
public String playlistsPath() {
return "/playlists?playlistType=audio&sectionID=" + section;
}
public String searchPath(String query) {
return "/hubs/search?query=" + Plex.enc(query) + "&sectionId=" + section + "&limit=12";
}
/** Search results grouped as [HEADER, items..., HEADER, items...] (artists, albums, tracks). */
public List<Item> search(String query) throws IOException {
Json.Obj mc = server.get(searchPath(query));
List<Item> out = new ArrayList<Item>();
Json.Arr hubs = mc == null ? null : mc.optArr("Hub");
if (hubs == null) return out;
String[] order = {"artist", "album", "track", "playlist"};
for (int o = 0; o < order.length; o++) {
for (int i = 0; i < hubs.size(); i++) {
Json.Obj hub = hubs.optObj(i);
if (hub == null || !order[o].equals(hub.optString("type"))) continue;
List<Item> items = Item.listFrom(hub);
if (items.isEmpty()) continue;
Item header = new Item();
header.type = Item.HEADER;
header.title = hub.optString("title", order[o]);
out.add(header);
out.addAll(items);
}
}
return out;
}
public static String childrenPath(Item it) {
if (it.type == Item.PLAYLIST) return "/playlists/" + it.ratingKey + "/items";
return "/library/metadata/" + it.ratingKey + "/children";
}
// ------------------------------------------------------------------ radios / mixes
/** "Library radio": a random slice of every track in the library. */
public List<Item> libraryRadio() throws IOException {
return server.items("/library/sections/" + section + "/all?type=10&sort=random" + page(0, 100));
}
/** 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%3E%3E=7&sort=random" + page(0, 100));
}
/** Tracks from albums released in a random decade that the library actually has. */
public List<Item> timeTravelRadio(int decade) throws IOException {
List<Item> tracks;
try {
tracks = server.items("/library/sections/" + section + "/all?type=10&album.decade=" + decade + "&sort=random" + page(0, 100));
} catch (Plex.PlexException e) {
tracks = new ArrayList<Item>();
}
if (!tracks.isEmpty()) return tracks;
// Older servers don't filter tracks by album decade: build it from random albums instead.
List<Item> albums = server.items("/library/sections/" + section + "/all?type=9&decade=" + decade + "&sort=random" + page(0, 8));
for (int i = 0; i < albums.size(); i++) tracks.addAll(tracksOf(albums.get(i)));
shuffle(tracks);
return tracks;
}
public List<Integer> decades() throws IOException {
Json.Obj mc = server.get("/library/sections/" + section + "/decade?type=9");
List<Integer> out = new ArrayList<Integer>();
Json.Arr d = mc == null ? null : mc.optArr("Directory");
if (d == null) return out;
for (int i = 0; i < d.size(); i++) {
Json.Obj o = d.optObj(i);
if (o == null) continue;
int v = o.optInt("key", 0);
if (v > 0) out.add(Integer.valueOf(v));
}
return out;
}
/** Every track for an album, artist or playlist, in order. */
public List<Item> tracksOf(Item it) throws IOException {
String path;
switch (it.type) {
case Item.TRACK: {
List<Item> one = new ArrayList<Item>();
one.add(it);
return one;
}
case Item.ALBUM: path = "/library/metadata/" + it.ratingKey + "/children"; break;
case Item.ARTIST: path = "/library/metadata/" + it.ratingKey + "/allLeaves"; break;
case Item.PLAYLIST: path = "/playlists/" + it.ratingKey + "/items" + "?X-Plex-Container-Start=0&X-Plex-Container-Size=1000"; break;
default: return new ArrayList<Item>();
}
List<Item> all = server.items(path);
List<Item> tracks = new ArrayList<Item>();
for (int i = 0; i < all.size(); i++) if (all.get(i).type == Item.TRACK) tracks.add(all.get(i));
return tracks;
}
/** Fills in media info for a track that came from somewhere without it (e.g. search). */
public void ensureMedia(Item t) throws IOException {
if (t.partKey.length() > 0) return;
List<Item> l = server.items("/library/metadata/" + t.ratingKey);
if (!l.isEmpty()) {
Item full = l.get(0);
t.partKey = full.partKey; t.codec = full.codec; t.container = full.container;
t.bitrate = full.bitrate; t.partSize = full.partSize;
if (t.duration == 0) t.duration = full.duration;
}
if (t.partKey.length() == 0) throw new IOException(com.minamp.util.Tr.t("no_media", "Track has no playable media"));
}
/** The track's lyrics text, "" when it has none: timed (LRC) if there are, else the longest plain text (a
* local .txt can be a broken copy of LyricFind's). Blocking. */
public String lyrics(Item t) throws IOException {
List<Item> l = server.items("/library/metadata/" + t.ratingKey); // lists leave the streams out
if (l.isEmpty()) return "";
Item full = l.get(0);
String best = "";
for (int i = 0; i < full.lyricsKeys.size(); i++) {
String text = lyricsStream(full.lyricsKeys.get(i));
if (i < full.lyricsTimed && text.trim().length() > 0) return text;
if (text.trim().length() > best.trim().length()) best = text;
}
return best;
}
private String lyricsStream(String key) throws IOException {
Http.Response r = Http.get(server.url(key), server.mediaHeaders(), 20000);
try {
if (r.code == 404) return "";
if (r.code != 200) throw new IOException("HTTP " + r.code);
byte[] d = r.readAll();
String text = new String(d, "UTF-8");
if (text.indexOf('\uFFFD') >= 0) text = new String(d, "ISO-8859-1"); // not UTF-8 after all
return text;
} finally {
r.close();
}
}
public static void shuffle(List<Item> l) {
Collections.shuffle(l, new Random());
}
// ------------------------------------------------------------------ streaming
/** What Android 2.2/2.3's media framework can decode itself. */
public static boolean canDirectPlay(Item t, int maxKbps) {
String c = t.codec.toLowerCase();
String k = t.container.toLowerCase();
boolean ok = ("mp3".equals(c) && ("mp3".equals(k) || k.length() == 0))
|| ("aac".equals(c) && ("mp4".equals(k) || "m4a".equals(k) || "m4b".equals(k)))
|| ("vorbis".equals(c) && "ogg".equals(k));
if (!ok) return false;
if (maxKbps <= 0) return true;
return t.bitrate > 0 && t.bitrate <= maxKbps + 16;
}
public String directUrl(Item t) throws IOException {
return server.url(t.partKey);
}
/**
* Plex only transcodes for a client identity it has a profile for: an unknown platform or
* device gets a bare 400 ("unable to find a matching profile"). These are request shapes
* known to work against real servers, tried in order; the one that works is remembered.
* Every variant gets its own session id: re-using one tears down the previous transcode
* (503s, tracks cut short).
*/
public static final int VARIANTS = 4;
public static final String[] VARIANT_NAMES = {"web player", "web player + MP3 profile", "speaker profile", "generic audio"};
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;
}
public static String variantSession(String session, int variant) { return session + "-v" + variant; }
/** 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, variantSession(session, order[i]), order[i]));
return out;
}
public String transcodeUrl(Item t, int kbps, long offsetMs, String session, int variant) throws IOException {
if (kbps <= 0) kbps = 320;
String mp3Target = "add-transcode-target(replace=true&type=musicProfile&context=streaming&protocol=http&container=mp3&audioCodec=mp3)";
String bitrateLimit = "+add-limitation(scope=musicCodec&scopeName=mp3&type=upperBound&name=audio.bitrate&value=" + kbps
+ "&onlyTranscodes=true&replace=true)";
StringBuilder q = new StringBuilder();
String base;
q.append("path=").append(Plex.enc("/library/metadata/" + t.ratingKey));
switch (variant) {
case 0:
// Plex Web's own identity and the minimal parameter set it uses for a progressive MP3
base = "/music/:/transcode/universal/start.mp3";
q.append("&protocol=http&mediaIndex=0&partIndex=0&audioCodec=mp3")
.append("&directPlay=0&directStream=1&fastSeek=1&musicBitrate=").append(kbps);
break;
case 1:
// same identity, forcing a real MP3 encode at our bitrate via a declared target
base = "/music/:/transcode/universal/start.mp3";
q.append("&protocol=http&mediaIndex=0&partIndex=0")
.append("&directPlay=0&directStream=0&fastSeek=1&musicBitrate=").append(kbps)
.append("&X-Plex-Client-Profile-Extra=").append(Plex.enc(mp3Target + bitrateLimit));
break;
case 2:
// a networked-speaker identity Plex ships a profile for; output decided by the target
base = "/audio/:/transcode/universal/start";
q.append("&directPlay=0&directStream=0&musicBitrate=").append(kbps)
.append("&X-Plex-Client-Profile-Extra=").append(Plex.enc(mp3Target + bitrateLimit
+ "+add-limitation(scope=musicCodec&scopeName=mp3&type=upperBound&name=audio.samplingRate&value=48000&onlyTranscodes=true&replace=true)"));
break;
default:
base = "/audio/:/transcode/universal/start";
q.append("&protocol=http&directPlay=0&directStream=0&musicBitrate=").append(kbps)
.append("&X-Plex-Client-Profile-Extra=").append(Plex.enc(mp3Target));
}
if (offsetMs >= 1000) q.append("&offset=").append(offsetMs / 1000);
q.append("&session=").append(Plex.enc(session))
.append("&X-Plex-Session-Identifier=").append(Plex.enc(session))
.append("&X-Plex-Client-Identifier=").append(Plex.enc(Plex.clientId))
.append("&X-Plex-Product=").append(Plex.enc(Plex.product))
.append("&X-Plex-Version=").append(Plex.enc(Plex.version));
if (variant == 2) {
q.append("&X-Plex-Platform=Linux&X-Plex-Platform-Version=1.0")
.append("&X-Plex-Device=Sonos&X-Plex-Device-Name=").append(Plex.enc(Plex.deviceName))
.append("&X-Plex-Model=sonos");
} else {
// no X-Plex-Device: a phone model name matches no server profile and gets a 400
q.append("&X-Plex-Platform=Chrome");
}
return server.url(base + "?" + q);
}
/** Whether the server can actually read a track's file (Plex's checkFiles), for diagnostics. */
public String checkPart(Item t) {
try {
Json.Obj mc = server.get("/library/metadata/" + t.ratingKey + "?checkFiles=1");
Json.Arr md = mc == null ? null : mc.optArr("Metadata");
Json.Obj m = md == null ? null : md.optObj(0);
Json.Arr media = m == null ? null : m.optArr("Media");
Json.Obj me = media == null ? null : media.optObj(0);
Json.Arr parts = me == null ? null : me.optArr("Part");
Json.Obj p = parts == null ? null : parts.optObj(0);
if (p == null) return "no file information returned";
return "exists=" + p.optString("exists", "?") + " accessible=" + p.optString("accessible", "?")
+ " file=" + p.optString("file", "?");
} catch (Exception e) {
return com.minamp.net.Threads.describe(e);
}
}
/** Ask the server what it would do with a transcode request (for the diagnostics screen). */
public String decision(Item t, int kbps, int variant) {
try {
String url = transcodeUrl(t, kbps, 0, "minamp-check-" + System.currentTimeMillis(), variant);
url = url.replace("/start.mp3?", "/decision?").replace("/universal/start?", "/universal/decision?");
com.minamp.net.Http.Response r = com.minamp.net.Http.get(url, server.mediaHeaders(), 20000);
String body = r.readString();
if (r.code != 200) return "HTTP " + r.code + (Plex.snippet(body).length() > 0 ? ": " + Plex.snippet(body) : "");
Json.Obj mc = Json.parseObj(body).optObj("MediaContainer");
if (mc == null) return "200 (no details)";
return "general " + mc.optString("generalDecisionCode", "?") + " " + mc.optString("generalDecisionText", "")
+ " | transcode " + mc.optString("transcodeDecisionCode", "?") + " " + mc.optString("transcodeDecisionText", "")
+ " | direct play " + mc.optString("directPlayDecisionCode", "?") + " " + mc.optString("directPlayDecisionText", "");
} catch (Exception e) {
return com.minamp.net.Threads.describe(e);
}
}
public void stopTranscode(String session) {
try {
server.fetchQuiet("/transcode/universal/stop?session=" + Plex.enc(session));
} catch (Exception ignored) {}
}
// ------------------------------------------------------------------ reporting
/** For testing against a real server: report no plays or positions (App sets it when the SD card has a file
* named PlexampLite-noreport), so the account's play counts and history stay as they were. */
public static boolean noReport;
public void timeline(Item t, String state, long timeMs) {
if (noReport) return;
try {
server.fetchQuiet("/:/timeline?ratingKey=" + t.ratingKey
+ "&key=" + Plex.enc("/library/metadata/" + t.ratingKey)
+ "&state=" + state + "&time=" + timeMs + "&playbackTime=" + timeMs
+ "&duration=" + t.duration + "&hasMDE=1");
} catch (Exception ignored) {}
}
public void scrobble(Item t) {
if (noReport) return;
try {
server.fetchQuiet("/:/scrobble?identifier=com.plexapp.plugins.library&key=" + Plex.enc(t.ratingKey));
} catch (Exception ignored) {}
}
public void rate(Item t, double rating) throws IOException {
server.put("/:/rate?identifier=com.plexapp.plugins.library&key=" + Plex.enc(t.ratingKey) + "&rating=" + (int) rating);
}
}