Private
Public Access
Import minamp 1.0 source
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
0df8124629
194 files changed
+7319
No files matched your search
@@ -0,0 +1,225 @@
|
||||
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.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>>=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>>=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("Track has no playable media");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** MP3 transcode over plain progressive HTTP, starting at offsetMs. */
|
||||
public String transcodeUrl(Item t, int kbps, long offsetMs, String session) throws IOException {
|
||||
if (kbps <= 0) kbps = 320;
|
||||
String profile = "add-transcode-target(type=musicProfile&context=streaming&protocol=http&container=mp3&audioCodec=mp3)"
|
||||
+ "+add-limitation(scope=musicCodec&scopeName=mp3&type=upperBound&name=audio.bitrate&value=" + kbps + ")"
|
||||
+ "+add-limitation(scope=musicCodec&scopeName=mp3&type=upperBound&name=audio.channels&value=2)";
|
||||
String path = "/music/:/transcode/universal/start.mp3"
|
||||
+ "?path=" + Plex.enc("/library/metadata/" + t.ratingKey)
|
||||
+ "&mediaIndex=0&partIndex=0&protocol=http&hasMDE=1&fastSeek=1"
|
||||
+ "&directPlay=0&directStream=0&directStreamAudio=0"
|
||||
+ "&musicBitrate=" + kbps + "&maxAudioBitrate=" + kbps
|
||||
+ "&offset=" + (offsetMs / 1000)
|
||||
+ "&session=" + Plex.enc(session)
|
||||
+ "&location=" + (server.isLocal() ? "lan" : "wan")
|
||||
+ "&X-Plex-Session-Identifier=" + Plex.enc(session)
|
||||
+ "&X-Plex-Client-Identifier=" + Plex.enc(Plex.clientId)
|
||||
+ "&X-Plex-Product=" + Plex.enc(Plex.product)
|
||||
+ "&X-Plex-Version=" + Plex.enc(Plex.version)
|
||||
+ "&X-Plex-Platform=Generic"
|
||||
+ "&X-Plex-Device=" + Plex.enc(Plex.device)
|
||||
+ "&X-Plex-Client-Profile-Extra=" + Plex.enc(profile);
|
||||
return server.url(path);
|
||||
}
|
||||
|
||||
public void stopTranscode(String session) {
|
||||
try {
|
||||
server.fetchQuiet("/transcode/universal/stop?session=" + Plex.enc(session));
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ reporting
|
||||
|
||||
public void timeline(Item t, String state, long timeMs) {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user