Minamp 1.2

- Transcoding: send client identities Plex has a profile for (unknown ones get
  400 "unable to find a matching profile"), with a fresh session id per variant
- Stream proxy: fetch transcodes once into a temp file and serve every player
  request from it, so reconnects no longer restart the transcode (503s, tracks
  cut short); original files still pass Range requests through
- Sign-in: remember the pending link code so it survives Minamp being closed
  while the code is entered elsewhere; add "I've entered it - check now"
- Diagnostics: in-app connection log (DiagLog) with a streaming test and save
  to SD card; tokens are never logged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xockoandClaude Opus 5.5 committed 2026-10-14 20:38:17 +02:00
1 parent 0a2ac901bd
commit a4b875ef34
12 files changed
+701 -196

No files matched your search

+70 -38
View File
@@ -173,11 +173,14 @@ public final class Library {
}
/**
* Plex servers differ in which form of the music transcoder request they accept (some answer
* 400 to one and stream fine with another), so there are several known variants. The proxy
* tries them in order; the one that worked is remembered and tried first next time.
* 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() {
@@ -187,54 +190,83 @@ public final class Library {
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, session, order[i]));
for (int i = 0; i < order.length; i++) out.add(transcodeUrl(t, kbps, offsetMs, variantSession(session, order[i]), order[i]));
return out;
}
String transcodeUrl(Item t, int kbps, long offsetMs, String session, int variant) throws IOException {
public String transcodeUrl(Item t, int kbps, long offsetMs, String session, int variant) throws IOException {
if (kbps <= 0) kbps = 320;
String target = "add-transcode-target(type=musicProfile&context=streaming&protocol=http&container=mp3&audioCodec=mp3)";
String profile;
String platform;
String base = "/music/:/transcode/universal/start.mp3";
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: // what Plex's own web player identifies as, with an explicit MP3 target
platform = "Chrome";
profile = target + "+add-limitation(scope=musicCodec&scopeName=*&type=upperBound&name=audio.bitrate&value=" + kbps + ")";
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: // generic client profile
platform = "Generic";
profile = target + "+add-limitation(scope=musicCodec&scopeName=mp3&type=upperBound&name=audio.bitrate&value=" + kbps + ")";
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: // older servers' audio endpoint
platform = "Chrome";
profile = target;
base = "/audio/:/transcode/universal/start.mp3";
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: // no profile tweaks at all; bitrate from musicBitrate only
platform = "Chrome";
profile = null;
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);
}
/** 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, Plex.headers(null), 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);
}
String path = base
+ "?path=" + Plex.enc("/library/metadata/" + t.ratingKey)
+ "&mediaIndex=0&partIndex=0&protocol=http&hasMDE=1&fastSeek=1&copyts=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=" + platform
+ "&X-Plex-Device=" + Plex.enc(Plex.device)
+ (profile != null ? "&X-Plex-Client-Profile-Extra=" + Plex.enc(profile) : "");
return server.url(path);
}
public void stopTranscode(String session) {