fix(auth): use the authentication spellings Jellyfin 12.0 leaves enabled
X-Emby-Authorization at the remaining request builders, and api_key= in the player-facing URLs, become Authorization and ApiKey. Jellyfin 12.0 disables X-Emby-Authorization, X-Emby-Token, X-MediaBrowser-Token, the Emby scheme and the api_key query parameter by default — and a migration (DisableLegacyAuthorization) turns them off on servers upgraded from 10.11 as well, so this is not confined to fresh installs. A client using them stops working against an upgraded server rather than degrading. Verified at source level rather than inferred: AuthorizationContext.cs is byte-identical between v10.11.5 and v12.0 apart from whitespace. The only change is the default of the gate that guards the legacy spellings. Authorization with the MediaBrowser scheme, and ApiKey as a query parameter, are ungated in both trees — and the server itself emits ApiKey in both (StreamInfo.cs). So one spelling is correct everywhere and no capability flag is involved. Also adds ServerCompatibility to ServerInfo: an opaque verdict the frontend renders without ever comparing a version number, with three states rather than a boolean. A server newer than this build is usable, not refused; an unreadable version string is not grounds for refusal either. Only a server below the floor is refused. TRACES: UR-085 | DR-286, DR-287 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+137
-3
@@ -19,6 +19,40 @@ pub struct ServerInfo {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
/// Normalized server URL with protocol and no trailing slash
|
/// Normalized server URL with protocol and no trailing slash
|
||||||
pub normalized_url: String,
|
pub normalized_url: String,
|
||||||
|
/// Whether this build can talk to this server, as an **opaque state**.
|
||||||
|
///
|
||||||
|
/// The version string above is informational — for display and for the log.
|
||||||
|
/// This is the judgement, made in Rust, because deciding whether an API
|
||||||
|
/// version is usable is domain reasoning: the frontend must never compare a
|
||||||
|
/// version number, for the same reason it never receives an item-type list.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-286
|
||||||
|
pub compatibility: ServerCompatibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The verdict on a server's version.
|
||||||
|
///
|
||||||
|
/// Deliberately three states rather than a boolean. "Unrecognised" is not a
|
||||||
|
/// failure: a server newer than this build resolves forward and works, and
|
||||||
|
/// refusing it would make every JellyTau release expire the moment the server
|
||||||
|
/// upgrades. Only a server below the supported floor is refused, where failure
|
||||||
|
/// is certain rather than merely likely.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-286
|
||||||
|
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "type")]
|
||||||
|
pub enum ServerCompatibility {
|
||||||
|
/// A generation this build knows and was tested against.
|
||||||
|
Supported,
|
||||||
|
/// Parsed, but newer than anything this build knows. Treated as the newest
|
||||||
|
/// known generation; everything works, and this exists so the UI *may*
|
||||||
|
/// mention it rather than so it must.
|
||||||
|
NewerThanKnown,
|
||||||
|
/// The version string could not be parsed. Treated as supported — we do not
|
||||||
|
/// refuse a server on the strength of not understanding its version string.
|
||||||
|
UnknownVersion,
|
||||||
|
/// Below the supported floor. This one is a refusal.
|
||||||
|
TooOld { minimum: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User information
|
/// User information
|
||||||
@@ -166,11 +200,35 @@ impl AuthManager {
|
|||||||
monitor.mark_reachable().await;
|
monitor.mark_reachable().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let capabilities =
|
||||||
|
crate::repository::capabilities::ServerCapabilities::from_reported(
|
||||||
|
&info.version,
|
||||||
|
);
|
||||||
|
let compatibility = if capabilities.is_below_supported_floor() {
|
||||||
|
let (major, minor) =
|
||||||
|
crate::repository::capabilities::MINIMUM_SUPPORTED_MAJOR_MINOR;
|
||||||
|
ServerCompatibility::TooOld {
|
||||||
|
minimum: format!("{major}.{minor}"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
use crate::repository::capabilities::ServerGeneration;
|
||||||
|
match capabilities.generation {
|
||||||
|
ServerGeneration::Unknown => ServerCompatibility::UnknownVersion,
|
||||||
|
ServerGeneration::V12Plus
|
||||||
|
if capabilities.version.as_ref().is_some_and(|v| v.major > 12) =>
|
||||||
|
{
|
||||||
|
ServerCompatibility::NewerThanKnown
|
||||||
|
}
|
||||||
|
_ => ServerCompatibility::Supported,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(ServerInfo {
|
Ok(ServerInfo {
|
||||||
name: info.server_name,
|
name: info.server_name,
|
||||||
version: info.version,
|
version: info.version,
|
||||||
id: info.id,
|
id: info.id,
|
||||||
normalized_url,
|
normalized_url,
|
||||||
|
compatibility,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -210,7 +268,7 @@ impl AuthManager {
|
|||||||
.client
|
.client
|
||||||
.post(&endpoint)
|
.post(&endpoint)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("X-Emby-Authorization", auth_header)
|
.header("Authorization", auth_header)
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
"Username": username,
|
"Username": username,
|
||||||
"Pw": password,
|
"Pw": password,
|
||||||
@@ -286,7 +344,7 @@ impl AuthManager {
|
|||||||
.http_client
|
.http_client
|
||||||
.client
|
.client
|
||||||
.get(&endpoint)
|
.get(&endpoint)
|
||||||
.header("X-Emby-Authorization", auth_header)
|
.header("Authorization", auth_header)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
@@ -365,7 +423,7 @@ impl AuthManager {
|
|||||||
.http_client
|
.http_client
|
||||||
.client
|
.client
|
||||||
.post(&endpoint)
|
.post(&endpoint)
|
||||||
.header("X-Emby-Authorization", auth_header)
|
.header("Authorization", auth_header)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
@@ -397,6 +455,82 @@ impl AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod compatibility_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::repository::capabilities::ServerCapabilities;
|
||||||
|
|
||||||
|
/// Mirror of the mapping in `connect_to_server`, so the verdict can be
|
||||||
|
/// asserted without standing up an HTTP server.
|
||||||
|
fn verdict(reported: &str) -> ServerCompatibility {
|
||||||
|
let capabilities = ServerCapabilities::from_reported(reported);
|
||||||
|
if capabilities.is_below_supported_floor() {
|
||||||
|
let (major, minor) = crate::repository::capabilities::MINIMUM_SUPPORTED_MAJOR_MINOR;
|
||||||
|
return ServerCompatibility::TooOld {
|
||||||
|
minimum: format!("{major}.{minor}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
use crate::repository::capabilities::ServerGeneration;
|
||||||
|
match capabilities.generation {
|
||||||
|
ServerGeneration::Unknown => ServerCompatibility::UnknownVersion,
|
||||||
|
ServerGeneration::V12Plus
|
||||||
|
if capabilities.version.as_ref().is_some_and(|v| v.major > 12) =>
|
||||||
|
{
|
||||||
|
ServerCompatibility::NewerThanKnown
|
||||||
|
}
|
||||||
|
_ => ServerCompatibility::Supported,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both live generations are supported outright. 12.0 is the current stable
|
||||||
|
/// and 10.11.x is what this client was built against.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-286
|
||||||
|
#[test]
|
||||||
|
fn both_live_generations_are_supported() {
|
||||||
|
assert_eq!(verdict("10.11.5"), ServerCompatibility::Supported);
|
||||||
|
assert_eq!(verdict("10.11.11"), ServerCompatibility::Supported);
|
||||||
|
assert_eq!(verdict("12.0.0"), ServerCompatibility::Supported);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server newer than this build is usable, not refused — otherwise every
|
||||||
|
/// release would expire the moment the server upgraded.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-286
|
||||||
|
#[test]
|
||||||
|
fn a_newer_server_is_usable_not_refused() {
|
||||||
|
assert_eq!(verdict("13.0.0"), ServerCompatibility::NewerThanKnown);
|
||||||
|
assert_eq!(verdict("99.1.2"), ServerCompatibility::NewerThanKnown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unreadable version is not grounds for refusal.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-286
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_version_is_not_a_refusal() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict("not-a-version"),
|
||||||
|
ServerCompatibility::UnknownVersion
|
||||||
|
);
|
||||||
|
assert_eq!(verdict(""), ServerCompatibility::UnknownVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only a server below the floor is refused, and it says what the floor is
|
||||||
|
/// so the message can name it.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-286
|
||||||
|
#[test]
|
||||||
|
fn only_a_server_below_the_floor_is_refused() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict("10.9.11"),
|
||||||
|
ServerCompatibility::TooOld {
|
||||||
|
minimum: "10.10".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(verdict("10.10.0"), ServerCompatibility::Supported);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ impl JellyfinClient {
|
|||||||
return "Unknown";
|
return "Unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the X-Emby-Authorization header value
|
/// Build the value for the `Authorization` header (the `MediaBrowser`
|
||||||
|
/// scheme — see `HttpClient::build_auth_header`).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-287
|
||||||
fn get_auth_header(&self) -> String {
|
fn get_auth_header(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
"MediaBrowser Client=\"{}\", Version=\"{}\", Device=\"{}\", DeviceId=\"{}\", Token=\"{}\"",
|
"MediaBrowser Client=\"{}\", Version=\"{}\", Device=\"{}\", DeviceId=\"{}\", Token=\"{}\"",
|
||||||
@@ -75,7 +78,7 @@ impl JellyfinClient {
|
|||||||
let response = self
|
let response = self
|
||||||
.http_client
|
.http_client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("X-Emby-Authorization", self.get_auth_header())
|
.header("Authorization", self.get_auth_header())
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -155,7 +158,7 @@ impl JellyfinClient {
|
|||||||
.http_client
|
.http_client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("X-Emby-Authorization", self.get_auth_header())
|
.header("Authorization", self.get_auth_header())
|
||||||
.json(body)
|
.json(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -293,7 +296,7 @@ impl JellyfinClient {
|
|||||||
let response = self
|
let response = self
|
||||||
.http_client
|
.http_client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("X-Emby-Authorization", self.get_auth_header())
|
.header("Authorization", self.get_auth_header())
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -360,7 +363,7 @@ impl JellyfinClient {
|
|||||||
let response = self
|
let response = self
|
||||||
.http_client
|
.http_client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("X-Emby-Authorization", self.get_auth_header())
|
.header("Authorization", self.get_auth_header())
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Network request failed: {}", e))?;
|
.map_err(|e| format!("Network request failed: {}", e))?;
|
||||||
@@ -503,7 +506,7 @@ impl JellyfinClient {
|
|||||||
let response = self
|
let response = self
|
||||||
.http_client
|
.http_client
|
||||||
.delete(&url)
|
.delete(&url)
|
||||||
.header("X-Emby-Authorization", self.get_auth_header())
|
.header("Authorization", self.get_auth_header())
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Network request failed: {}", e))?;
|
.map_err(|e| format!("Network request failed: {}", e))?;
|
||||||
|
|||||||
@@ -56,6 +56,27 @@ impl HttpClient {
|
|||||||
Ok(Self { client, config })
|
Ok(Self { client, config })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A client that will also talk plain HTTP, for tests only.
|
||||||
|
///
|
||||||
|
/// `new` sets `https_only(true)` and that must stay: it is what stops a
|
||||||
|
/// downgrade putting a session token on the wire in clear. `wiremock` serves
|
||||||
|
/// plain HTTP on loopback, so the alternative to this constructor is either
|
||||||
|
/// weakening the real one or not testing the repository against a server at
|
||||||
|
/// all — and the latter is what DR-281 exists to end.
|
||||||
|
///
|
||||||
|
/// `#[cfg(test)]` so it cannot reach a shipped binary.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-281
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn new_allowing_plaintext_for_tests(config: HttpConfig) -> Result<Self, String> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(config.timeout)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
Ok(Self { client, config })
|
||||||
|
}
|
||||||
|
|
||||||
/// Get device name based on platform
|
/// Get device name based on platform
|
||||||
fn get_device_name() -> &'static str {
|
fn get_device_name() -> &'static str {
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
@@ -78,7 +99,16 @@ impl HttpClient {
|
|||||||
return "Unknown";
|
return "Unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the X-Emby-Authorization header value
|
/// Build the value for the `Authorization` header.
|
||||||
|
///
|
||||||
|
/// The `MediaBrowser` scheme, which is the non-deprecated one: Jellyfin 12.0
|
||||||
|
/// disables `X-Emby-Authorization` (and the `Emby` scheme, `X-Emby-Token`
|
||||||
|
/// and `X-MediaBrowser-Token`) by default, and a migration turns it off on
|
||||||
|
/// upgraded servers too. `Authorization: MediaBrowser …` is ungated on both
|
||||||
|
/// 10.11.x and 12.x, so this is one value for both generations rather than a
|
||||||
|
/// capability branch.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-287
|
||||||
pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String {
|
pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String {
|
||||||
let mut parts = vec![
|
let mut parts = vec![
|
||||||
format!("MediaBrowser Client=\"{}\"", APP_NAME),
|
format!("MediaBrowser Client=\"{}\"", APP_NAME),
|
||||||
|
|||||||
@@ -4475,7 +4475,7 @@ mod tests {
|
|||||||
duration: Some(runtime_seconds),
|
duration: Some(runtime_seconds),
|
||||||
source: MediaSource::Remote {
|
source: MediaSource::Remote {
|
||||||
stream_url:
|
stream_url:
|
||||||
"http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
|
"http://s/Audio/ep2/universal?ApiKey=k&AudioStreamIndex=2&StartTimeTicks=0"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
jellyfin_item_id: "ep2".to_string(),
|
jellyfin_item_id: "ep2".to_string(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ pub fn is_truncated_end(position: f64, duration: Option<f64>, tolerance: f64) ->
|
|||||||
/// Resuming re-opens *the stream we were already playing*, so the URL is edited
|
/// Resuming re-opens *the stream we were already playing*, so the URL is edited
|
||||||
/// in place rather than rebuilt from the repository: every other parameter —
|
/// in place rather than rebuilt from the repository: every other parameter —
|
||||||
/// `AudioStreamIndex` (the track the user picked in the video player),
|
/// `AudioStreamIndex` (the track the user picked in the video player),
|
||||||
/// `MediaSourceId`, `api_key` — is carried over untouched, and no network call
|
/// `MediaSourceId`, `ApiKey` — is carried over untouched, and no network call
|
||||||
/// is needed to recover from a network failure.
|
/// is needed to recover from a network failure.
|
||||||
pub fn with_start_time(url: &str, position_seconds: f64) -> String {
|
pub fn with_start_time(url: &str, position_seconds: f64) -> String {
|
||||||
let ticks = (position_seconds.max(0.0) * 10_000_000.0) as i64;
|
let ticks = (position_seconds.max(0.0) * 10_000_000.0) as i64;
|
||||||
@@ -387,22 +387,22 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_with_start_time_replaces_existing_ticks() {
|
fn test_with_start_time_replaces_existing_ticks() {
|
||||||
let url = "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=1200000000&Container=mp3";
|
let url = "http://s/Audio/ep2/universal?ApiKey=k&AudioStreamIndex=2&StartTimeTicks=1200000000&Container=mp3";
|
||||||
let out = with_start_time(url, 600.0);
|
let out = with_start_time(url, 600.0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
out,
|
out,
|
||||||
"http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=6000000000&Container=mp3"
|
"http://s/Audio/ep2/universal?ApiKey=k&AudioStreamIndex=2&StartTimeTicks=6000000000&Container=mp3"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_with_start_time_appends_when_absent() {
|
fn test_with_start_time_appends_when_absent() {
|
||||||
// The next-episode stream is built without StartTimeTicks.
|
// The next-episode stream is built without StartTimeTicks.
|
||||||
let url = "http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0";
|
let url = "http://s/Audio/ep3/universal?ApiKey=k&AudioStreamIndex=0";
|
||||||
let out = with_start_time(url, 90.0);
|
let out = with_start_time(url, 90.0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
out,
|
out,
|
||||||
"http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0&StartTimeTicks=900000000"
|
"http://s/Audio/ep3/universal?ApiKey=k&AudioStreamIndex=0&StartTimeTicks=900000000"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -434,7 +434,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() {
|
fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() {
|
||||||
let url = without_server_chosen_subtitle(
|
let url = without_server_chosen_subtitle(
|
||||||
"/videos/abc/master.m3u8?api_key=k&alwaysBurnInSubtitleWhenTranscoding=true\
|
"/videos/abc/master.m3u8?ApiKey=k&alwaysBurnInSubtitleWhenTranscoding=true\
|
||||||
&subtitlestreamindex=3&SubtitleCodec=ass",
|
&subtitlestreamindex=3&SubtitleCodec=ass",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -442,7 +442,7 @@ mod tests {
|
|||||||
assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}");
|
assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}");
|
||||||
assert!(!url.contains("subtitlestreamindex=3"), "{url}");
|
assert!(!url.contains("subtitlestreamindex=3"), "{url}");
|
||||||
assert!(url.contains("SubtitleStreamIndex=-1"), "{url}");
|
assert!(url.contains("SubtitleStreamIndex=-1"), "{url}");
|
||||||
assert!(url.contains("api_key=k"), "{url}");
|
assert!(url.contains("ApiKey=k"), "{url}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A URL the server built without any subtitle in it still has to *say* so:
|
/// A URL the server built without any subtitle in it still has to *say* so:
|
||||||
@@ -451,10 +451,10 @@ mod tests {
|
|||||||
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||||
#[test]
|
#[test]
|
||||||
fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() {
|
fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() {
|
||||||
let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?api_key=k");
|
let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?ApiKey=k");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
url,
|
url,
|
||||||
"/videos/abc/master.m3u8?api_key=k&SubtitleStreamIndex=-1"
|
"/videos/abc/master.m3u8?ApiKey=k&SubtitleStreamIndex=-1"
|
||||||
);
|
);
|
||||||
|
|
||||||
// A bare URL is rare but must not come out malformed.
|
// A bare URL is rare but must not come out malformed.
|
||||||
|
|||||||
Reference in New Issue
Block a user