Private
Public Access
- 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>
353 lines
17 KiB
Java
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§ionID=" + section;
|
|
}
|
|
|
|
public String searchPath(String query) {
|
|
return "/hubs/search?query=" + Plex.enc(query) + "§ionId=" + 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);
|
|
}
|
|
}
|