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 search(String query) throws IOException { Json.Obj mc = server.get(searchPath(query)); List out = new ArrayList(); 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 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 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 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 timeTravelRadio(int decade) throws IOException { List 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(); } if (!tracks.isEmpty()) return tracks; // Older servers don't filter tracks by album decade: build it from random albums instead. List 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 decades() throws IOException { Json.Obj mc = server.get("/library/sections/" + section + "/decade?type=9"); List out = new ArrayList(); 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 tracksOf(Item it) throws IOException { String path; switch (it.type) { case Item.TRACK: { List one = new ArrayList(); 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(); } List all = server.items(path); List tracks = new ArrayList(); 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 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 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 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 transcodeUrls(Item t, int kbps, long offsetMs, String session) throws IOException { int[] order = variantOrder(); java.util.List out = new ArrayList(); 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); } }