From 2584ed00b81bc8f0bbcd2e635409630783368054 Mon Sep 17 00:00:00 2001 From: Dave Townsend Date: Wed, 25 Jan 2023 12:36:07 +0000 Subject: [PATCH] feat: Add functionality for transcoding media. --- crates/plex-api/Cargo.toml | 4 + crates/plex-api/src/error.rs | 6 + crates/plex-api/src/http_client.rs | 39 +- crates/plex-api/src/lib.rs | 9 +- .../src/media_container/server/library.rs | 333 ++- crates/plex-api/src/server/library.rs | 119 +- crates/plex-api/src/server/mod.rs | 39 +- crates/plex-api/src/server/transcode.rs | 930 +++++++++ crates/plex-api/src/url.rs | 3 + crates/plex-api/src/webhook/mod.rs | 2 + .../tests/mocks/transcode/empty_sessions.json | 5 + .../tests/mocks/transcode/metadata_1036.json | 545 +++++ .../mocks/transcode/metadata_157786.json | 169 ++ .../mocks/transcode/metadata_159637.json | 967 +++++++++ .../tests/mocks/transcode/music_mp3.json | 153 ++ .../tests/mocks/transcode/music_sessions.json | 26 + .../mocks/transcode/video_dash_h264_mp3.json | 857 ++++++++ .../mocks/transcode/video_dash_h265_aac.json | 857 ++++++++ .../mocks/transcode/video_hls_vp9_pcm.json | 857 ++++++++ .../transcode/video_offline_h264_mp3.json | 871 ++++++++ .../transcode/video_offline_refused.json | 550 +++++ .../tests/mocks/transcode/video_sessions.json | 33 + crates/plex-api/tests/transcode.rs | 1783 +++++++++++++++++ crates/xtask/src/test.rs | 4 +- 24 files changed, 9138 insertions(+), 23 deletions(-) create mode 100644 crates/plex-api/src/server/transcode.rs create mode 100644 crates/plex-api/tests/mocks/transcode/empty_sessions.json create mode 100644 crates/plex-api/tests/mocks/transcode/metadata_1036.json create mode 100644 crates/plex-api/tests/mocks/transcode/metadata_157786.json create mode 100644 crates/plex-api/tests/mocks/transcode/metadata_159637.json create mode 100644 crates/plex-api/tests/mocks/transcode/music_mp3.json create mode 100644 crates/plex-api/tests/mocks/transcode/music_sessions.json create mode 100644 crates/plex-api/tests/mocks/transcode/video_dash_h264_mp3.json create mode 100644 crates/plex-api/tests/mocks/transcode/video_dash_h265_aac.json create mode 100644 crates/plex-api/tests/mocks/transcode/video_hls_vp9_pcm.json create mode 100644 crates/plex-api/tests/mocks/transcode/video_offline_h264_mp3.json create mode 100644 crates/plex-api/tests/mocks/transcode/video_offline_refused.json create mode 100644 crates/plex-api/tests/mocks/transcode/video_sessions.json create mode 100644 crates/plex-api/tests/transcode.rs diff --git a/crates/plex-api/Cargo.toml b/crates/plex-api/Cargo.toml index fe00ca99..bb71d7ae 100644 --- a/crates/plex-api/Cargo.toml +++ b/crates/plex-api/Cargo.toml @@ -43,6 +43,10 @@ async-std = { version = "^1.12", features = ["attributes"] } plex-api-test-helper = { path = "../plex-api-test-helper" } rstest = "^0.16.0" rpassword = "^7.2" +dash-mpd = "^0.7.0" +hls_m3u8 = "^0.4.1" +mp4 = "^0.13.0" +mp3-metadata = "^0.3.4" [dev-dependencies.cargo-husky] version = "1" diff --git a/crates/plex-api/src/error.rs b/crates/plex-api/src/error.rs index 207b4047..2b0ad22f 100644 --- a/crates/plex-api/src/error.rs +++ b/crates/plex-api/src/error.rs @@ -69,6 +69,12 @@ pub enum Error { PinNotLinked, #[error("Item requested was not found on the server.")] ItemNotFound, + #[error("The requested transcode parameters were invalid.")] + InvalidTranscodeSettings, + #[error("The transcode request failed: {0}.")] + TranscodeError(String), + #[error("The server thinks the client should just play the original media.")] + TranscodeRefused, } const PLEX_API_ERROR_CODE_AUTH_OTP_REQUIRED: i32 = 1029; diff --git a/crates/plex-api/src/http_client.rs b/crates/plex-api/src/http_client.rs index 2606fae0..f0dd953d 100644 --- a/crates/plex-api/src/http_client.rs +++ b/crates/plex-api/src/http_client.rs @@ -13,7 +13,7 @@ use std::time::Duration; use uuid::Uuid; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); -const DEFAULT_CONNECTIONT_TIMEOUT: Duration = Duration::from_secs(5); +const DEFAULT_CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug, Clone)] pub struct HttpClient { @@ -114,6 +114,7 @@ impl HttpClient { base_url: self.api_url.clone(), path_and_query: path, request_builder: self.prepare_request().method("POST"), + timeout: Some(DEFAULT_TIMEOUT), } } @@ -129,6 +130,7 @@ impl HttpClient { base_url: self.api_url.clone(), path_and_query: path, request_builder: self.prepare_request_min().method("POST"), + timeout: Some(DEFAULT_TIMEOUT), } } @@ -143,6 +145,7 @@ impl HttpClient { base_url: self.api_url.clone(), path_and_query: path, request_builder: self.prepare_request().method("GET"), + timeout: Some(DEFAULT_TIMEOUT), } } @@ -158,6 +161,7 @@ impl HttpClient { base_url: self.api_url.clone(), path_and_query: path, request_builder: self.prepare_request_min().method("GET"), + timeout: Some(DEFAULT_TIMEOUT), } } @@ -172,6 +176,7 @@ impl HttpClient { base_url: self.api_url.clone(), path_and_query: path, request_builder: self.prepare_request().method("PUT"), + timeout: Some(DEFAULT_TIMEOUT), } } @@ -187,6 +192,7 @@ impl HttpClient { base_url: self.api_url.clone(), path_and_query: path, request_builder: self.prepare_request_min().method("PUT"), + timeout: Some(DEFAULT_TIMEOUT), } } @@ -201,6 +207,7 @@ impl HttpClient { base_url: self.api_url.clone(), path_and_query: path, request_builder: self.prepare_request().method("DELETE"), + timeout: Some(DEFAULT_TIMEOUT), } } @@ -216,6 +223,7 @@ impl HttpClient { base_url: self.api_url.clone(), path_and_query: path, request_builder: self.prepare_request_min().method("DELETE"), + timeout: Some(DEFAULT_TIMEOUT), } } @@ -245,6 +253,7 @@ where base_url: Uri, path_and_query: P, request_builder: Builder, + timeout: Option, } impl<'a, P> RequestBuilder<'a, P> @@ -252,6 +261,17 @@ where PathAndQuery: TryFrom

, >::Error: Into, { + // Sets the maximum timeout for this request or disables timeouts. + pub fn timeout(self, timeout: Option) -> RequestBuilder<'a, P> { + Self { + http_client: self.http_client, + base_url: self.base_url, + path_and_query: self.path_and_query, + request_builder: self.request_builder, + timeout, + } + } + /// Adds a body to the request. pub fn body(self, body: B) -> Result> where @@ -262,9 +282,14 @@ where uri_parts.path_and_query = Some(path_and_query); let uri = Uri::from_parts(uri_parts).map_err(Into::::into)?; + let mut builder = self.request_builder.uri(uri); + if let Some(timeout) = self.timeout { + builder = builder.timeout(timeout); + } + Ok(Request { http_client: self.http_client, - request: self.request_builder.uri(uri).body(body)?, + request: builder.body(body)?, }) } @@ -289,6 +314,7 @@ where base_url: self.base_url, path_and_query: self.path_and_query, request_builder: self.request_builder.header(key, value), + timeout: self.timeout, } } @@ -350,8 +376,7 @@ impl Default for HttpClientBuilder { let client = HttpClient { api_url: Uri::from_static(MYPLEX_DEFAULT_API_URL), http_client: IsahcHttpClient::builder() - .timeout(DEFAULT_TIMEOUT) - .connect_timeout(DEFAULT_CONNECTIONT_TIMEOUT) + .connect_timeout(DEFAULT_CONNECTION_TIMEOUT) .redirect_policy(RedirectPolicy::None) .build() .expect("failed to create default http client"), @@ -376,6 +401,12 @@ impl Default for HttpClientBuilder { } impl HttpClientBuilder { + /// Creates a client that maps to Plex's Generic profile which has no + /// particular settings defined for transcoding. + pub fn generic() -> Self { + Self::default().set_x_plex_platform("Generic".to_string()) + } + pub fn build(self) -> Result { self.client } diff --git a/crates/plex-api/src/lib.rs b/crates/plex-api/src/lib.rs index 6a40157f..30973531 100644 --- a/crates/plex-api/src/lib.rs +++ b/crates/plex-api/src/lib.rs @@ -14,13 +14,20 @@ pub use http_client::HttpClient; pub use http_client::HttpClientBuilder; pub use media_container::devices::Feature; pub use media_container::preferences::Value as SettingValue; -pub use media_container::server::library::ContainerFormat; +pub use media_container::server::library::{ + AudioCodec, ContainerFormat, Decision, Protocol, SubtitleCodec, VideoCodec, +}; +pub use media_container::server::Feature as ServerFeature; pub use myplex::{device, pin::PinManager, MyPlex, MyPlexBuilder}; pub use player::Player; pub use server::library::{ Artist, Collection, Episode, Item, Library, MediaItem, MetadataItem, Movie, MusicAlbum, Photo, PhotoAlbum, PhotoAlbumItem, Playlist, Season, Show, Track, Video, }; +pub use server::transcode::{ + AudioSetting, Constraint, Limitation, MusicTranscodeOptions, TranscodeSession, TranscodeStatus, + VideoSetting, VideoTranscodeOptions, +}; pub use server::Server; pub type Result = std::result::Result; diff --git a/crates/plex-api/src/media_container/server/library.rs b/crates/plex-api/src/media_container/server/library.rs index 0b4deabf..4bbe207e 100644 --- a/crates/plex-api/src/media_container/server/library.rs +++ b/crates/plex-api/src/media_container/server/library.rs @@ -1,12 +1,13 @@ use crate::media_container::MediaContainer; use monostate::MustBe; -use serde::{Deserialize, Deserializer}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_aux::prelude::{ deserialize_bool_from_anything, deserialize_number_from_string, deserialize_option_number_from_string, }; use serde_json::Value; -use serde_plain::derive_fromstr_from_deserialize; +use serde_plain::{derive_display_from_serialize, derive_fromstr_from_deserialize}; +use serde_with::{formats::CommaSeparator, serde_as, StringWithSeparator}; use time::{Date, OffsetDateTime}; fn optional_boolish<'de, D>(deserializer: D) -> Result, D::Error> @@ -16,6 +17,37 @@ where Ok(Some(deserialize_bool_from_anything(deserializer)?)) } +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Decision { + Copy, + Transcode, + Ignore, + DirectPlay, + Burn, + #[cfg(not(feature = "tests_deny_unknown_fields"))] + #[serde(other)] + Unknown, +} + +derive_display_from_serialize!(Decision); + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Protocol { + // HTTP file download + Http, + // HTTP Live Streaming + Hls, + // Dynamic Adaptive Streaming over HTTP + Dash, + #[cfg(not(feature = "tests_deny_unknown_fields"))] + #[serde(other)] + Unknown, +} + +derive_display_from_serialize!(Protocol); + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ChapterSource { @@ -29,7 +61,7 @@ pub enum ChapterSource { derive_fromstr_from_deserialize!(ChapterSource); -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)] #[serde(rename_all = "kebab-case")] pub enum AudioCodec { Aac, @@ -48,8 +80,9 @@ pub enum AudioCodec { } derive_fromstr_from_deserialize!(AudioCodec); +derive_display_from_serialize!(AudioCodec); -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)] #[serde(rename_all = "kebab-case")] pub enum VideoCodec { H264, @@ -67,8 +100,41 @@ pub enum VideoCodec { } derive_fromstr_from_deserialize!(VideoCodec); +derive_display_from_serialize!(VideoCodec); + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum LyricCodec { + Lrc, + Txt, + #[cfg(not(feature = "tests_deny_unknown_fields"))] + #[serde(other)] + Unknown, +} + +derive_fromstr_from_deserialize!(LyricCodec); +derive_display_from_serialize!(LyricCodec); + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SubtitleCodec { + Ass, + Pgs, + Subrip, + Srt, + DvdSubtitle, + MovText, + Vtt, + DvbSubtitle, + #[cfg(not(feature = "tests_deny_unknown_fields"))] + #[serde(other)] + Unknown, +} + +derive_fromstr_from_deserialize!(SubtitleCodec); +derive_display_from_serialize!(SubtitleCodec); -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ContainerFormat { Aac, @@ -80,20 +146,223 @@ pub enum ContainerFormat { Mp4, Mpeg, MpegTs, + Ogg, + Wav, #[cfg(not(feature = "tests_deny_unknown_fields"))] #[serde(other)] Unknown, } derive_fromstr_from_deserialize!(ContainerFormat); +derive_display_from_serialize!(ContainerFormat); + +#[serde_as] +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))] +pub struct VideoStream { + #[serde(deserialize_with = "deserialize_number_from_string")] + pub id: u64, + pub stream_type: MustBe!(1), + pub index: Option, + pub codec: VideoCodec, + pub default: Option, + pub selected: Option, + pub title: Option, + pub display_title: String, + pub extended_display_title: String, + + #[serde_as(as = "Option>")] + pub required_bandwidths: Option>, + pub decision: Option, + pub location: Option, + + pub height: u32, + pub width: u32, + pub bit_depth: Option, + pub bitrate: u32, + pub chroma_location: Option, + pub chroma_subsampling: Option, + pub coded_height: Option, + pub coded_width: Option, + pub color_primaries: Option, + pub color_range: Option, + pub color_space: Option, + pub color_trc: Option, + pub frame_rate: f32, + pub has_scaling_matrix: Option, + pub level: Option, + pub profile: Option, + pub ref_frames: Option, + pub scan_type: Option, + #[serde(rename = "codecID")] + pub codec_id: Option, + pub stream_identifier: Option, +} + +#[serde_as] +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))] +pub struct AudioStream { + #[serde(deserialize_with = "deserialize_number_from_string")] + pub id: u64, + pub stream_type: MustBe!(2), + pub index: Option, + pub codec: AudioCodec, + pub default: Option, + pub selected: Option, + pub title: Option, + pub display_title: String, + pub extended_display_title: String, + + #[serde_as(as = "Option>")] + pub required_bandwidths: Option>, + pub decision: Option, + pub location: Option, + + pub channels: u32, + pub audio_channel_layout: Option, + pub profile: Option, + pub sampling_rate: Option, + pub bitrate: Option, + pub bitrate_mode: Option, + pub language: Option, + pub language_code: Option, + pub language_tag: Option, + pub peak: Option, + pub gain: Option, + pub album_peak: Option, + pub album_gain: Option, + pub album_range: Option, + pub lra: Option, + pub loudness: Option, + pub stream_identifier: Option, +} + +#[serde_as] +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))] +pub struct SubtitleStream { + #[serde(deserialize_with = "deserialize_number_from_string")] + pub id: u64, + pub stream_type: MustBe!(3), + pub index: Option, + pub codec: SubtitleCodec, + pub default: Option, + pub selected: Option, + pub title: Option, + pub display_title: String, + pub extended_display_title: String, + + #[serde_as(as = "Option>")] + pub required_bandwidths: Option>, + pub decision: Option, + pub location: Option, + + pub key: Option, + pub format: Option, + pub file: Option, + pub bitrate: Option, + pub hearing_impaired: Option, + pub language: Option, + pub language_code: Option, + pub language_tag: Option, + pub ignore: Option, + pub burn: Option, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))] +pub struct LyricStream { + #[serde(deserialize_with = "deserialize_number_from_string")] + pub id: u64, + pub stream_type: MustBe!(4), + pub index: Option, + pub codec: LyricCodec, + pub default: Option, + pub selected: Option, + pub title: Option, + pub display_title: String, + pub extended_display_title: String, + + pub key: Option, + pub format: Option, + pub timed: Option, + pub min_lines: Option, + pub provider: Option, +} +#[derive(Debug, Deserialize, Clone)] +#[cfg_attr(not(feature = "tests_deny_unknown_fields"), serde(untagged))] +#[cfg_attr(feature = "tests_deny_unknown_fields", serde(try_from = "Value"))] +pub enum Stream { + Video(VideoStream), + Audio(AudioStream), + Subtitle(SubtitleStream), + Lyric(LyricStream), + Unknown(Value), +} + +// This generates much saner errors in tests than an untagged enum. +#[cfg(feature = "tests_deny_unknown_fields")] +impl TryFrom for Stream { + type Error = String; + + fn try_from(value: Value) -> Result { + let stream_type = match &value { + Value::Object(o) => { + if let Some(Value::Number(n)) = o.get("streamType") { + if let Some(v) = n.as_u64() { + v + } else { + return Err(format!( + "Failed to decode Stream. Unexpected streamType `{n}`" + )); + } + } else { + return Err("Failed to decode Stream. Missing streamType property.".to_string()); + } + } + _ => return Err("Failed to decode Stream. Data was not an object.".to_string()), + }; + + if stream_type == 1 { + let s: VideoStream = serde_json::from_value(value) + .map_err(|e| format!("Failed to decode video stream: {e}"))?; + Ok(Self::Video(s)) + } else if stream_type == 2 { + let s: AudioStream = serde_json::from_value(value) + .map_err(|e| format!("Failed to decode audio stream: {e}"))?; + Ok(Self::Audio(s)) + } else if stream_type == 3 { + let s: SubtitleStream = serde_json::from_value(value) + .map_err(|e| format!("Failed to decode subtitle stream: {e}"))?; + Ok(Self::Subtitle(s)) + } else if stream_type == 4 { + let s: LyricStream = serde_json::from_value(value) + .map_err(|e| format!("Failed to decode lyric stream: {e}"))?; + Ok(Self::Lyric(s)) + } else if !cfg!(feature = "tests_deny_unknown_fields") { + Ok(Self::Unknown(value)) + } else { + Err(format!( + "Failed to decode Stream. Unexpected streamType `{stream_type}`" + )) + } + } +} + +#[serde_as] #[derive(Debug, Deserialize, Clone)] #[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))] #[serde(rename_all = "camelCase")] pub struct Part { #[serde(deserialize_with = "deserialize_number_from_string")] pub id: u64, - pub key: String, + pub key: Option, pub duration: Option, pub file: Option, pub size: Option, @@ -101,6 +370,11 @@ pub struct Part { pub indexes: Option, pub audio_profile: Option, pub video_profile: Option, + pub protocol: Option, + pub selected: Option, + pub decision: Option, + pub width: Option, + pub height: Option, pub packet_length: Option, pub has_thumbnail: Option, #[serde(rename = "has64bitOffsets")] @@ -108,10 +382,12 @@ pub struct Part { #[serde(default, deserialize_with = "optional_boolish")] pub optimized_for_streaming: Option, pub has_chapter_text_stream: Option, - // Not deserialized for now but included to allow the deny_unknown_field tests to pass. - #[cfg(feature = "tests_deny_unknown_fields")] + pub deep_analysis_version: Option, + #[serde_as(as = "Option>")] + pub required_bandwidths: Option>, + pub bitrate: Option, #[serde(rename = "Stream")] - _streams: Option>, + pub streams: Option>, } #[derive(Debug, Deserialize, Clone)] @@ -127,6 +403,7 @@ pub struct Media { #[serde(default, deserialize_with = "deserialize_option_number_from_string")] pub aspect_ratio: Option, pub audio_channels: Option, + pub protocol: Option, pub audio_codec: Option, pub video_codec: Option, pub video_resolution: Option, @@ -134,6 +411,7 @@ pub struct Media { pub video_frame_rate: Option, pub audio_profile: Option, pub video_profile: Option, + pub selected: Option, #[serde(rename = "Part")] pub parts: Vec, #[serde(rename = "has64bitOffsets")] @@ -156,23 +434,49 @@ pub struct Location { pub path: String, } +#[derive(Debug, Deserialize, Clone)] +#[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))] +pub struct Guid { + pub id: String, +} + #[derive(Debug, Deserialize, Clone)] #[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))] pub struct Tag { + #[serde(default, deserialize_with = "deserialize_option_number_from_string")] pub id: Option, pub tag: String, pub filter: Option, + #[serde(default, deserialize_with = "deserialize_option_number_from_string")] pub count: Option, } #[derive(Debug, Deserialize, Clone)] #[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))] +#[serde(rename_all = "camelCase")] +pub struct Rating { + #[serde(default, deserialize_with = "deserialize_number_from_string")] + pub count: u32, + pub image: String, + #[serde(rename = "type")] + pub rating_type: String, + #[serde(default, deserialize_with = "deserialize_number_from_string")] + pub value: f32, +} + +#[derive(Debug, Deserialize, Clone)] +#[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))] +#[serde(rename_all = "camelCase")] pub struct Role { + #[serde(default, deserialize_with = "deserialize_option_number_from_string")] pub id: Option, pub tag: String, + pub tag_key: Option, pub filter: Option, pub role: Option, pub thumb: Option, + #[serde(default, deserialize_with = "deserialize_option_number_from_string")] + pub count: Option, } #[derive(Debug, Deserialize, Clone)] @@ -261,6 +565,7 @@ pub struct Metadata { pub subtype: Option, pub playlist_type: Option, pub smart: Option, + #[serde(default, deserialize_with = "optional_boolish")] pub allow_sync: Option, pub title: String, @@ -321,9 +626,12 @@ pub struct Metadata { pub view_offset: Option, pub chapter_source: Option, pub primary_extra_key: Option, + #[serde(default, deserialize_with = "optional_boolish")] pub has_premium_lyrics: Option, + pub music_analysis_version: Option, #[serde(rename = "librarySectionID")] + #[serde(default, deserialize_with = "deserialize_option_number_from_string")] pub library_section_id: Option, pub library_section_title: Option, pub library_section_key: Option, @@ -333,6 +641,8 @@ pub struct Metadata { #[serde(flatten)] pub grand_parent: GrandParentMetadata, + #[serde(default, rename = "Guid")] + pub guids: Vec, #[serde(default, rename = "Collection")] pub collections: Vec, #[serde(default, rename = "Similar")] @@ -347,12 +657,16 @@ pub struct Metadata { pub writers: Vec, #[serde(default, rename = "Country")] pub countries: Vec, + #[serde(default, rename = "Rating")] + pub ratings: Vec, #[serde(default, rename = "Role")] pub roles: Vec, #[serde(default, rename = "Location")] pub locations: Vec, #[serde(default, rename = "Field")] pub fields: Vec, + #[serde(default, rename = "Mood")] + pub moods: Vec, #[serde(rename = "Media")] pub media: Option>, @@ -372,6 +686,7 @@ pub struct MetadataMediaContainer { pub summary: Option, pub duration: Option, + #[serde(default, deserialize_with = "optional_boolish")] pub allow_sync: Option, #[serde(rename = "nocache")] pub no_cache: Option, diff --git a/crates/plex-api/src/server/library.rs b/crates/plex-api/src/server/library.rs index c6f73b7e..fef7358d 100644 --- a/crates/plex-api/src/server/library.rs +++ b/crates/plex-api/src/server/library.rs @@ -9,13 +9,15 @@ use crate::{ media_container::{ server::library::{ LibraryType, Media as MediaMetadata, Metadata, MetadataMediaContainer, MetadataType, - Part as PartMetadata, PlaylistType, ServerLibrary, + Part as PartMetadata, PlaylistType, Protocol, ServerLibrary, }, MediaContainerWrapper, }, - HttpClient, Result, + HttpClient, MusicTranscodeOptions, Result, TranscodeSession, VideoTranscodeOptions, }; +use super::transcode::{create_transcode_session, Context}; + pub trait FromMetadata { /// Creates an item given the http configuration and item metadata. No /// validation is performed that the metadata is correct. @@ -129,8 +131,10 @@ where } /// A single media format for a `MediaItem`. +#[derive(Debug, Clone)] pub struct Media<'a> { client: &'a HttpClient, + media_index: usize, media: &'a MediaMetadata, } @@ -141,8 +145,11 @@ impl<'a> Media<'a> { self.media .parts .iter() - .map(|part| Part { + .enumerate() + .map(|(index, part)| Part { client: self.client, + media_index: self.media_index, + part_index: index, part, }) .collect() @@ -155,8 +162,11 @@ impl<'a> Media<'a> { } /// One part of a `Media`. +#[derive(Debug, Clone)] pub struct Part<'a> { - client: &'a HttpClient, + pub(crate) client: &'a HttpClient, + pub(crate) media_index: usize, + pub(crate) part_index: usize, part: &'a PartMetadata, } @@ -174,7 +184,7 @@ impl<'a> Part<'a> { W: AsyncWrite + Unpin, R: RangeBounds, { - let path = format!("{}?download=1", self.part.key); + let path = format!("{}?download=1", self.part.key.as_ref().unwrap()); let start = match range.start_bound() { std::ops::Bound::Included(v) => *v, @@ -221,8 +231,10 @@ pub trait MediaItem: MetadataItem { if let Some(ref media) = metadata.media { media .iter() - .map(|media| Media { + .enumerate() + .map(|(index, media)| Media { client: self.client(), + media_index: index, media, }) .collect() @@ -318,6 +330,39 @@ derive_metadata_item!(Movie); impl MediaItem for Movie {} +impl Movie { + /// Starts an offline transcode of the given media part using the provided + /// options. + /// + /// The server may refuse to transcode if the options suggest that the + /// original media file can be played back directly. + pub async fn create_download_session<'a>( + &'a self, + part: &Part<'a>, + options: VideoTranscodeOptions, + ) -> Result { + create_transcode_session( + &self.metadata, + part, + Context::Static, + Protocol::Http, + options, + ) + .await + } + + /// Starts a streaming transcode using of the given media part using the + /// streaming protocol and provided options. + pub async fn create_streaming_session<'a>( + &'a self, + part: &Part<'a>, + protocol: Protocol, + options: VideoTranscodeOptions, + ) -> Result { + create_transcode_session(&self.metadata, part, Context::Streaming, protocol, options).await + } +} + #[derive(Debug, Clone)] pub struct Show { client: HttpClient, @@ -391,6 +436,37 @@ impl Episode { pub async fn season(&self) -> Result> { parent(self, &self.client).await } + + /// Starts an offline transcode of the given media part using the provided + /// options. + /// + /// The server may refuse to transcode if the options suggest that the + /// original media file can be played back directly. + pub async fn create_download_session<'a>( + &'a self, + part: &Part<'a>, + options: VideoTranscodeOptions, + ) -> Result { + create_transcode_session( + &self.metadata, + part, + Context::Static, + Protocol::Http, + options, + ) + .await + } + + /// Starts a streaming transcode using of the given media part using the + /// streaming protocol and provided options. + pub async fn create_streaming_session<'a>( + &'a self, + part: &Part<'a>, + protocol: Protocol, + options: VideoTranscodeOptions, + ) -> Result { + create_transcode_session(&self.metadata, part, Context::Streaming, protocol, options).await + } } #[derive(Debug, Clone)] @@ -451,6 +527,37 @@ impl Track { pub async fn album(&self) -> Result> { parent(self, &self.client).await } + + /// Starts an offline transcode of the given media part using the provided + /// options. + /// + /// The server may refuse to transcode if the options suggest that the + /// original media file can be played back directly. + pub async fn create_download_session<'a>( + &'a self, + part: &Part<'a>, + options: MusicTranscodeOptions, + ) -> Result { + create_transcode_session( + &self.metadata, + part, + Context::Static, + Protocol::Http, + options, + ) + .await + } + + /// Starts a streaming transcode using of the given media part using the + /// streaming protocol and provided options. + pub async fn create_streaming_session<'a>( + &'a self, + part: &Part<'a>, + protocol: Protocol, + options: MusicTranscodeOptions, + ) -> Result { + create_transcode_session(&self.metadata, part, Context::Streaming, protocol, options).await + } } #[derive(Debug, Clone)] diff --git a/crates/plex-api/src/server/mod.rs b/crates/plex-api/src/server/mod.rs index 4f39bb52..24a614dd 100644 --- a/crates/plex-api/src/server/mod.rs +++ b/crates/plex-api/src/server/mod.rs @@ -1,9 +1,11 @@ pub mod library; pub mod prefs; +pub mod transcode; use self::{ library::{metadata_items, Library}, prefs::Preferences, + transcode::TranscodeSessionsMediaContainer, }; #[cfg(not(feature = "tests_deny_unknown_fields"))] use crate::media_container::server::library::LibraryType; @@ -14,8 +16,11 @@ use crate::{ MediaContainerWrapper, }, myplex::MyPlex, - url::{SERVER_MEDIA_PROVIDERS, SERVER_MYPLEX_ACCOUNT, SERVER_MYPLEX_CLAIM}, - Error, HttpClientBuilder, Item, Result, + url::{ + SERVER_MEDIA_PROVIDERS, SERVER_MYPLEX_ACCOUNT, SERVER_MYPLEX_CLAIM, + SERVER_TRANSCODE_SESSIONS, + }, + Error, HttpClientBuilder, Item, Result, TranscodeSession, }; use core::convert::TryFrom; use http::{StatusCode, Uri}; @@ -92,6 +97,36 @@ impl Server { } } + /// Retrieves a list of the current transcode sessions. + pub async fn transcode_sessions(&self) -> Result> { + let wrapper: MediaContainerWrapper = + self.client.get(SERVER_TRANSCODE_SESSIONS).json().await?; + + Ok(wrapper + .media_container + .transcode_sessions + .into_iter() + .map(move |stats| TranscodeSession::from_stats(self.client.clone(), stats)) + .collect()) + } + + /// Retrieves the transcode session with the passed ID. + pub async fn transcode_session(&self, session_id: &str) -> Result { + let wrapper: MediaContainerWrapper = self + .client + .get(format!("{SERVER_TRANSCODE_SESSIONS}/{session_id}")) + .json() + .await?; + let stats = wrapper + .media_container + .transcode_sessions + .get(0) + .cloned() + .ok_or(crate::Error::ItemNotFound)?; + + Ok(TranscodeSession::from_stats(self.client.clone(), stats)) + } + /// Allows retrieving media, playlists, collections and other items using /// their rating key. pub async fn item_by_id(&self, rating_key: u32) -> Result { diff --git a/crates/plex-api/src/server/transcode.rs b/crates/plex-api/src/server/transcode.rs new file mode 100644 index 00000000..4e4d3789 --- /dev/null +++ b/crates/plex-api/src/server/transcode.rs @@ -0,0 +1,930 @@ +//! Support for transcoding media files into lower quality versions. +//! +//! Transcoding comes in two forms: +//! * Streaming allows for real-time playback of the media using streaming +//! protocols such as [HTTP Live Streaming](https://en.wikipedia.org/wiki/HTTP_Live_Streaming) +//! and [Dynamic Adaptive Streaming over HTTP](https://en.wikipedia.org/wiki/Dynamic_Adaptive_Streaming_over_HTTP). +//! * Offline transcoding (the mobile downloads feature) requests that the +//! server converts the file in the background allowing it to be downloaded +//! later. +//! +//! This feature should be considered quite experimental, lots of the API calls +//! are derived from inspection and guesswork. +use std::{collections::HashMap, fmt::Display}; + +use futures::AsyncWrite; +use http::StatusCode; +use isahc::AsyncReadResponseExt; +use serde::{Deserialize, Serialize}; +use serde_plain::derive_display_from_serialize; +use uuid::Uuid; + +use crate::{ + error, + media_container::{ + server::{ + library::{ + AudioStream, Decision, Media as MediaMetadata, Metadata, Protocol, Stream, + VideoStream, + }, + Feature, + }, + MediaContainer, MediaContainerWrapper, + }, + url::{SERVER_TRANSCODE_DECISION, SERVER_TRANSCODE_DOWNLOAD}, + AudioCodec, ContainerFormat, HttpClient, Result, VideoCodec, +}; + +use super::library::Part; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Context { + Streaming, + Static, + #[cfg(not(feature = "tests_deny_unknown_fields"))] + #[serde(other)] + Unknown, +} + +derive_display_from_serialize!(Context); + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))] +struct TranscodeDecisionMediaContainer { + general_decision_code: Option, + general_decision_text: Option, + + direct_play_decision_code: Option, + direct_play_decision_text: Option, + + transcode_decision_code: Option, + transcode_decision_text: Option, + + allow_sync: String, + #[serde(rename = "librarySectionID")] + library_section_id: Option, + library_section_title: Option, + #[serde(rename = "librarySectionUUID")] + library_section_uuid: Option, + media_tag_prefix: Option, + media_tag_version: Option, + resource_session: Option, + + #[serde(flatten)] + media_container: MediaContainer, + + #[serde(default, rename = "Metadata")] + metadata: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))] +pub struct TranscodeSessionStats { + pub key: String, + pub throttled: bool, + pub complete: bool, + // Percentage complete. + pub progress: f32, + pub size: i64, + pub speed: Option, + pub error: bool, + pub duration: Option, + // Appears to be the number of seconds that the server thinks remain. + pub remaining: Option, + pub context: Context, + pub source_video_codec: Option, + pub source_audio_codec: Option, + pub video_decision: Option, + pub audio_decision: Option, + pub subtitle_decision: Option, + pub protocol: Protocol, + pub container: ContainerFormat, + pub video_codec: Option, + pub audio_codec: Option, + pub audio_channels: u8, + pub width: Option, + pub height: Option, + pub transcode_hw_requested: bool, + pub transcode_hw_decoding: Option, + pub transcode_hw_encoding: Option, + pub transcode_hw_decoding_title: Option, + pub transcode_hw_full_pipeline: Option, + pub transcode_hw_encoding_title: Option, + #[serde(default)] + pub offline_transcode: bool, + pub time_stamp: Option, + pub min_offset_available: Option, + pub max_offset_available: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TranscodeSessionsMediaContainer { + #[serde(default, rename = "TranscodeSession")] + pub transcode_sessions: Vec, +} + +struct Query { + params: HashMap, +} + +impl Query { + fn new() -> Self { + Self { + params: HashMap::new(), + } + } + + fn param, V: Into>(mut self, name: N, value: V) -> Self { + self.params.insert(name.into(), value.into()); + self + } +} + +impl ToString for Query { + fn to_string(&self) -> String { + serde_urlencoded::to_string(&self.params).unwrap() + } +} + +struct ProfileSetting { + setting: String, + params: Vec, +} + +impl ProfileSetting { + fn new(setting: &str) -> Self { + Self { + setting: setting.to_owned(), + params: Vec::new(), + } + } + + fn param(mut self, name: N, value: V) -> Self { + self.params.push(format!("{name}={value}")); + self + } +} + +impl ToString for ProfileSetting { + fn to_string(&self) -> String { + format!("{}({})", self.setting, self.params.join("&")) + } +} + +#[derive(Debug, Copy, Clone)] +pub enum VideoSetting { + /// Video width. + Width, + /// Video height. + Height, + /// Colour bit depth. + BitDepth, + /// h264 level. + Level, + /// Supported h264 profile. + Profile, + /// Framerate. + FrameRate, +} + +impl ToString for VideoSetting { + fn to_string(&self) -> String { + match self { + VideoSetting::Width => "video.width".to_string(), + VideoSetting::Height => "video.height".to_string(), + VideoSetting::BitDepth => "video.bitDepth".to_string(), + VideoSetting::Level => "video.level".to_string(), + VideoSetting::Profile => "video.profile".to_string(), + VideoSetting::FrameRate => "video.frameRate".to_string(), + } + } +} + +#[derive(Debug, Copy, Clone)] +pub enum AudioSetting { + /// Audio channels. + Channels, + /// Sample rate. + SamplingRate, + /// Sample bit depth. + BitDepth, +} + +impl ToString for AudioSetting { + fn to_string(&self) -> String { + match self { + AudioSetting::Channels => "audio.channels".to_string(), + AudioSetting::SamplingRate => "audio.samplingRate".to_string(), + AudioSetting::BitDepth => "audio.bitDepth".to_string(), + } + } +} + +#[derive(Debug, Clone)] +pub enum Constraint { + Max(String), + Min(String), + Match(Vec), + NotMatch(String), +} + +/// Limitations add a constraint to the supported media format. +/// +/// They generally set the maximum or minimum value of a setting or constrain +/// the setting to a specific list of values. So for example you can set the +/// maximum video width or the maximum number of audio channels. Limitations are +/// either set on a per-codec basis or apply to all codecs. +#[derive(Debug, Clone)] +pub struct Limitation { + pub codec: Option, + pub setting: S, + pub constraint: Constraint, +} + +impl Limitation { + fn build(&self, scope: &str) -> ProfileSetting { + let scope_name = if let Some(codec) = &self.codec { + codec.to_string() + } else { + "*".to_string() + }; + let name = self.setting.to_string(); + + let setting = ProfileSetting::new("add-limitation") + .param("scope", scope) + .param("scopeName", scope_name) + .param("name", name); + + match &self.constraint { + Constraint::Max(v) => setting.param("type", "upperBound").param("value", v), + Constraint::Min(v) => setting.param("type", "lowerBound").param("value", v), + Constraint::Match(l) => setting.param("type", "match").param( + "list", + l.iter() + .map(|s| s.to_string()) + .collect::>() + .join("|"), + ), + Constraint::NotMatch(v) => setting.param("type", "notMatch").param("value", v), + } + } +} + +impl From<(S, Constraint)> for Limitation { + fn from((setting, constraint): (S, Constraint)) -> Self { + Self { + codec: None, + setting, + constraint, + } + } +} + +impl From<(C, S, Constraint)> for Limitation { + fn from((codec, setting, constraint): (C, S, Constraint)) -> Self { + Self { + codec: Some(codec), + setting, + constraint, + } + } +} + +impl From<(Option, S, Constraint)> for Limitation { + fn from((codec, setting, constraint): (Option, S, Constraint)) -> Self { + Self { + codec, + setting, + constraint, + } + } +} + +pub(super) trait TranscodeOptions { + fn transcode_parameters( + &self, + context: Context, + protocol: Protocol, + container: Option, + ) -> String; +} + +/// Defines the media formats suitable for transcoding video. The server uses +/// these settings to choose a format to transcode to. +/// +/// The server is not very clever at choosing codecs that work for a given +/// container format. It is safest to only list codecs and containers that work +/// together. +/// +/// Note that the server maintains default transcode profiles for many devices +/// which will alter the supported transcode targets. By default for instance if +/// the server thinks you are an Android client it will only offer stereo audio +/// in videos. You can see these profiles in `Resources/Profiles` of the media +/// server install directory. Individual settings in the profile can be +/// overridden via the API however if you want to be sure of a clean slate use +/// a [generic client](crate::HttpClientBuilder::generic). +#[derive(Debug, Clone)] +pub struct VideoTranscodeOptions { + /// Maximum bitrate in kbps. + pub bitrate: u32, + /// Maximum video width. + pub width: u32, + /// Maximum video height. + pub height: u32, + /// Audio gain from 0 to 100. + pub audio_boost: Option, + /// Whether to burn the subtitles into the video. + pub burn_subtitles: bool, + /// Supported media container formats. Ignored for streaming transcodes. + pub containers: Vec, + /// Supported video codecs. + pub video_codecs: Vec, + /// Limitations to constraint video transcoding options. + pub video_limitations: Vec>, + /// Supported audio codecs. + pub audio_codecs: Vec, + /// Limitations to constraint audio transcoding options. + pub audio_limitations: Vec>, +} + +impl Default for VideoTranscodeOptions { + fn default() -> Self { + Self { + bitrate: 2000, + width: 1280, + height: 720, + audio_boost: None, + burn_subtitles: true, + containers: vec![ContainerFormat::Mp4, ContainerFormat::Mkv], + video_codecs: vec![VideoCodec::H264], + video_limitations: Default::default(), + audio_codecs: vec![AudioCodec::Aac, AudioCodec::Mp3], + audio_limitations: Default::default(), + } + } +} + +impl TranscodeOptions for VideoTranscodeOptions { + fn transcode_parameters( + &self, + context: Context, + protocol: Protocol, + container: Option, + ) -> String { + let mut query = Query::new() + .param("maxVideoBitrate", self.bitrate.to_string()) + .param("videoBitrate", self.bitrate.to_string()) + .param("videoResolution", format!("{}x{}", self.width, self.height)); + + if self.burn_subtitles { + query = query + .param("subtitles", "burn") + .param("subtitleSize", "100"); + } + + if let Some(boost) = self.audio_boost { + query = query.param("audioBoost", boost.to_string()); + } + + let video_codecs = self + .video_codecs + .iter() + .map(|c| c.to_string()) + .collect::>() + .join(","); + + let audio_codecs = self + .audio_codecs + .iter() + .map(|c| c.to_string()) + .collect::>() + .join(","); + + let containers = if let Some(container) = container { + vec![container.to_string()] + } else { + self.containers.iter().map(ToString::to_string).collect() + }; + + let mut profile = Vec::new(); + + for container in containers { + profile.push( + ProfileSetting::new("add-transcode-target") + .param("type", "videoProfile") + .param("context", context.to_string()) + .param("protocol", protocol.to_string()) + .param("container", &container) + .param("videoCodec", &video_codecs) + .param("audioCodec", &audio_codecs) + .to_string(), + ); + + // Allow potentially direct playing for offline transcodes. + if context == Context::Static { + profile.push( + ProfileSetting::new("add-direct-play-profile") + .param("type", "videoProfile") + .param("container", container) + .param("videoCodec", &video_codecs) + .param("audioCodec", &audio_codecs) + .to_string(), + ); + } + } + + profile.extend(self.video_codecs.iter().map(|codec| { + ProfileSetting::new("append-transcode-target-codec") + .param("type", "videoProfile") + .param("context", context.to_string()) + .param("protocol", protocol.to_string()) + .param("videoCodec", codec.to_string()) + .to_string() + })); + + profile.extend(self.audio_codecs.iter().map(|codec| { + ProfileSetting::new("add-transcode-target-audio-codec") + .param("type", "videoProfile") + .param("context", context.to_string()) + .param("protocol", protocol.to_string()) + .param("audioCodec", codec.to_string()) + .to_string() + })); + + profile.extend( + self.video_limitations + .iter() + .map(|l| l.build("videoCodec").to_string()), + ); + profile.extend( + self.audio_limitations + .iter() + .map(|l| l.build("videoAudioCodec").to_string()), + ); + + query + .param("X-Plex-Client-Profile-Extra", profile.join("+")) + .to_string() + } +} + +/// Defines the media formats suitable for transcoding music. The server uses +/// these settings to choose a format to transcode to. +/// +/// The server is not very clever at choosing codecs that work for a given +/// container format. It is safest to only list codecs and containers that work +/// together. +/// +/// Note that the server maintains default transcode profiles for many devices +/// which will alter the supported transcode targets. By default for instance if +/// the server thinks you are an Android client it will only offer stereo audio +/// in videos. You can see these profiles in `Resources/Profiles` of the media +/// server install directory. Individual settings in the profile can be +/// overridden via the API however if you want to be sure of a clean slate use +/// a [generic client](crate::HttpClientBuilder::generic). +#[derive(Debug, Clone)] +pub struct MusicTranscodeOptions { + /// Maximum bitrate in kbps. + pub bitrate: u32, + /// Supported media container formats. Ignored for streaming transcodes. + pub containers: Vec, + /// Supported audio codecs. + pub codecs: Vec, + /// Limitations to constraint audio transcoding options. + pub limitations: Vec>, +} + +impl Default for MusicTranscodeOptions { + fn default() -> Self { + Self { + bitrate: 192, + containers: vec![ContainerFormat::Mp3], + codecs: vec![AudioCodec::Mp3], + limitations: Default::default(), + } + } +} + +impl TranscodeOptions for MusicTranscodeOptions { + fn transcode_parameters( + &self, + context: Context, + protocol: Protocol, + container: Option, + ) -> String { + let query = Query::new().param("musicBitrate", self.bitrate.to_string()); + + let audio_codecs = self + .codecs + .iter() + .map(|c| c.to_string()) + .collect::>() + .join(","); + + let containers = if let Some(container) = container { + vec![container.to_string()] + } else { + self.containers.iter().map(ToString::to_string).collect() + }; + + let mut profile = Vec::new(); + + for container in containers { + profile.push( + ProfileSetting::new("add-transcode-target") + .param("type", "musicProfile") + .param("context", context.to_string()) + .param("protocol", protocol.to_string()) + .param("container", &container) + .param("audioCodec", &audio_codecs) + .to_string(), + ); + + // Allow potentially direct playing for offline transcodes. + if context == Context::Static { + profile.push( + ProfileSetting::new("add-direct-play-profile") + .param("type", "musicProfile") + .param("container", container) + .param("audioCodec", &audio_codecs) + .to_string(), + ); + } + } + + profile.extend( + self.limitations + .iter() + .map(|l| l.build("audioCodec").to_string()), + ); + + query + .param("X-Plex-Client-Profile-Extra", profile.join("+")) + .to_string() + } +} + +/// Generates a unique session id. This appears to just be any random string. +fn session_id() -> String { + Uuid::new_v4().as_simple().to_string() +} + +fn bs(val: bool) -> String { + if val { + "1".to_string() + } else { + "0".to_string() + } +} + +fn get_transcode_params( + id: &str, + context: Context, + protocol: Protocol, + item_metadata: &Metadata, + part: &Part, + options: O, +) -> Result { + let container = match (context, protocol) { + (Context::Static, _) => None, + (_, Protocol::Dash) => Some(ContainerFormat::Mp4), + (_, Protocol::Hls) => Some(ContainerFormat::MpegTs), + _ => return Err(error::Error::InvalidTranscodeSettings), + }; + + let mut query = Query::new() + .param("session", id) + .param("path", item_metadata.key.clone()) + .param("mediaIndex", part.media_index.to_string()) + .param("partIndex", part.part_index.to_string()) + // Setting this to true tells the server that we're willing to directly + // play the item if needed. That probably makes sense for downloads but + // not streaming (where we need the DASH/HLS protocol). + .param("directPlay", bs(context == Context::Static)) + // Allows using the original video stream if possible. + .param("directStream", bs(true)) + // Allows using the original audio stream if possible. + .param("directStreamAudio", bs(true)) + .param("protocol", protocol.to_string()) + .param("context", context.to_string()) + .param("location", "lan") + .param("fastSeek", bs(true)); + + if context == Context::Static { + query = query.param("offlineTranscode", bs(true)); + } + + let query = query.to_string(); + + let params = options.transcode_parameters(context, protocol, container); + + Ok(format!("{query}&{params}")) +} + +async fn transcode_decision<'a>(part: &Part<'a>, params: &str) -> Result { + let path = format!("{SERVER_TRANSCODE_DECISION}?{params}"); + + let mut response = part + .client + .get(path) + .header("Accept", "application/json") + .send() + .await?; + + let text = match response.status() { + StatusCode::OK => response.text().await?, + _ => return Err(crate::Error::from_response(response).await), + }; + + let wrapper: MediaContainerWrapper = + serde_json::from_str(&text)?; + + if wrapper.media_container.general_decision_code == Some(2011) + && wrapper.media_container.general_decision_text + == Some("Downloads not allowed".to_string()) + { + return Err(error::Error::SubscriptionFeatureNotAvailable( + Feature::SyncV3, + )); + } + + if wrapper.media_container.direct_play_decision_code == Some(1000) { + return Err(error::Error::TranscodeRefused); + } + + wrapper + .media_container + .metadata + .into_iter() + .next() + .and_then(|m| m.media) + .and_then(|m| m.into_iter().find(|m| m.selected == Some(true))) + .ok_or_else(|| { + if let Some(text) = wrapper.media_container.transcode_decision_text { + error::Error::TranscodeError(text) + } else { + error::Error::UnexpectedApiResponse { + status_code: response.status().as_u16(), + content: text, + } + } + }) +} + +pub(super) async fn create_transcode_session<'a, O: TranscodeOptions>( + item_metadata: &'a Metadata, + part: &Part<'a>, + context: Context, + target_protocol: Protocol, + options: O, +) -> Result { + let id = session_id(); + + let params = get_transcode_params(&id, context, target_protocol, item_metadata, part, options)?; + + let media_data = transcode_decision(part, ¶ms).await?; + + if target_protocol != media_data.protocol.unwrap_or(Protocol::Http) { + return Err(error::Error::TranscodeError( + "Server returned an invalid protocol.".to_string(), + )); + } + + TranscodeSession::from_metadata( + id, + part.client.clone(), + media_data, + context == Context::Static, + params, + ) +} + +pub enum TranscodeStatus { + Complete, + Error, + Transcoding { + // The server's estimate of how many seconds are left until complete. + remaining: Option, + // Percent complete (0-100). + progress: f32, + }, +} + +pub struct TranscodeSession { + id: String, + client: HttpClient, + offline: bool, + protocol: Protocol, + container: ContainerFormat, + video_transcode: Option<(Decision, VideoCodec)>, + audio_transcode: Option<(Decision, AudioCodec)>, + params: String, +} + +impl TranscodeSession { + pub(crate) fn from_stats(client: HttpClient, stats: TranscodeSessionStats) -> Self { + Self { + client, + // Once the transcode session is started we only need the session ID + // to download. + params: format!("session={}", stats.key), + offline: stats.offline_transcode, + container: stats.container, + protocol: stats.protocol, + video_transcode: stats.video_decision.zip(stats.video_codec), + audio_transcode: stats.audio_decision.zip(stats.audio_codec), + id: stats.key, + } + } + + fn from_metadata( + id: String, + client: HttpClient, + media_data: MediaMetadata, + offline: bool, + params: String, + ) -> Result { + let part_data = media_data + .parts + .iter() + .find(|p| p.selected == Some(true)) + .ok_or_else(|| { + error::Error::TranscodeError("Server returned unexpected response".to_string()) + })?; + + let streams = part_data.streams.as_ref().ok_or_else(|| { + error::Error::TranscodeError("Server returned unexpected response".to_string()) + })?; + + let video_streams = streams + .iter() + .filter_map(|s| match s { + Stream::Video(s) => Some(s), + _ => None, + }) + .collect::>(); + + let video_transcode = video_streams + .iter() + .find(|s| s.selected == Some(true)) + .or_else(|| video_streams.get(0)) + .map(|s| (s.decision.unwrap(), s.codec)); + + let audio_streams = streams + .iter() + .filter_map(|s| match s { + Stream::Audio(s) => Some(s), + _ => None, + }) + .collect::>(); + + let audio_transcode = audio_streams + .iter() + .find(|s| s.selected == Some(true)) + .or_else(|| audio_streams.get(0)) + .map(|s| (s.decision.unwrap(), s.codec)); + + Ok(Self { + id, + client, + offline, + params, + container: media_data.container.unwrap(), + protocol: media_data.protocol.unwrap_or(Protocol::Http), + video_transcode, + audio_transcode, + }) + } + + /// The session ID allows for re-retrieving this session at a later date. + pub fn session_id(&self) -> &str { + &self.id + } + + pub fn is_offline(&self) -> bool { + self.offline + } + + /// The selected protocol. + pub fn protocol(&self) -> Protocol { + self.protocol + } + + /// The selected container. + pub fn container(&self) -> ContainerFormat { + self.container + } + + // The target video codec and the transcode decision. + pub fn video_transcode(&self) -> Option<(Decision, VideoCodec)> { + self.video_transcode + } + + // The target audio codec and the transcode decision. + pub fn audio_transcode(&self) -> Option<(Decision, AudioCodec)> { + self.audio_transcode + } + + /// Downloads the transcoded data to the provided writer. + /// + /// For streaming transcodes (MPEG-DASH or HLS) this will return the + /// playlist data. This crate doesn't contain any support for processing + /// these streaming formats and figuring out how to use them is currently + /// left as an exercise for the caller. + /// + /// For offline transcodes it is possible to start downloading before the + /// transcode is complete. In this case any data already transcoded is + /// downloaded and then the connection will remain open and more data will + /// be delivered to the writer as it becomes available. This can mean + /// that the HTTP connection is idle for long periods of time waiting for + /// more data to be transcoded and so the normal timeouts are disabled for + /// offline transcode downloads. + /// + /// Unfortunately there does not appear to be any way to restart downloads + /// from a specific point in the file. So if the download fails for + /// any reason you have to start downloading all over again. It may make + /// more sense to wait until the transcode is complete or nearly complete + /// before attempting download. + pub async fn download(&self, writer: W) -> Result<()> + where + W: AsyncWrite + Unpin, + { + // Strictly speaking it doesn't appear that the requested extension + // matters but we'll attempt to match other clients anyway. + let ext = match (self.protocol, self.container) { + (Protocol::Dash, _) => "mpd".to_string(), + (Protocol::Hls, _) => "m3u8".to_string(), + (_, container) => container.to_string(), + }; + + let path = format!("{SERVER_TRANSCODE_DOWNLOAD}/start.{}?{}", ext, self.params); + + let mut builder = self.client.get(path); + if self.offline { + builder = builder.timeout(None) + } + let mut response = builder.send().await?; + + match response.status() { + StatusCode::OK => { + response.copy_to(writer).await?; + Ok(()) + } + _ => Err(crate::Error::from_response(response).await), + } + } + + pub async fn status(&self) -> Result { + let stats = self.stats().await?; + + if stats.error { + Ok(TranscodeStatus::Error) + } else if stats.complete { + Ok(TranscodeStatus::Complete) + } else { + Ok(TranscodeStatus::Transcoding { + remaining: stats.remaining, + progress: stats.progress, + }) + } + } + + /// Retrieves the current transcode stats. + pub async fn stats(&self) -> Result { + let wrapper: MediaContainerWrapper = self + .client + .get(format!("/transcode/sessions/{}", self.id)) + .json() + .await?; + wrapper + .media_container + .transcode_sessions + .get(0) + .cloned() + .ok_or(crate::Error::ItemNotFound) + } + + /// Cancels the transcode and removes any transcoded data from the server. + pub async fn cancel(self) -> Result<()> { + let mut response = self + .client + .get(format!( + "/video/:/transcode/universal/stop?session={}", + self.id + )) + .send() + .await?; + + match response.status() { + // Sometimes the server will respond not found but still cancel the + // session. + StatusCode::OK | StatusCode::NOT_FOUND => Ok(response.consume().await?), + _ => Err(crate::Error::from_response(response).await), + } + } +} diff --git a/crates/plex-api/src/url.rs b/crates/plex-api/src/url.rs index fc6f8450..5a8c5d49 100644 --- a/crates/plex-api/src/url.rs +++ b/crates/plex-api/src/url.rs @@ -25,5 +25,8 @@ pub const SERVER_MEDIA_PROVIDERS: &str = "/media/providers"; pub const SERVER_MYPLEX_ACCOUNT: &str = "/myplex/account"; pub const SERVER_MYPLEX_CLAIM: &str = "/myplex/claim"; pub const SERVER_PREFS: &str = "/:/prefs"; +pub const SERVER_TRANSCODE_SESSIONS: &str = "/transcode/sessions"; +pub const SERVER_TRANSCODE_DECISION: &str = "/video/:/transcode/universal/decision"; +pub const SERVER_TRANSCODE_DOWNLOAD: &str = "/video/:/transcode/universal"; pub const CLIENT_RESOURCES: &str = "/resources"; diff --git a/crates/plex-api/src/webhook/mod.rs b/crates/plex-api/src/webhook/mod.rs index 883388eb..8b48031b 100644 --- a/crates/plex-api/src/webhook/mod.rs +++ b/crates/plex-api/src/webhook/mod.rs @@ -5,6 +5,7 @@ //! please read it for further information. use serde::{Deserialize, Serialize}; +use serde_aux::prelude::deserialize_option_number_from_string; use time::OffsetDateTime; #[derive(Deserialize, Debug)] @@ -67,6 +68,7 @@ pub struct Metadata { pub grandparent_rating_key: Option, pub guid: Option, #[serde(rename = "librarySectionID")] + #[serde(deserialize_with = "deserialize_option_number_from_string")] pub library_section_id: Option, pub r#type: Option, pub parent_title: Option, diff --git a/crates/plex-api/tests/mocks/transcode/empty_sessions.json b/crates/plex-api/tests/mocks/transcode/empty_sessions.json new file mode 100644 index 00000000..3280488a --- /dev/null +++ b/crates/plex-api/tests/mocks/transcode/empty_sessions.json @@ -0,0 +1,5 @@ +{ + "MediaContainer": { + "size": 0 + } +} \ No newline at end of file diff --git a/crates/plex-api/tests/mocks/transcode/metadata_1036.json b/crates/plex-api/tests/mocks/transcode/metadata_1036.json new file mode 100644 index 00000000..c00790dc --- /dev/null +++ b/crates/plex-api/tests/mocks/transcode/metadata_1036.json @@ -0,0 +1,545 @@ +{ + "MediaContainer": { + "size": 1, + "allowSync": true, + "identifier": "com.plexapp.plugins.library", + "librarySectionID": 1, + "librarySectionTitle": "Movies", + "librarySectionUUID": "a006b58966aa34f3c577ca3106e99c5d1d6ea8b1", + "mediaTagPrefix": "/system/bundle/media/flags/", + "mediaTagVersion": 1676975406, + "Metadata": [ + { + "ratingKey": "1036", + "key": "/library/metadata/1036", + "guid": "plex://movie/5d776830f59e58002189813a", + "studio": "The Zanuck Company", + "type": "movie", + "title": "Reign of Fire", + "librarySectionTitle": "Movies", + "librarySectionID": 1, + "librarySectionKey": "/library/sections/1", + "contentRating": "gb/12", + "summary": "In post-apocalyptic England, an American volunteer and a British survivor team up to fight off a brood of fire-breathing dragons seeking to return to global dominance after centuries of rest underground. The Brit -- leading a clan of survivors to hunt down the King of the Dragons -- has much at stake: His mother was killed by a dragon, but his love is still alive.", + "rating": 4.2, + "audienceRating": 4.9, + "viewCount": 1, + "lastViewedAt": 1368998603, + "year": 2002, + "tagline": "Fight Fire With Fire", + "thumb": "/library/metadata/1036/thumb/1677122881", + "art": "/library/metadata/1036/art/1677122881", + "duration": 6118122, + "originallyAvailableAt": "2002-07-12", + "addedAt": 1368992739, + "updatedAt": 1677122881, + "audienceRatingImage": "rottentomatoes://image.rating.spilled", + "primaryExtraKey": "/library/metadata/145150", + "ratingImage": "rottentomatoes://image.rating.rotten", + "Media": [ + { + "id": 307448, + "duration": 6118122, + "bitrate": 2108, + "width": 1920, + "height": 820, + "aspectRatio": 2.35, + "audioChannels": 2, + "audioCodec": "aac", + "videoCodec": "h264", + "videoResolution": "1080", + "container": "mp4", + "videoFrameRate": "24p", + "optimizedForStreaming": 1, + "audioProfile": "lc", + "has64bitOffsets": false, + "videoProfile": "high", + "Part": [ + { + "id": 320566, + "key": "/library/parts/320566/1677272892/file.mp4", + "duration": 6118122, + "file": "/mnt/media/Libraries/movies/Reign of Fire (2002)/Reign of Fire (2002).mp4", + "size": 1615558857, + "audioProfile": "lc", + "container": "mp4", + "has64bitOffsets": false, + "indexes": "sd", + "optimizedForStreaming": true, + "videoProfile": "high", + "Stream": [ + { + "id": 566406, + "streamType": 1, + "default": true, + "codec": "h264", + "index": 0, + "bitrate": 2016, + "bitDepth": 8, + "chromaLocation": "left", + "chromaSubsampling": "4:2:0", + "codedHeight": 832, + "codedWidth": 1920, + "frameRate": 23.976, + "hasScalingMatrix": false, + "height": 820, + "level": 40, + "profile": "high", + "refFrames": 5, + "scanType": "progressive", + "streamIdentifier": "1", + "width": 1920, + "displayTitle": "1080p (H.264)", + "extendedDisplayTitle": "1080p (H.264)" + }, + { + "id": 566407, + "streamType": 2, + "selected": true, + "default": true, + "codec": "aac", + "index": 1, + "channels": 2, + "bitrate": 92, + "audioChannelLayout": "stereo", + "profile": "lc", + "samplingRate": 48000, + "streamIdentifier": "2", + "displayTitle": "Unknown (AAC Stereo)", + "extendedDisplayTitle": "Unknown (AAC Stereo)" + }, + { + "id": 566408, + "key": "/library/streams/566408", + "streamType": 3, + "codec": "srt", + "format": "srt", + "displayTitle": "Unknown (SRT External)", + "extendedDisplayTitle": "Unknown (SRT External)" + } + ] + } + ] + } + ], + "Genre": [ + { + "id": 48, + "filter": "genre=48", + "tag": "Fantasy" + }, + { + "id": 128, + "filter": "genre=128", + "tag": "Thriller" + }, + { + "id": 39, + "filter": "genre=39", + "tag": "Action" + }, + { + "id": 130, + "filter": "genre=130", + "tag": "Adventure" + } + ], + "Director": [ + { + "id": 92561, + "filter": "director=92561", + "tag": "Rob Bowman" + } + ], + "Writer": [ + { + "id": 124916, + "filter": "writer=124916", + "tag": "Gregg Shabot" + } + ], + "Producer": [ + { + "id": 92586, + "filter": "producer=92586", + "tag": "Richard D. Zanuck" + }, + { + "id": 92587, + "filter": "producer=92587", + "tag": "Roger Birnbaum" + }, + { + "id": 92588, + "filter": "producer=92588", + "tag": "Gary Barber" + }, + { + "id": 92589, + "filter": "producer=92589", + "tag": "Lili Fini Zanuck" + } + ], + "Country": [ + { + "id": 51039, + "filter": "country=51039", + "tag": "Ireland" + }, + { + "id": 113, + "filter": "country=113", + "tag": "United Kingdom" + }, + { + "id": 55636, + "filter": "country=55636", + "tag": "United States of America" + } + ], + "Guid": [ + { + "id": "imdb://tt0253556" + }, + { + "id": "tmdb://6278" + }, + { + "id": "tvdb://1709" + } + ], + "Rating": [ + { + "image": "imdb://image.rating", + "value": 6.2, + "type": "audience" + }, + { + "image": "rottentomatoes://image.rating.rotten", + "value": 4.2, + "type": "critic" + }, + { + "image": "rottentomatoes://image.rating.spilled", + "value": 4.9, + "type": "audience" + }, + { + "image": "themoviedb://image.rating", + "value": 6.1, + "type": "audience" + } + ], + "Role": [ + { + "id": 89823, + "filter": "actor=89823", + "tag": "Christian Bale", + "tagKey": "5d776825880197001ec9038f", + "role": "Quinn Abercromby", + "thumb": "https://metadata-static.plex.tv/f/people/fde8f8b1be96957d9659bee97b0fab30.jpg" + }, + { + "id": 92563, + "filter": "actor=92563", + "tag": "Matthew McConaughey", + "tagKey": "5d7768287e9a3c0020c6adeb", + "role": "Denton Van Zan", + "thumb": "https://metadata-static.plex.tv/8/people/8750c9fb7d18bbb37ac2a14e13b81b3a.jpg" + }, + { + "id": 92564, + "filter": "actor=92564", + "tag": "Izabella Scorupco", + "tagKey": "5d77682854c0f0001f301f75", + "role": "Alex Jensen", + "thumb": "https://metadata-static.plex.tv/d/people/d429e638a59b28634ec6af3140960d2e.jpg" + }, + { + "id": 92565, + "filter": "actor=92565", + "tag": "Gerard Butler", + "tagKey": "5d776827103a2d001f564587", + "role": "Creedy", + "thumb": "https://metadata-static.plex.tv/d/people/dbc4b9437e4ce8025baaae2d732b332c.jpg" + }, + { + "id": 89320, + "filter": "actor=89320", + "tag": "Alexander Siddig", + "tagKey": "5d7768253c3c2a001fbca997", + "role": "Ajay", + "thumb": "https://metadata-static.plex.tv/3/people/361ac76f8a192a9c0ac3456b57bd247d.jpg" + }, + { + "id": 92566, + "filter": "actor=92566", + "tag": "Scott Moutter", + "tagKey": "5d776830f59e58002189824c", + "role": "Jared Wilke", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e58002189824c.jpg" + }, + { + "id": 92567, + "filter": "actor=92567", + "tag": "David Kennedy", + "tagKey": "5d776824103a2d001f563af2", + "role": "Eddie Stax", + "thumb": "https://metadata-static.plex.tv/people/5d776824103a2d001f563af2.jpg" + }, + { + "id": 92568, + "filter": "actor=92568", + "tag": "Ned Dennehy", + "tagKey": "5d776830f59e58002189824d", + "role": "Barlow", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e58002189824d.jpg" + }, + { + "id": 92569, + "filter": "actor=92569", + "tag": "Rory Keenan", + "tagKey": "5d776830f59e58002189824e", + "role": "Devon", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e58002189824e.jpg" + }, + { + "id": 92570, + "filter": "actor=92570", + "tag": "Terence Maynard", + "tagKey": "5d776830f59e58002189824f", + "role": "Gideon", + "thumb": "https://metadata-static.plex.tv/c/people/c575b6dc7431d4e9531e0b0b36964a57.jpg" + }, + { + "id": 92571, + "filter": "actor=92571", + "tag": "Doug Cockle", + "tagKey": "5d77682a103a2d001f56544b", + "role": "Goosh", + "thumb": "https://metadata-static.plex.tv/people/5d77682a103a2d001f56544b.jpg" + }, + { + "id": 92572, + "filter": "actor=92572", + "tag": "Randall Carlton", + "tagKey": "5d776830f59e580021898250", + "role": "Burke (Tito)" + }, + { + "id": 131769, + "filter": "actor=131769", + "tag": "Chris Kelly", + "tagKey": "5f402a2c864225004283df99", + "role": "Mead" + }, + { + "id": 92574, + "filter": "actor=92574", + "tag": "Ben Thornton", + "tagKey": "5d776830f59e580021898252", + "role": "Young Quinn", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e580021898252.jpg" + }, + { + "id": 92575, + "filter": "actor=92575", + "tag": "Alice Krige", + "tagKey": "5d7768256f4521001ea989e5", + "role": "Karen Abercromby", + "thumb": "https://metadata-static.plex.tv/a/people/a6a97be93e67ef006335a3053cebbccc.jpg" + }, + { + "id": 124918, + "filter": "actor=124918", + "tag": "Malcolm Douglas", + "tagKey": "6323ac6993de28374b3036a6", + "role": "Stuart", + "thumb": "https://metadata-static.plex.tv/c/people/c14ed37571ff876919c23eb2afc6bd68.jpg" + }, + { + "id": 92577, + "filter": "actor=92577", + "tag": "Berts Folan", + "tagKey": "5d776830f59e580021898254", + "role": "Construction Worker #1" + }, + { + "id": 92578, + "filter": "actor=92578", + "tag": "Brian McGuinness", + "tagKey": "5d776830f59e580021898255", + "role": "Construction Worker #2", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e580021898255.jpg" + }, + { + "id": 92579, + "filter": "actor=92579", + "tag": "Barry Barnes", + "tagKey": "5d776830f59e580021898256", + "role": "Construction Worker #3", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e580021898256.jpg" + }, + { + "id": 92580, + "filter": "actor=92580", + "tag": "Gerry O'Brien", + "tagKey": "5d776830f59e580021898257", + "role": "Jerry", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e580021898257.jpg" + }, + { + "id": 92581, + "filter": "actor=92581", + "tag": "Laura Pyper", + "tagKey": "5d776830f59e580021898258", + "role": "Lin", + "thumb": "https://metadata-static.plex.tv/5/people/5ccf760be76f2d9a77fc6692a333461b.jpg" + }, + { + "id": 92582, + "filter": "actor=92582", + "tag": "Maree Duffy", + "tagKey": "5d776830f59e580021898259", + "role": "Rachel", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e580021898259.jpg" + }, + { + "id": 92583, + "filter": "actor=92583", + "tag": "David Garrick", + "tagKey": "5d776830f59e58002189825a", + "role": "Jefferson", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e58002189825a.jpg" + }, + { + "id": 92584, + "filter": "actor=92584", + "tag": "Anne Maria McAuley", + "tagKey": "5d776830f59e58002189825b", + "role": "Rose", + "thumb": "https://metadata-static.plex.tv/2/people/22b4d2774882f886b8b552ed35d6e61f.jpg" + }, + { + "id": 92585, + "filter": "actor=92585", + "tag": "Dessie Gallagher", + "tagKey": "5d776830f59e58002189825c", + "role": "Jess", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e58002189825c.jpg" + }, + { + "id": 115830, + "filter": "actor=115830", + "tag": "Jack Gleeson", + "tagKey": "5d77686a374a5b001fec4f9b", + "role": "Kid (uncredited)", + "thumb": "https://metadata-static.plex.tv/9/people/924ab7470eee26ca525ec2499ec4a6c8.jpg" + } + ], + "Similar": [ + { + "id": 49276, + "filter": "similar=49276", + "tag": "The 6th Day" + }, + { + "id": 51030, + "filter": "similar=51030", + "tag": "The 13th Warrior" + }, + { + "id": 50430, + "filter": "similar=50430", + "tag": "The One" + }, + { + "id": 53334, + "filter": "similar=53334", + "tag": "Outlander" + }, + { + "id": 49161, + "filter": "similar=49161", + "tag": "Sky Captain and the World of Tomorrow" + }, + { + "id": 51643, + "filter": "similar=51643", + "tag": "Paycheck" + }, + { + "id": 52522, + "filter": "similar=52522", + "tag": "DragonHeart" + }, + { + "id": 51111, + "filter": "similar=51111", + "tag": "The Time Machine" + }, + { + "id": 51434, + "filter": "similar=51434", + "tag": "Hollow Man" + }, + { + "id": 49265, + "filter": "similar=49265", + "tag": "Broken Arrow" + }, + { + "id": 49152, + "filter": "similar=49152", + "tag": "Æon Flux" + }, + { + "id": 51440, + "filter": "similar=51440", + "tag": "Sphere" + }, + { + "id": 49151, + "filter": "similar=49151", + "tag": "Final Fantasy: The Spirits Within" + }, + { + "id": 51441, + "filter": "similar=51441", + "tag": "Outbreak" + }, + { + "id": 50909, + "filter": "similar=50909", + "tag": "The League of Extraordinary Gentlemen" + }, + { + "id": 53335, + "filter": "similar=53335", + "tag": "Waterworld" + }, + { + "id": 53336, + "filter": "similar=53336", + "tag": "Doom" + }, + { + "id": 49372, + "filter": "similar=49372", + "tag": "Godzilla" + }, + { + "id": 49153, + "filter": "similar=49153", + "tag": "Daybreakers" + }, + { + "id": 49267, + "filter": "similar=49267", + "tag": "Payback" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/crates/plex-api/tests/mocks/transcode/metadata_157786.json b/crates/plex-api/tests/mocks/transcode/metadata_157786.json new file mode 100644 index 00000000..17efd7fe --- /dev/null +++ b/crates/plex-api/tests/mocks/transcode/metadata_157786.json @@ -0,0 +1,169 @@ +{ + "MediaContainer": { + "size": 1, + "allowSync": true, + "identifier": "com.plexapp.plugins.library", + "librarySectionID": 33, + "librarySectionTitle": "Dave's Music", + "librarySectionUUID": "5da09f65-108f-470b-a8c1-bc5961da07c5", + "mediaTagPrefix": "/system/bundle/media/flags/", + "mediaTagVersion": 1676975406, + "Metadata": [ + { + "ratingKey": "157786", + "key": "/library/metadata/157786", + "parentRatingKey": "157785", + "grandparentRatingKey": "157717", + "guid": "plex://track/5d07cdc4403c640290f653c2", + "parentGuid": "plex://album/5d07c185403c64029084f920", + "grandparentGuid": "plex://artist/5d07bbfc403c6402904a60d1", + "parentStudio": "Virgin", + "type": "track", + "title": "One More Time", + "grandparentKey": "/library/metadata/157717", + "parentKey": "/library/metadata/157785", + "librarySectionTitle": "Dave's Music", + "librarySectionID": 33, + "librarySectionKey": "/library/sections/33", + "grandparentTitle": "Daft Punk", + "parentTitle": "Discovery", + "summary": "", + "index": 1, + "parentIndex": 1, + "ratingCount": 1549072, + "parentYear": 2001, + "thumb": "/library/metadata/157785/thumb/1675091155", + "art": "/library/metadata/157717/art/1677122331", + "parentThumb": "/library/metadata/157785/thumb/1675091155", + "grandparentThumb": "/library/metadata/157717/thumb/1677122331", + "grandparentArt": "/library/metadata/157717/art/1677122331", + "duration": 320888, + "addedAt": 1360195318, + "updatedAt": 1675091155, + "musicAnalysisVersion": "1", + "Media": [ + { + "id": 305587, + "duration": 320888, + "bitrate": 320, + "audioChannels": 2, + "audioCodec": "mp3", + "container": "mp3", + "Part": [ + { + "id": 318704, + "key": "/library/parts/318704/1360195318/file.MP3", + "duration": 320888, + "file": "/mnt/media/Libraries/music/dave/Daft Punk/Discovery/01 One More Time.MP3", + "size": 12867355, + "container": "mp3", + "hasThumbnail": "1", + "Stream": [ + { + "id": 560564, + "streamType": 2, + "selected": true, + "codec": "mp3", + "index": 0, + "channels": 2, + "bitrate": 320, + "albumGain": "-6.89", + "albumPeak": "1.000000", + "albumRange": "8.604516", + "audioChannelLayout": "stereo", + "gain": "-6.89", + "loudness": "-12.23", + "lra": "6.73", + "peak": "1.000000", + "samplingRate": 44100, + "displayTitle": "MP3 (Stereo)", + "extendedDisplayTitle": "MP3 (Stereo)" + }, + { + "id": 564240, + "key": "/library/streams/564240", + "streamType": 4, + "codec": "lrc", + "format": "lrc", + "minLines": "3", + "provider": "com.plexapp.agents.lyricfind", + "timed": "1", + "displayTitle": "LRC (External)", + "extendedDisplayTitle": "LRC (External)" + }, + { + "id": 564241, + "key": "/library/streams/564241", + "streamType": 4, + "codec": "txt", + "format": "txt", + "provider": "com.plexapp.agents.lyricfind", + "displayTitle": "TXT (External)", + "extendedDisplayTitle": "TXT (External)" + } + ] + } + ] + } + ], + "Guid": [ + { + "id": "mbid://5bc23f28-1b3a-33c7-ac1a-5d78c63cd8d1" + } + ], + "Mood": [ + { + "id": 133027, + "filter": "mood=133027", + "tag": "Energetic" + }, + { + "id": 132779, + "filter": "mood=132779", + "tag": "Bright" + }, + { + "id": 132778, + "filter": "mood=132778", + "tag": "Freewheeling" + }, + { + "id": 133013, + "filter": "mood=133013", + "tag": "Fun" + }, + { + "id": 132785, + "filter": "mood=132785", + "tag": "Rousing" + }, + { + "id": 132970, + "filter": "mood=132970", + "tag": "Celebratory" + }, + { + "id": 133148, + "filter": "mood=133148", + "tag": "Carefree" + }, + { + "id": 132732, + "filter": "mood=132732", + "tag": "Stylish" + }, + { + "id": 132887, + "filter": "mood=132887", + "tag": "Trippy" + }, + { + "id": 133130, + "filter": "mood=133130", + "tag": "Hypnotic" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/crates/plex-api/tests/mocks/transcode/metadata_159637.json b/crates/plex-api/tests/mocks/transcode/metadata_159637.json new file mode 100644 index 00000000..c76fce49 --- /dev/null +++ b/crates/plex-api/tests/mocks/transcode/metadata_159637.json @@ -0,0 +1,967 @@ +{ + "MediaContainer": { + "size": 1, + "allowSync": true, + "identifier": "com.plexapp.plugins.library", + "librarySectionID": 1, + "librarySectionTitle": "Movies", + "librarySectionUUID": "a006b58966aa34f3c577ca3106e99c5d1d6ea8b1", + "mediaTagPrefix": "/system/bundle/media/flags/", + "mediaTagVersion": 1676899281, + "Metadata": [ + { + "ratingKey": "159637", + "key": "/library/metadata/159637", + "guid": "plex://movie/5d77702e6afb3d0020613fd1", + "studio": "Marvel Studios", + "type": "movie", + "title": "Black Panther: Wakanda Forever", + "librarySectionTitle": "Movies", + "librarySectionID": 1, + "librarySectionKey": "/library/sections/1", + "contentRating": "gb/12A", + "summary": "Queen Ramonda, Shuri, M'Baku, Okoye and the Dora Milaje fight to protect the kingdom of Wakanda from intervening world powers in the wake of King T'Challa's death. As the Wakandans strive to embrace their next chapter, the heroes must band together with the help of War Dog Nakia and Everett Ross and forge a new path for their nation.", + "rating": 8.4, + "audienceRating": 9.4, + "year": 2022, + "tagline": "Forever.", + "thumb": "/library/metadata/159637/thumb/1675330665", + "art": "/library/metadata/159637/art/1675330665", + "duration": 9678688, + "originallyAvailableAt": "2022-11-09", + "addedAt": 1675330657, + "updatedAt": 1675330665, + "audienceRatingImage": "rottentomatoes://image.rating.upright", + "chapterSource": "media", + "primaryExtraKey": "/library/metadata/159638", + "ratingImage": "rottentomatoes://image.rating.ripe", + "Media": [ + { + "id": 307380, + "duration": 9678688, + "bitrate": 8791, + "width": 3840, + "height": 1608, + "aspectRatio": 2.35, + "audioChannels": 6, + "audioCodec": "eac3", + "videoCodec": "hevc", + "videoResolution": "4k", + "container": "mkv", + "videoFrameRate": "24p", + "videoProfile": "main 10", + "Part": [ + { + "id": 320497, + "key": "/library/parts/320497/1675330548/file.mkv", + "duration": 9678688, + "file": "/mnt/media/Libraries/movies/Black Panther Wakanda Forever (2022)/Black Panther Wakanda Forever (2022).mkv", + "size": 10638512184, + "container": "mkv", + "hasThumbnail": "1", + "indexes": "sd", + "videoProfile": "main 10", + "Stream": [ + { + "id": 566075, + "streamType": 1, + "default": true, + "codec": "hevc", + "index": 0, + "bitrate": 8023, + "bitDepth": 10, + "chromaLocation": "left", + "chromaSubsampling": "4:2:0", + "codedHeight": 1608, + "codedWidth": 3840, + "colorPrimaries": "bt2020", + "colorRange": "tv", + "colorSpace": "bt2020nc", + "colorTrc": "smpte2084", + "frameRate": 23.976, + "height": 1608, + "level": 153, + "profile": "main 10", + "refFrames": 1, + "width": 3840, + "displayTitle": "4K HDR10 (HEVC Main 10)", + "extendedDisplayTitle": "4K HDR10 (HEVC Main 10)" + }, + { + "id": 566076, + "streamType": 2, + "selected": true, + "default": true, + "codec": "eac3", + "index": 1, + "channels": 6, + "bitrate": 768, + "language": "English", + "languageTag": "en", + "languageCode": "eng", + "audioChannelLayout": "5.1(side)", + "samplingRate": 48000, + "title": "English DDP Atmos 5.1", + "displayTitle": "English (EAC3 5.1)", + "extendedDisplayTitle": "English DDP Atmos 5.1 (EAC3)" + }, + { + "id": 566077, + "streamType": 3, + "default": true, + "codec": "srt", + "index": 2, + "bitrate": 0, + "language": "English", + "languageTag": "en", + "languageCode": "eng", + "title": "English SRT", + "displayTitle": "English (SRT)", + "extendedDisplayTitle": "English SRT" + }, + { + "id": 566078, + "streamType": 3, + "codec": "srt", + "index": 3, + "bitrate": 0, + "language": "English", + "languageTag": "en", + "languageCode": "eng", + "hearingImpaired": true, + "title": "English SDH SRT", + "displayTitle": "English SDH (SRT)", + "extendedDisplayTitle": "English SDH SRT" + } + ] + } + ] + }, + { + "id": 307381, + "duration": 9678688, + "bitrate": 2000, + "width": 1280, + "height": 720, + "aspectRatio": 2.35, + "audioChannels": 6, + "audioCodec": "aac", + "videoCodec": "h264", + "videoResolution": "720p", + "container": "mkv", + "Part": [ + { + "id": 320498, + "key": "/library/parts/320498/1675330548/file.mp4", + "duration": 5678688, + "file": "/mnt/media/Libraries/movies/Black Panther Wakanda Forever (2022)/Black Panther Wakanda Forever (2022) Pt1.mp4", + "size": 5638512184, + "container": "mp4", + "hasThumbnail": "1", + "indexes": "sd", + "Stream": [ + { + "id": 566079, + "streamType": 1, + "default": true, + "codec": "h264", + "index": 0, + "bitrate": 1808, + "frameRate": 23.976, + "height": 720, + "level": 51, + "width": 1280, + "displayTitle": "720p", + "extendedDisplayTitle": "720p" + }, + { + "id": 566080, + "streamType": 2, + "selected": true, + "default": true, + "codec": "aac", + "index": 1, + "channels": 6, + "bitrate": 192, + "language": "English", + "languageTag": "en", + "languageCode": "eng", + "audioChannelLayout": "5.1(side)", + "samplingRate": 44100, + "title": "AAC", + "displayTitle": "AAC", + "extendedDisplayTitle": "AAC" + } + ] + }, + { + "id": 320499, + "key": "/library/parts/320499/1675330548/file.mp4", + "duration": 4000000, + "file": "/mnt/media/Libraries/movies/Black Panther Wakanda Forever (2022)/Black Panther Wakanda Forever (2022) Pt2.mp4", + "size": 5638512184, + "container": "mp4", + "hasThumbnail": "1", + "indexes": "sd", + "Stream": [ + { + "id": 566079, + "streamType": 1, + "default": true, + "codec": "h264", + "index": 0, + "bitrate": 1808, + "frameRate": 23.976, + "height": 720, + "level": 51, + "width": 1280, + "displayTitle": "720p", + "extendedDisplayTitle": "720p" + }, + { + "id": 566080, + "streamType": 2, + "selected": true, + "default": true, + "codec": "aac", + "index": 1, + "channels": 6, + "bitrate": 192, + "language": "English", + "languageTag": "en", + "languageCode": "eng", + "audioChannelLayout": "5.1(side)", + "samplingRate": 44100, + "title": "AAC", + "displayTitle": "AAC", + "extendedDisplayTitle": "AAC" + } + ] + } + ] + } + ], + "Genre": [ + { + "id": 39, + "filter": "genre=39", + "tag": "Action" + }, + { + "id": 130, + "filter": "genre=130", + "tag": "Adventure" + }, + { + "id": 132, + "filter": "genre=132", + "tag": "Science Fiction" + }, + { + "id": 93, + "filter": "genre=93", + "tag": "Drama" + }, + { + "id": 128, + "filter": "genre=128", + "tag": "Thriller" + }, + { + "id": 48, + "filter": "genre=48", + "tag": "Fantasy" + } + ], + "Director": [ + { + "id": 109867, + "filter": "director=109867", + "tag": "Ryan Coogler" + } + ], + "Writer": [ + { + "id": 99532, + "filter": "writer=99532", + "tag": "Stan Lee" + }, + { + "id": 92467, + "filter": "writer=92467", + "tag": "Jack Kirby" + }, + { + "id": 109868, + "filter": "writer=109868", + "tag": "Ryan Coogler" + }, + { + "id": 112263, + "filter": "writer=112263", + "tag": "Joe Robert Cole" + } + ], + "Producer": [ + { + "id": 89536, + "filter": "producer=89536", + "tag": "Kevin Feige" + }, + { + "id": 92508, + "filter": "producer=92508", + "tag": "Nate Moore" + } + ], + "Country": [ + { + "id": 55636, + "filter": "country=55636", + "tag": "United States of America" + } + ], + "Guid": [ + { + "id": "imdb://tt9114286" + }, + { + "id": "tmdb://505642" + }, + { + "id": "tvdb://31110" + } + ], + "Rating": [ + { + "image": "imdb://image.rating", + "value": 7.2, + "type": "audience" + }, + { + "image": "rottentomatoes://image.rating.ripe", + "value": 8.4, + "type": "critic" + }, + { + "image": "rottentomatoes://image.rating.upright", + "value": 9.4, + "type": "audience" + }, + { + "image": "themoviedb://image.rating", + "value": 7.5, + "type": "audience" + } + ], + "Role": [ + { + "id": 112266, + "filter": "actor=112266", + "tag": "Letitia Wright", + "tagKey": "5d77698896b655001fdd14d1", + "role": "Shuri", + "thumb": "https://metadata-static.plex.tv/9/people/95bd7f16f95577ccfae11f60e4995edb.jpg" + }, + { + "id": 104353, + "filter": "actor=104353", + "tag": "Lupita Nyong'o", + "tagKey": "5d7768ba0ea56a001e2a972f", + "role": "Nakia", + "thumb": "https://metadata-static.plex.tv/4/people/47ca5ee0d2b76822f10572edaea0195d.jpg" + }, + { + "id": 112265, + "filter": "actor=112265", + "tag": "Danai Gurira", + "tagKey": "5d776839f54112001f5bddf9", + "role": "Okoye", + "thumb": "https://metadata-static.plex.tv/1/people/1ac9c5f4b757cd615eb6734b2909c74e.jpg" + }, + { + "id": 112267, + "filter": "actor=112267", + "tag": "Winston Duke", + "tagKey": "5d776b05fb0d55001f5592d5", + "role": "M'Baku", + "thumb": "https://metadata-static.plex.tv/8/people/8803800d4ee7bcb73052932af60d3f5d.jpg" + }, + { + "id": 140579, + "filter": "actor=140579", + "tag": "Dominique Thorne", + "tagKey": "5d776d3a7a53e9001e754ddd", + "role": "Riri Williams / Ironheart", + "thumb": "https://metadata-static.plex.tv/b/people/bd298c6f1a8fcca0e1fd65dff210e6e5.jpg" + }, + { + "id": 131467, + "filter": "actor=131467", + "tag": "Tenoch Huerta Mejía", + "tagKey": "5d7768468718ba001e317d8d", + "role": "Namor", + "thumb": "https://metadata-static.plex.tv/2/people/2cb6d643da8a1de3a8901edbd3feb97a.jpg" + }, + { + "id": 106584, + "filter": "actor=106584", + "tag": "Angela Bassett", + "tagKey": "5d7768267e9a3c0020c6a9ec", + "role": "Ramonda", + "thumb": "https://metadata-static.plex.tv/7/people/75c2642f58f0bf47de1865633a4f309f.jpg" + }, + { + "id": 110223, + "filter": "actor=110223", + "tag": "Florence Kasumba", + "tagKey": "5d77683e7e9a3c0020c6e8e5", + "role": "Ayo", + "thumb": "https://metadata-static.plex.tv/people/5d77683e7e9a3c0020c6e8e5.jpg" + }, + { + "id": 112043, + "filter": "actor=112043", + "tag": "Michaela Coel", + "tagKey": "5d7769b396b655001fdd6fe9", + "role": "Aneka", + "thumb": "https://metadata-static.plex.tv/c/people/cc2c7c20d21eb0832c5d03d02fecffdc.jpg" + }, + { + "id": 140580, + "filter": "actor=140580", + "tag": "Mabel Cadena", + "tagKey": "5e16515b27d563003ed660d3", + "role": "Namora", + "thumb": "https://metadata-static.plex.tv/b/people/b97e4f92db01516849788fe1b866e1cb.jpg" + }, + { + "id": 113112, + "filter": "actor=113112", + "tag": "Lake Bell", + "tagKey": "5d776832151a60001f24d339", + "role": "Dr. Graham", + "thumb": "https://metadata-static.plex.tv/b/people/b158320c71ecb5befb7d6521818eddbc.jpg" + }, + { + "id": 140581, + "filter": "actor=140581", + "tag": "Alex Livinalli", + "tagKey": "5d7768a507c4a5001e67ac21", + "role": "Attuma", + "thumb": "https://metadata-static.plex.tv/people/5d7768a507c4a5001e67ac21.jpg" + }, + { + "id": 140582, + "filter": "actor=140582", + "tag": "Robert John Burke", + "tagKey": "5d77682d8718ba001e3131ac", + "role": "Smitty", + "thumb": "https://metadata-static.plex.tv/8/people/81a06f9ee23dd8bc19110a33b9e21d76.jpg" + }, + { + "id": 112068, + "filter": "actor=112068", + "tag": "Danny Sapani", + "tagKey": "5d7768397228e5001f1df331", + "role": "Border Tribe Elder", + "thumb": "https://metadata-static.plex.tv/people/5d7768397228e5001f1df331.jpg" + }, + { + "id": 112271, + "filter": "actor=112271", + "tag": "Isaach De Bankolé", + "tagKey": "5d77682485719b001f3a04e1", + "role": "River Tribe Elder", + "thumb": "https://metadata-static.plex.tv/2/people/2185ff1eaea20f2a34a4544a62be5ea7.jpg" + }, + { + "id": 112272, + "filter": "actor=112272", + "tag": "Connie Chiume", + "tagKey": "5d7768472e80df001ebe09e1", + "role": "Zawavari", + "thumb": "https://metadata-static.plex.tv/people/5d7768472e80df001ebe09e1.jpg" + }, + { + "id": 94250, + "filter": "actor=94250", + "tag": "Martin Freeman", + "tagKey": "5d776826961905001eb9111d", + "role": "Everett Ross", + "thumb": "https://metadata-static.plex.tv/5/people/51899e85031bd16b71bf6e33fa20cda0.jpg" + }, + { + "id": 116688, + "filter": "actor=116688", + "tag": "Julia Louis-Dreyfus", + "tagKey": "5d7768275af944001f1f6ec8", + "role": "Valentina Allegra de Fontaine", + "thumb": "https://metadata-static.plex.tv/4/people/4876e6724400778eff550417cf336045.jpg" + }, + { + "id": 95382, + "filter": "actor=95382", + "tag": "Richard Schiff", + "tagKey": "5d7768263c3c2a001fbcadd6", + "role": "U.S. Secretary of State", + "thumb": "https://metadata-static.plex.tv/6/people/68b16270a9766b8d1c776425bebd785f.jpg" + }, + { + "id": 109098, + "filter": "actor=109098", + "tag": "Michael B. Jordan", + "tagKey": "5d7768823ab0e7001f5033c4", + "role": "N'Jadaka / Erik 'Killmonger' Stevens", + "thumb": "https://metadata-static.plex.tv/8/people/855634fdbe74c41a32b4d0b305d09c18.jpg" + }, + { + "id": 127988, + "filter": "actor=127988", + "tag": "Dorothy Steel", + "tagKey": "5d776b05fb0d55001f5592d7", + "role": "Merchant Tribe Elder", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d7.jpg" + }, + { + "id": 140583, + "filter": "actor=140583", + "tag": "Zainab Jah", + "tagKey": "5d77684c0ea56a001e2a2aa5", + "role": "Mining Tribe Elder", + "thumb": "https://metadata-static.plex.tv/4/people/4124460d703e38bb134922737e79053e.jpg" + }, + { + "id": 112280, + "filter": "actor=112280", + "tag": "Sope Aluko", + "tagKey": "5d77692623d5a3001f4f6434", + "role": "Sope the Shaman", + "thumb": "https://metadata-static.plex.tv/people/5d77692623d5a3001f4f6434.jpg" + }, + { + "id": 112290, + "filter": "actor=112290", + "tag": "Trevor Noah", + "tagKey": "5d77687eeb5d26001f1edd7e", + "role": "Griot (voice)", + "thumb": "https://metadata-static.plex.tv/people/5d77687eeb5d26001f1edd7e.jpg" + }, + { + "id": 91804, + "filter": "actor=91804", + "tag": "Shawn Roberts", + "tagKey": "5d77682b61141d001fb13e9f", + "role": "WDG Scientist", + "thumb": "https://metadata-static.plex.tv/people/5d77682b61141d001fb13e9f.jpg" + }, + { + "id": 109262, + "filter": "actor=109262", + "tag": "Zola Williams", + "tagKey": "5d776b05fb0d55001f5592d9", + "role": "Zola", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d9.jpg" + }, + { + "id": 112276, + "filter": "actor=112276", + "tag": "Janeshia Adams-Ginyard", + "tagKey": "5d776885fb0d55001f512c08", + "role": "Nomble", + "thumb": "https://metadata-static.plex.tv/people/5d776885fb0d55001f512c08.jpg" + }, + { + "id": 140584, + "filter": "actor=140584", + "tag": "Jemini Powell", + "tagKey": "6370f552136ea85697900668", + "role": "Jemini" + }, + { + "id": 112275, + "filter": "actor=112275", + "tag": "Marija Abney", + "tagKey": "5d776b05fb0d55001f5592d8", + "role": "Dora Milaje", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d8.jpg" + }, + { + "id": 114796, + "filter": "actor=114796", + "tag": "Keisha Tucker", + "tagKey": "5e4fd461a09d3e0037012ea8", + "role": "Dora Milaje", + "thumb": "https://metadata-static.plex.tv/2/people/24c05fc4e6f7a23a75db42db842b9765.jpg" + }, + { + "id": 140585, + "filter": "actor=140585", + "tag": "Ivy Haralson", + "tagKey": "61698da83ccba719f3f2e3c1", + "role": "Dora Milaje" + }, + { + "id": 140586, + "filter": "actor=140586", + "tag": "Maya Macatumpag", + "tagKey": "5f1c7db4cc93a100401e972d", + "role": "Dora Milaje" + }, + { + "id": 140587, + "filter": "actor=140587", + "tag": "Baaba Maal", + "tagKey": "5d77689e7a53e9001e6d4337", + "role": "Funeral Singer", + "thumb": "https://metadata-static.plex.tv/2/people/27913b88f8663602f5b016441c197741.jpg" + }, + { + "id": 140588, + "filter": "actor=140588", + "tag": "Jabari Exum", + "tagKey": "6370f552136ea85697900666", + "role": "Drummer / Naval Guard" + }, + { + "id": 140589, + "filter": "actor=140589", + "tag": "Massamba Diop", + "tagKey": "6370f552136ea85697900667", + "role": "Drummer" + }, + { + "id": 140590, + "filter": "actor=140590", + "tag": "Magatte Saw", + "tagKey": "604e374133732c002c9a65ce", + "role": "Drummer" + }, + { + "id": 140591, + "filter": "actor=140591", + "tag": "Gerardo Aldana", + "tagKey": "5d776e62594b2b001e72235a", + "role": "Assembly Chairperson" + }, + { + "id": 140592, + "filter": "actor=140592", + "tag": "Gigi Bermingham", + "tagKey": "5d7768412ec6b5001f6be380", + "role": "French Secretary of State", + "thumb": "https://metadata-static.plex.tv/e/people/e7c50ee53ccea55a9dd63c7c28bdbad4.jpg" + }, + { + "id": 140593, + "filter": "actor=140593", + "tag": "Rudolph Massanga", + "tagKey": "6370f552136ea8569790066a", + "role": "Young Mali Technician" + }, + { + "id": 108651, + "filter": "actor=108651", + "tag": "Judd Wild", + "tagKey": "5d7768baad5437001f74e684", + "role": "Jackson", + "thumb": "https://metadata-static.plex.tv/7/people/7c144923d23b134a1593269e36aa4d5f.jpg" + }, + { + "id": 140594, + "filter": "actor=140594", + "tag": "Amber Harrington", + "tagKey": "616ade1de8e432810e6581f3", + "role": "Rita Salazar" + }, + { + "id": 140595, + "filter": "actor=140595", + "tag": "Michael Blake Kruse", + "tagKey": "5d776f1b7a53e9001e78b813", + "role": "Henderson" + }, + { + "id": 140596, + "filter": "actor=140596", + "tag": "Justin James Boykin", + "tagKey": "5d776d1f96b655001fe4033f", + "role": "Cargo Ship Helo Pilot", + "thumb": "https://metadata-static.plex.tv/6/people/6a7cd7ae8f13700f87f1fa7a7be919eb.jpg" + }, + { + "id": 108521, + "filter": "actor=108521", + "tag": "Anderson Cooper", + "tagKey": "5d77683aeb5d26001f1e1e05", + "role": "Anderson Cooper", + "thumb": "https://metadata-static.plex.tv/people/5d77683aeb5d26001f1e1e05.jpg" + }, + { + "id": 140597, + "filter": "actor=140597", + "tag": "Mackenro Alexander", + "tagKey": "5f3fc3333e5306003e55036c", + "role": "River Barrier Naval Guard" + }, + { + "id": 140598, + "filter": "actor=140598", + "tag": "Kamaru Usman", + "tagKey": "5f4027e804a86500409fd230", + "role": "Naval Officer", + "thumb": "https://metadata-static.plex.tv/0/people/093884ef6b126272f1c56bb11c8f8f7c.jpg" + }, + { + "id": 123546, + "filter": "actor=123546", + "tag": "T. Love", + "tagKey": "5d776b05fb0d55001f5592ec", + "role": "M'Bele", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592ec.jpg" + }, + { + "id": 117708, + "filter": "actor=117708", + "tag": "Floyd Anthony Johns Jr.", + "tagKey": "5d776b53ad5437001f79b9b6", + "role": "Jabari Warrior", + "thumb": "https://metadata-static.plex.tv/people/5d776b53ad5437001f79b9b6.jpg" + }, + { + "id": 140599, + "filter": "actor=140599", + "tag": "Jermaine Brantley", + "tagKey": "5e1649d661c6140040d7fc92", + "role": "Jabari Warrior", + "thumb": "https://metadata-static.plex.tv/people/5e1649d661c6140040d7fc92.jpg" + }, + { + "id": 140600, + "filter": "actor=140600", + "tag": "Granger Summerset II", + "tagKey": "5f406c5786422500428c4fd4", + "role": "Jabari Warrior" + }, + { + "id": 140601, + "filter": "actor=140601", + "tag": "Luke Lenza", + "tagKey": "6370f552136ea8569790066d", + "role": "MIT Student" + }, + { + "id": 140602, + "filter": "actor=140602", + "tag": "Alan Wells", + "tagKey": "5d776838103a2d001f5687ac", + "role": "Federal Agent", + "thumb": "https://metadata-static.plex.tv/people/5d776838103a2d001f5687ac.jpg" + }, + { + "id": 140603, + "filter": "actor=140603", + "tag": "Bill Barrett", + "tagKey": "6083d977444a60002da33701", + "role": "FBI Special Agent" + }, + { + "id": 140604, + "filter": "actor=140604", + "tag": "Lieiry J. Perez Escalera", + "tagKey": "6370f552136ea8569790066e", + "role": "Haitian School Kid" + }, + { + "id": 140605, + "filter": "actor=140605", + "tag": "Sevyn Hill", + "tagKey": "6370f552136ea8569790066f", + "role": "Haitian School Kid" + }, + { + "id": 140606, + "filter": "actor=140606", + "tag": "Gavin Macon", + "tagKey": "6370f552136ea85697900670", + "role": "Haitian School Kid" + }, + { + "id": 140607, + "filter": "actor=140607", + "tag": "Skylar Ebron", + "tagKey": "6370f552136ea85697900671", + "role": "Haitian School Kid" + }, + { + "id": 140608, + "filter": "actor=140608", + "tag": "Taylor Holmes", + "tagKey": "6370f552136ea85697900672", + "role": "Haitian School Kid" + }, + { + "id": 140609, + "filter": "actor=140609", + "tag": "Angela Cipra", + "tagKey": "6370f552136ea85697900673", + "role": "Talokanil Guard" + }, + { + "id": 140610, + "filter": "actor=140610", + "tag": "Faya Madrid", + "tagKey": "6370f552136ea85697900674", + "role": "Talokanil Guard" + }, + { + "id": 140611, + "filter": "actor=140611", + "tag": "María Telón", + "tagKey": "5d77698c7a53e9001e6e936c", + "role": "Female Mayan Elder", + "thumb": "https://image.tmdb.org/t/p/original/tlfwcpVCjSdYYEvLiiVe1jFAuhP.jpg" + }, + { + "id": 140612, + "filter": "actor=140612", + "tag": "María Mercedes Coroy", + "tagKey": "5d776b69594b2b001e6d947e", + "role": "Namor's Mother", + "thumb": "https://metadata-static.plex.tv/people/5d776b69594b2b001e6d947e.jpg" + }, + { + "id": 140613, + "filter": "actor=140613", + "tag": "Josué Maychi", + "tagKey": "5f3fbf3f1ae7100041fc8314", + "role": "Shaman", + "thumb": "https://metadata-static.plex.tv/2/people/28f970559b110170f1ad2e8cc1230453.jpg" + }, + { + "id": 96885, + "filter": "actor=96885", + "tag": "Sal Lopez", + "tagKey": "5d776827880197001ec90ae3", + "role": "Yucatan Elder", + "thumb": "https://metadata-static.plex.tv/8/people/802548390b27f594650892dfe554a135.jpg" + }, + { + "id": 140614, + "filter": "actor=140614", + "tag": "Irma Estella La Guerre", + "tagKey": "5d77707631d95e001f1a2193", + "role": "Namor's Mother (Older)", + "thumb": "https://metadata-static.plex.tv/0/people/09587182629b809b1054232b36f34035.jpg" + }, + { + "id": 140615, + "filter": "actor=140615", + "tag": "Manuel Chavez", + "tagKey": "62ff4580b2cc0a7ab1f18d4f", + "role": "Young Namor", + "thumb": "https://metadata-static.plex.tv/4/people/4dcea4d82e1a43bed4278eaab585ded8.jpg" + }, + { + "id": 140616, + "filter": "actor=140616", + "tag": "Leonardo Castro", + "tagKey": "619cf7ff4b44ca915078a945", + "role": "Hacienda Owner" + }, + { + "id": 123615, + "filter": "actor=123615", + "tag": "Juan Carlos Cantu", + "tagKey": "5d77683c6f4521001ea9d503", + "role": "Friar", + "thumb": "https://metadata-static.plex.tv/people/5d77683c6f4521001ea9d503.jpg" + }, + { + "id": 109021, + "filter": "actor=109021", + "tag": "Shawntae Hughes", + "tagKey": "5e69c6d60fdbbd003de628b7", + "role": "Fisherman", + "thumb": "https://metadata-static.plex.tv/people/5e69c6d60fdbbd003de628b7.jpg" + }, + { + "id": 140617, + "filter": "actor=140617", + "tag": "Corey Hibbert", + "tagKey": "5f3fe5a2bf3e560040b2fb56", + "role": "Terrified Man", + "thumb": "https://metadata-static.plex.tv/0/people/0e8b53f991c43876078e150491d1b4db.jpg" + }, + { + "id": 140618, + "filter": "actor=140618", + "tag": "Zaiden James", + "tagKey": "6370f552136ea85697900675", + "role": "Wakandan Kid" + }, + { + "id": 140619, + "filter": "actor=140619", + "tag": "Aba Arthur", + "tagKey": "5d77688c9ab54400214e78fc", + "role": "Naval Engineer" + }, + { + "id": 140620, + "filter": "actor=140620", + "tag": "Délé Ogundiran", + "tagKey": "5d77683554f42c001f8c463e", + "role": "Flower Shop Owner", + "thumb": "https://metadata-static.plex.tv/3/people/33e9eea633f784e47144ceb2602f7c19.jpg" + }, + { + "id": 140621, + "filter": "actor=140621", + "tag": "Kevin Changaris", + "tagKey": "5e1653d310faa500400f8eaa", + "role": "Pete", + "thumb": "https://metadata-static.plex.tv/d/people/d25013711d628aea3f0b29ebd2b0a5c3.jpg" + }, + { + "id": 140622, + "filter": "actor=140622", + "tag": "Valerio Dorvillen", + "tagKey": "6370f552136ea85697900676", + "role": "Haitian Taxi Passenger" + }, + { + "id": 140623, + "filter": "actor=140623", + "tag": "Don Castor", + "tagKey": "6370f552136ea85697900677", + "role": "Haitian Taxi Passenger" + }, + { + "id": 140624, + "filter": "actor=140624", + "tag": "Jonathan González Collins", + "tagKey": "6370f552136ea85697900678", + "role": "Haitian Taxi Passenger" + }, + { + "id": 140625, + "filter": "actor=140625", + "tag": "Divine Love Konadu-Sun", + "tagKey": "6370f552136ea85697900679", + "role": "Toussaint", + "thumb": "https://metadata-static.plex.tv/2/people/2a5a9c96ba51088f862e6cfe23509353.jpg" + }, + { + "id": 110217, + "filter": "actor=110217", + "tag": "Chadwick Boseman", + "tagKey": "5d77690996b655001fdc8c8f", + "role": "T'Challa / Black Panther (archive footage) (uncredited)", + "thumb": "https://metadata-static.plex.tv/d/people/d12e4d776c045ce4c8cba456a44e6fb3.jpg" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/crates/plex-api/tests/mocks/transcode/music_mp3.json b/crates/plex-api/tests/mocks/transcode/music_mp3.json new file mode 100644 index 00000000..89492a3f --- /dev/null +++ b/crates/plex-api/tests/mocks/transcode/music_mp3.json @@ -0,0 +1,153 @@ +{ + "MediaContainer": { + "size": 1, + "allowSync": "1", + "directPlayDecisionCode": 3000, + "directPlayDecisionText": "App cannot direct play this item. Direct play is disabled.", + "generalDecisionCode": 1001, + "generalDecisionText": "Direct play not available; Conversion OK.", + "identifier": "com.plexapp.plugins.library", + "librarySectionID": "33", + "librarySectionTitle": "Dave's Music", + "librarySectionUUID": "5da09f65-108f-470b-a8c1-bc5961da07c5", + "mediaTagPrefix": "/system/bundle/media/flags/", + "mediaTagVersion": "1676975406", + "resourceSession": "{fa5429a4-98ed-4ae6-b140-d5e99a870938}", + "transcodeDecisionCode": 1001, + "transcodeDecisionText": "Direct play not available; Conversion OK.", + "Metadata": [ + { + "addedAt": 1360195318, + "art": "/library/metadata/157717/art/1677122331", + "duration": 320888, + "grandparentArt": "/library/metadata/157717/art/1677122331", + "grandparentGuid": "plex://artist/5d07bbfc403c6402904a60d1", + "grandparentKey": "/library/metadata/157717", + "grandparentRatingKey": "157717", + "grandparentThumb": "/library/metadata/157717/thumb/1677122331", + "grandparentTitle": "Daft Punk", + "guid": "plex://track/5d07cdc4403c640290f653c2", + "index": 1, + "key": "/library/metadata/157786", + "librarySectionID": "33", + "librarySectionKey": "/library/sections/33", + "librarySectionTitle": "Dave's Music", + "musicAnalysisVersion": "1", + "parentGuid": "plex://album/5d07c185403c64029084f920", + "parentIndex": 1, + "parentKey": "/library/metadata/157785", + "parentRatingKey": "157785", + "parentStudio": "Virgin", + "parentThumb": "/library/metadata/157785/thumb/1675091155", + "parentTitle": "Discovery", + "parentYear": 2001, + "ratingCount": 1549072, + "ratingKey": "157786", + "thumb": "/library/metadata/157785/thumb/1675091155", + "title": "One More Time", + "type": "track", + "updatedAt": 1675091155, + "Media": [ + { + "id": "305587", + "audioChannels": 2, + "audioCodec": "mp3", + "bitrate": 182, + "container": "mp4", + "duration": 320888, + "protocol": "dash", + "selected": true, + "Part": [ + { + "deepAnalysisVersion": "6", + "hasThumbnail": "1", + "id": "318704", + "requiredBandwidths": "316,316,316,316,316,316,316,316", + "bitrate": 182, + "container": "mp4", + "duration": 320888, + "decision": "transcode", + "selected": true, + "Stream": [ + { + "albumGain": "-6.89", + "albumPeak": "1.000000", + "albumRange": "8.604516", + "bitrate": 182, + "bitrateMode": "vbr", + "channels": 2, + "codec": "mp3", + "displayTitle": "MP3 (Stereo)", + "extendedDisplayTitle": "MP3 (Stereo)", + "gain": "-6.89", + "id": "560564", + "loudness": "-12.23", + "lra": "6.73", + "peak": "1.000000", + "requiredBandwidths": "316,316,316,316,316,316,316,316", + "selected": true, + "streamType": 2, + "decision": "transcode", + "location": "segments-audio" + } + ] + } + ] + } + ], + "Mood": [ + { + "filter": "mood=133027", + "id": "133027", + "tag": "Energetic" + }, + { + "filter": "mood=132779", + "id": "132779", + "tag": "Bright" + }, + { + "filter": "mood=132778", + "id": "132778", + "tag": "Freewheeling" + }, + { + "filter": "mood=133013", + "id": "133013", + "tag": "Fun" + }, + { + "filter": "mood=132785", + "id": "132785", + "tag": "Rousing" + }, + { + "filter": "mood=132970", + "id": "132970", + "tag": "Celebratory" + }, + { + "filter": "mood=133148", + "id": "133148", + "tag": "Carefree" + }, + { + "filter": "mood=132732", + "id": "132732", + "tag": "Stylish" + }, + { + "filter": "mood=132887", + "id": "132887", + "tag": "Trippy" + }, + { + "filter": "mood=133130", + "id": "133130", + "tag": "Hypnotic" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/crates/plex-api/tests/mocks/transcode/music_sessions.json b/crates/plex-api/tests/mocks/transcode/music_sessions.json new file mode 100644 index 00000000..b213791e --- /dev/null +++ b/crates/plex-api/tests/mocks/transcode/music_sessions.json @@ -0,0 +1,26 @@ +{ + "MediaContainer": { + "size": 1, + "TranscodeSession": [ + { + "key": "dfghtybntbretybrtyb", + "throttled": false, + "complete": false, + "progress": 2.5999999046325685, + "size": 33554480, + "speed": 1.2000000476837159, + "error": false, + "duration": 9678688, + "remaining": 8104, + "context": "streaming", + "sourceAudioCodec": "mp3", + "audioDecision": "copy", + "protocol": "dash", + "container": "mp4", + "audioCodec": "mp3", + "audioChannels": 2, + "transcodeHwRequested": true + } + ] + } +} \ No newline at end of file diff --git a/crates/plex-api/tests/mocks/transcode/video_dash_h264_mp3.json b/crates/plex-api/tests/mocks/transcode/video_dash_h264_mp3.json new file mode 100644 index 00000000..95554dd9 --- /dev/null +++ b/crates/plex-api/tests/mocks/transcode/video_dash_h264_mp3.json @@ -0,0 +1,857 @@ +{ + "MediaContainer": { + "size": 1, + "allowSync": "1", + "directPlayDecisionCode": 3000, + "directPlayDecisionText": "App cannot direct play this item. Direct play is disabled.", + "generalDecisionCode": 1001, + "generalDecisionText": "Direct play not available; Conversion OK.", + "identifier": "com.plexapp.plugins.library", + "librarySectionID": "1", + "librarySectionTitle": "Movies", + "librarySectionUUID": "a006b58966aa34f3c577ca3106e99c5d1d6ea8b1", + "mediaTagPrefix": "/system/bundle/media/flags/", + "mediaTagVersion": "1676975406", + "resourceSession": "{fa5429a4-98ed-4ae6-b140-d5e99a870938}", + "transcodeDecisionCode": 1001, + "transcodeDecisionText": "Direct play not available; Conversion OK.", + "Metadata": [ + { + "addedAt": 1675330657, + "art": "/library/metadata/159637/art/1675330665", + "audienceRating": 9.4, + "audienceRatingImage": "rottentomatoes://image.rating.upright", + "chapterSource": "media", + "contentRating": "gb/12A", + "duration": 9678688, + "guid": "plex://movie/5d77702e6afb3d0020613fd1", + "key": "/library/metadata/159637", + "lastViewedAt": 1677362803, + "librarySectionID": "1", + "librarySectionKey": "/library/sections/1", + "librarySectionTitle": "Movies", + "originallyAvailableAt": "2022-11-09", + "primaryExtraKey": "/library/metadata/159638", + "rating": 8.4, + "ratingImage": "rottentomatoes://image.rating.ripe", + "ratingKey": "159637", + "studio": "Marvel Studios", + "summary": "Queen Ramonda, Shuri, M'Baku, Okoye and the Dora Milaje fight to protect the kingdom of Wakanda from intervening world powers in the wake of King T'Challa's death. As the Wakandans strive to embrace their next chapter, the heroes must band together with the help of War Dog Nakia and Everett Ross and forge a new path for their nation.", + "tagline": "Forever.", + "thumb": "/library/metadata/159637/thumb/1675330665", + "title": "Black Panther: Wakanda Forever", + "type": "movie", + "updatedAt": 1675330665, + "viewCount": 1, + "year": 2022, + "Media": [ + { + "id": "307380", + "videoProfile": "main 10", + "audioChannels": 2, + "audioCodec": "mp3", + "bitrate": 1903, + "container": "mp4", + "duration": 9678688, + "height": 302, + "optimizedForStreaming": true, + "protocol": "dash", + "videoCodec": "h264", + "videoFrameRate": "24p", + "videoResolution": "SD", + "width": 720, + "selected": true, + "Part": [ + { + "deepAnalysisVersion": "6", + "hasThumbnail": "1", + "id": "320497", + "indexes": "sd", + "requiredBandwidths": "53608,45732,31113,27983,26375,24767,18633,12057", + "videoProfile": "main 10", + "bitrate": 1903, + "container": "mp4", + "duration": 9678688, + "height": 302, + "optimizedForStreaming": true, + "protocol": "dash", + "width": 720, + "decision": "transcode", + "selected": true, + "Stream": [ + { + "bitrate": 1697, + "codec": "h264", + "default": true, + "displayTitle": "4K HDR10 (HEVC Main 10)", + "extendedDisplayTitle": "4K HDR10 (HEVC Main 10)", + "frameRate": 23.97599983215332, + "height": 302, + "id": "566075", + "requiredBandwidths": "52842,44966,30344,27215,25607,23999,17864,11289", + "streamType": 1, + "width": 720, + "decision": "transcode", + "location": "segments-video" + }, + { + "bitrate": 206, + "bitrateMode": "vbr", + "channels": 2, + "codec": "mp3", + "default": true, + "displayTitle": "English (EAC3 5.1)", + "extendedDisplayTitle": "English DDP Atmos 5.1 (EAC3)", + "id": "566076", + "language": "English", + "languageCode": "eng", + "languageTag": "en", + "requiredBandwidths": "768,768,768,768,768,768,768,768", + "selected": true, + "streamType": 2, + "decision": "transcode", + "location": "segments-audio" + }, + { + "bitrate": 0, + "burn": "1", + "codec": "srt", + "default": true, + "displayTitle": "English (SRT)", + "extendedDisplayTitle": "English SRT", + "id": "566077", + "language": "English", + "languageCode": "eng", + "languageTag": "en", + "requiredBandwidths": "1,1,1,1,1,1,1,1", + "selected": true, + "streamType": 3, + "title": "English SRT", + "decision": "burn", + "location": "segments-video" + } + ] + } + ] + } + ], + "Genre": [ + { + "filter": "genre=39", + "id": "39", + "tag": "Action" + }, + { + "filter": "genre=130", + "id": "130", + "tag": "Adventure" + }, + { + "filter": "genre=132", + "id": "132", + "tag": "Science Fiction" + }, + { + "filter": "genre=93", + "id": "93", + "tag": "Drama" + }, + { + "filter": "genre=128", + "id": "128", + "tag": "Thriller" + }, + { + "filter": "genre=48", + "id": "48", + "tag": "Fantasy" + } + ], + "Director": [ + { + "filter": "director=109867", + "id": "109867", + "tag": "Ryan Coogler" + } + ], + "Writer": [ + { + "filter": "writer=99532", + "id": "99532", + "tag": "Stan Lee" + }, + { + "filter": "writer=92467", + "id": "92467", + "tag": "Jack Kirby" + }, + { + "filter": "writer=109868", + "id": "109868", + "tag": "Ryan Coogler" + }, + { + "filter": "writer=112263", + "id": "112263", + "tag": "Joe Robert Cole" + } + ], + "Producer": [ + { + "filter": "producer=89536", + "id": "89536", + "tag": "Kevin Feige" + }, + { + "filter": "producer=92508", + "id": "92508", + "tag": "Nate Moore" + } + ], + "Country": [ + { + "filter": "country=55636", + "id": "55636", + "tag": "United States of America" + } + ], + "Rating": [ + { + "image": "imdb://image.rating", + "type": "audience", + "value": "7.2" + }, + { + "image": "rottentomatoes://image.rating.ripe", + "type": "critic", + "value": "8.4" + }, + { + "image": "rottentomatoes://image.rating.upright", + "type": "audience", + "value": "9.4" + }, + { + "image": "themoviedb://image.rating", + "type": "audience", + "value": "7.5" + } + ], + "Collection": [ + { + "filter": "collection=46656", + "id": "46656", + "tag": "Marvel" + } + ], + "Role": [ + { + "filter": "actor=112266", + "id": "112266", + "role": "Shuri", + "tag": "Letitia Wright", + "tagKey": "5d77698896b655001fdd14d1", + "thumb": "https://metadata-static.plex.tv/9/people/95bd7f16f95577ccfae11f60e4995edb.jpg" + }, + { + "filter": "actor=104353", + "id": "104353", + "role": "Nakia", + "tag": "Lupita Nyong'o", + "tagKey": "5d7768ba0ea56a001e2a972f", + "thumb": "https://metadata-static.plex.tv/4/people/47ca5ee0d2b76822f10572edaea0195d.jpg" + }, + { + "filter": "actor=112265", + "id": "112265", + "role": "Okoye", + "tag": "Danai Gurira", + "tagKey": "5d776839f54112001f5bddf9", + "thumb": "https://metadata-static.plex.tv/1/people/1ac9c5f4b757cd615eb6734b2909c74e.jpg" + }, + { + "filter": "actor=112267", + "id": "112267", + "role": "M'Baku", + "tag": "Winston Duke", + "tagKey": "5d776b05fb0d55001f5592d5", + "thumb": "https://metadata-static.plex.tv/8/people/8803800d4ee7bcb73052932af60d3f5d.jpg" + }, + { + "filter": "actor=140579", + "id": "140579", + "role": "Riri Williams / Ironheart", + "tag": "Dominique Thorne", + "tagKey": "5d776d3a7a53e9001e754ddd", + "thumb": "https://metadata-static.plex.tv/b/people/bd298c6f1a8fcca0e1fd65dff210e6e5.jpg" + }, + { + "filter": "actor=131467", + "id": "131467", + "role": "Namor", + "tag": "Tenoch Huerta Mejía", + "tagKey": "5d7768468718ba001e317d8d", + "thumb": "https://metadata-static.plex.tv/2/people/2cb6d643da8a1de3a8901edbd3feb97a.jpg" + }, + { + "filter": "actor=106584", + "id": "106584", + "role": "Ramonda", + "tag": "Angela Bassett", + "tagKey": "5d7768267e9a3c0020c6a9ec", + "thumb": "https://metadata-static.plex.tv/7/people/75c2642f58f0bf47de1865633a4f309f.jpg" + }, + { + "filter": "actor=110223", + "id": "110223", + "role": "Ayo", + "tag": "Florence Kasumba", + "tagKey": "5d77683e7e9a3c0020c6e8e5", + "thumb": "https://metadata-static.plex.tv/people/5d77683e7e9a3c0020c6e8e5.jpg" + }, + { + "filter": "actor=112043", + "id": "112043", + "role": "Aneka", + "tag": "Michaela Coel", + "tagKey": "5d7769b396b655001fdd6fe9", + "thumb": "https://metadata-static.plex.tv/c/people/cc2c7c20d21eb0832c5d03d02fecffdc.jpg" + }, + { + "filter": "actor=140580", + "id": "140580", + "role": "Namora", + "tag": "Mabel Cadena", + "tagKey": "5e16515b27d563003ed660d3", + "thumb": "https://metadata-static.plex.tv/b/people/b97e4f92db01516849788fe1b866e1cb.jpg" + }, + { + "filter": "actor=113112", + "id": "113112", + "role": "Dr. Graham", + "tag": "Lake Bell", + "tagKey": "5d776832151a60001f24d339", + "thumb": "https://metadata-static.plex.tv/b/people/b158320c71ecb5befb7d6521818eddbc.jpg" + }, + { + "filter": "actor=140581", + "id": "140581", + "role": "Attuma", + "tag": "Alex Livinalli", + "tagKey": "5d7768a507c4a5001e67ac21", + "thumb": "https://metadata-static.plex.tv/people/5d7768a507c4a5001e67ac21.jpg" + }, + { + "filter": "actor=140582", + "id": "140582", + "role": "Smitty", + "tag": "Robert John Burke", + "tagKey": "5d77682d8718ba001e3131ac", + "thumb": "https://metadata-static.plex.tv/8/people/81a06f9ee23dd8bc19110a33b9e21d76.jpg" + }, + { + "filter": "actor=112068", + "id": "112068", + "role": "Border Tribe Elder", + "tag": "Danny Sapani", + "tagKey": "5d7768397228e5001f1df331", + "thumb": "https://metadata-static.plex.tv/people/5d7768397228e5001f1df331.jpg" + }, + { + "filter": "actor=112271", + "id": "112271", + "role": "River Tribe Elder", + "tag": "Isaach De Bankolé", + "tagKey": "5d77682485719b001f3a04e1", + "thumb": "https://metadata-static.plex.tv/2/people/2185ff1eaea20f2a34a4544a62be5ea7.jpg" + }, + { + "filter": "actor=112272", + "id": "112272", + "role": "Zawavari", + "tag": "Connie Chiume", + "tagKey": "5d7768472e80df001ebe09e1", + "thumb": "https://metadata-static.plex.tv/people/5d7768472e80df001ebe09e1.jpg" + }, + { + "filter": "actor=94250", + "id": "94250", + "role": "Everett Ross", + "tag": "Martin Freeman", + "tagKey": "5d776826961905001eb9111d", + "thumb": "https://metadata-static.plex.tv/5/people/51899e85031bd16b71bf6e33fa20cda0.jpg" + }, + { + "filter": "actor=116688", + "id": "116688", + "role": "Valentina Allegra de Fontaine", + "tag": "Julia Louis-Dreyfus", + "tagKey": "5d7768275af944001f1f6ec8", + "thumb": "https://metadata-static.plex.tv/4/people/4876e6724400778eff550417cf336045.jpg" + }, + { + "filter": "actor=95382", + "id": "95382", + "role": "U.S. Secretary of State", + "tag": "Richard Schiff", + "tagKey": "5d7768263c3c2a001fbcadd6", + "thumb": "https://metadata-static.plex.tv/6/people/68b16270a9766b8d1c776425bebd785f.jpg" + }, + { + "filter": "actor=109098", + "id": "109098", + "role": "N'Jadaka / Erik 'Killmonger' Stevens", + "tag": "Michael B. Jordan", + "tagKey": "5d7768823ab0e7001f5033c4", + "thumb": "https://metadata-static.plex.tv/8/people/855634fdbe74c41a32b4d0b305d09c18.jpg" + }, + { + "filter": "actor=127988", + "id": "127988", + "role": "Merchant Tribe Elder", + "tag": "Dorothy Steel", + "tagKey": "5d776b05fb0d55001f5592d7", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d7.jpg" + }, + { + "filter": "actor=140583", + "id": "140583", + "role": "Mining Tribe Elder", + "tag": "Zainab Jah", + "tagKey": "5d77684c0ea56a001e2a2aa5", + "thumb": "https://metadata-static.plex.tv/4/people/4124460d703e38bb134922737e79053e.jpg" + }, + { + "filter": "actor=112280", + "id": "112280", + "role": "Sope the Shaman", + "tag": "Sope Aluko", + "tagKey": "5d77692623d5a3001f4f6434", + "thumb": "https://metadata-static.plex.tv/people/5d77692623d5a3001f4f6434.jpg" + }, + { + "filter": "actor=112290", + "id": "112290", + "role": "Griot (voice)", + "tag": "Trevor Noah", + "tagKey": "5d77687eeb5d26001f1edd7e", + "thumb": "https://metadata-static.plex.tv/people/5d77687eeb5d26001f1edd7e.jpg" + }, + { + "filter": "actor=91804", + "id": "91804", + "role": "WDG Scientist", + "tag": "Shawn Roberts", + "tagKey": "5d77682b61141d001fb13e9f", + "thumb": "https://metadata-static.plex.tv/people/5d77682b61141d001fb13e9f.jpg" + }, + { + "filter": "actor=109262", + "id": "109262", + "role": "Zola", + "tag": "Zola Williams", + "tagKey": "5d776b05fb0d55001f5592d9", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d9.jpg" + }, + { + "filter": "actor=112276", + "id": "112276", + "role": "Nomble", + "tag": "Janeshia Adams-Ginyard", + "tagKey": "5d776885fb0d55001f512c08", + "thumb": "https://metadata-static.plex.tv/people/5d776885fb0d55001f512c08.jpg" + }, + { + "filter": "actor=140584", + "id": "140584", + "role": "Jemini", + "tag": "Jemini Powell", + "tagKey": "6370f552136ea85697900668" + }, + { + "filter": "actor=112275", + "id": "112275", + "role": "Dora Milaje", + "tag": "Marija Abney", + "tagKey": "5d776b05fb0d55001f5592d8", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d8.jpg" + }, + { + "filter": "actor=114796", + "id": "114796", + "role": "Dora Milaje", + "tag": "Keisha Tucker", + "tagKey": "5e4fd461a09d3e0037012ea8", + "thumb": "https://metadata-static.plex.tv/2/people/24c05fc4e6f7a23a75db42db842b9765.jpg" + }, + { + "filter": "actor=140585", + "id": "140585", + "role": "Dora Milaje", + "tag": "Ivy Haralson", + "tagKey": "61698da83ccba719f3f2e3c1" + }, + { + "filter": "actor=140586", + "id": "140586", + "role": "Dora Milaje", + "tag": "Maya Macatumpag", + "tagKey": "5f1c7db4cc93a100401e972d" + }, + { + "filter": "actor=140587", + "id": "140587", + "role": "Funeral Singer", + "tag": "Baaba Maal", + "tagKey": "5d77689e7a53e9001e6d4337", + "thumb": "https://metadata-static.plex.tv/2/people/27913b88f8663602f5b016441c197741.jpg" + }, + { + "filter": "actor=140588", + "id": "140588", + "role": "Drummer / Naval Guard", + "tag": "Jabari Exum", + "tagKey": "6370f552136ea85697900666" + }, + { + "filter": "actor=140589", + "id": "140589", + "role": "Drummer", + "tag": "Massamba Diop", + "tagKey": "6370f552136ea85697900667" + }, + { + "filter": "actor=140590", + "id": "140590", + "role": "Drummer", + "tag": "Magatte Saw", + "tagKey": "604e374133732c002c9a65ce" + }, + { + "filter": "actor=140591", + "id": "140591", + "role": "Assembly Chairperson", + "tag": "Gerardo Aldana", + "tagKey": "5d776e62594b2b001e72235a" + }, + { + "filter": "actor=140592", + "id": "140592", + "role": "French Secretary of State", + "tag": "Gigi Bermingham", + "tagKey": "5d7768412ec6b5001f6be380", + "thumb": "https://metadata-static.plex.tv/e/people/e7c50ee53ccea55a9dd63c7c28bdbad4.jpg" + }, + { + "filter": "actor=140593", + "id": "140593", + "role": "Young Mali Technician", + "tag": "Rudolph Massanga", + "tagKey": "6370f552136ea8569790066a" + }, + { + "filter": "actor=108651", + "id": "108651", + "role": "Jackson", + "tag": "Judd Wild", + "tagKey": "5d7768baad5437001f74e684", + "thumb": "https://metadata-static.plex.tv/7/people/7c144923d23b134a1593269e36aa4d5f.jpg" + }, + { + "filter": "actor=140594", + "id": "140594", + "role": "Rita Salazar", + "tag": "Amber Harrington", + "tagKey": "616ade1de8e432810e6581f3" + }, + { + "filter": "actor=140595", + "id": "140595", + "role": "Henderson", + "tag": "Michael Blake Kruse", + "tagKey": "5d776f1b7a53e9001e78b813" + }, + { + "filter": "actor=140596", + "id": "140596", + "role": "Cargo Ship Helo Pilot", + "tag": "Justin James Boykin", + "tagKey": "5d776d1f96b655001fe4033f", + "thumb": "https://metadata-static.plex.tv/6/people/6a7cd7ae8f13700f87f1fa7a7be919eb.jpg" + }, + { + "filter": "actor=108521", + "id": "108521", + "role": "Anderson Cooper", + "tag": "Anderson Cooper", + "tagKey": "5d77683aeb5d26001f1e1e05", + "thumb": "https://metadata-static.plex.tv/people/5d77683aeb5d26001f1e1e05.jpg" + }, + { + "filter": "actor=140597", + "id": "140597", + "role": "River Barrier Naval Guard", + "tag": "Mackenro Alexander", + "tagKey": "5f3fc3333e5306003e55036c" + }, + { + "filter": "actor=140598", + "id": "140598", + "role": "Naval Officer", + "tag": "Kamaru Usman", + "tagKey": "5f4027e804a86500409fd230", + "thumb": "https://metadata-static.plex.tv/0/people/093884ef6b126272f1c56bb11c8f8f7c.jpg" + }, + { + "filter": "actor=123546", + "id": "123546", + "role": "M'Bele", + "tag": "T. Love", + "tagKey": "5d776b05fb0d55001f5592ec", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592ec.jpg" + }, + { + "filter": "actor=117708", + "id": "117708", + "role": "Jabari Warrior", + "tag": "Floyd Anthony Johns Jr.", + "tagKey": "5d776b53ad5437001f79b9b6", + "thumb": "https://metadata-static.plex.tv/people/5d776b53ad5437001f79b9b6.jpg" + }, + { + "filter": "actor=140599", + "id": "140599", + "role": "Jabari Warrior", + "tag": "Jermaine Brantley", + "tagKey": "5e1649d661c6140040d7fc92", + "thumb": "https://metadata-static.plex.tv/people/5e1649d661c6140040d7fc92.jpg" + }, + { + "filter": "actor=140600", + "id": "140600", + "role": "Jabari Warrior", + "tag": "Granger Summerset II", + "tagKey": "5f406c5786422500428c4fd4" + }, + { + "filter": "actor=140601", + "id": "140601", + "role": "MIT Student", + "tag": "Luke Lenza", + "tagKey": "6370f552136ea8569790066d" + }, + { + "filter": "actor=140602", + "id": "140602", + "role": "Federal Agent", + "tag": "Alan Wells", + "tagKey": "5d776838103a2d001f5687ac", + "thumb": "https://metadata-static.plex.tv/people/5d776838103a2d001f5687ac.jpg" + }, + { + "filter": "actor=140603", + "id": "140603", + "role": "FBI Special Agent", + "tag": "Bill Barrett", + "tagKey": "6083d977444a60002da33701" + }, + { + "filter": "actor=140604", + "id": "140604", + "role": "Haitian School Kid", + "tag": "Lieiry J. Perez Escalera", + "tagKey": "6370f552136ea8569790066e" + }, + { + "filter": "actor=140605", + "id": "140605", + "role": "Haitian School Kid", + "tag": "Sevyn Hill", + "tagKey": "6370f552136ea8569790066f" + }, + { + "filter": "actor=140606", + "id": "140606", + "role": "Haitian School Kid", + "tag": "Gavin Macon", + "tagKey": "6370f552136ea85697900670" + }, + { + "filter": "actor=140607", + "id": "140607", + "role": "Haitian School Kid", + "tag": "Skylar Ebron", + "tagKey": "6370f552136ea85697900671" + }, + { + "filter": "actor=140608", + "id": "140608", + "role": "Haitian School Kid", + "tag": "Taylor Holmes", + "tagKey": "6370f552136ea85697900672" + }, + { + "filter": "actor=140609", + "id": "140609", + "role": "Talokanil Guard", + "tag": "Angela Cipra", + "tagKey": "6370f552136ea85697900673" + }, + { + "filter": "actor=140610", + "id": "140610", + "role": "Talokanil Guard", + "tag": "Faya Madrid", + "tagKey": "6370f552136ea85697900674" + }, + { + "filter": "actor=140611", + "id": "140611", + "role": "Female Mayan Elder", + "tag": "María Telón", + "tagKey": "5d77698c7a53e9001e6e936c", + "thumb": "https://image.tmdb.org/t/p/original/tlfwcpVCjSdYYEvLiiVe1jFAuhP.jpg" + }, + { + "filter": "actor=140612", + "id": "140612", + "role": "Namor's Mother", + "tag": "María Mercedes Coroy", + "tagKey": "5d776b69594b2b001e6d947e", + "thumb": "https://metadata-static.plex.tv/people/5d776b69594b2b001e6d947e.jpg" + }, + { + "filter": "actor=140613", + "id": "140613", + "role": "Shaman", + "tag": "Josué Maychi", + "tagKey": "5f3fbf3f1ae7100041fc8314", + "thumb": "https://metadata-static.plex.tv/2/people/28f970559b110170f1ad2e8cc1230453.jpg" + }, + { + "filter": "actor=96885", + "id": "96885", + "role": "Yucatan Elder", + "tag": "Sal Lopez", + "tagKey": "5d776827880197001ec90ae3", + "thumb": "https://metadata-static.plex.tv/8/people/802548390b27f594650892dfe554a135.jpg" + }, + { + "filter": "actor=140614", + "id": "140614", + "role": "Namor's Mother (Older)", + "tag": "Irma Estella La Guerre", + "tagKey": "5d77707631d95e001f1a2193", + "thumb": "https://metadata-static.plex.tv/0/people/09587182629b809b1054232b36f34035.jpg" + }, + { + "filter": "actor=140615", + "id": "140615", + "role": "Young Namor", + "tag": "Manuel Chavez", + "tagKey": "62ff4580b2cc0a7ab1f18d4f", + "thumb": "https://metadata-static.plex.tv/4/people/4dcea4d82e1a43bed4278eaab585ded8.jpg" + }, + { + "filter": "actor=140616", + "id": "140616", + "role": "Hacienda Owner", + "tag": "Leonardo Castro", + "tagKey": "619cf7ff4b44ca915078a945" + }, + { + "filter": "actor=123615", + "id": "123615", + "role": "Friar", + "tag": "Juan Carlos Cantu", + "tagKey": "5d77683c6f4521001ea9d503", + "thumb": "https://metadata-static.plex.tv/people/5d77683c6f4521001ea9d503.jpg" + }, + { + "filter": "actor=109021", + "id": "109021", + "role": "Fisherman", + "tag": "Shawntae Hughes", + "tagKey": "5e69c6d60fdbbd003de628b7", + "thumb": "https://metadata-static.plex.tv/people/5e69c6d60fdbbd003de628b7.jpg" + }, + { + "filter": "actor=140617", + "id": "140617", + "role": "Terrified Man", + "tag": "Corey Hibbert", + "tagKey": "5f3fe5a2bf3e560040b2fb56", + "thumb": "https://metadata-static.plex.tv/0/people/0e8b53f991c43876078e150491d1b4db.jpg" + }, + { + "filter": "actor=140618", + "id": "140618", + "role": "Wakandan Kid", + "tag": "Zaiden James", + "tagKey": "6370f552136ea85697900675" + }, + { + "filter": "actor=140619", + "id": "140619", + "role": "Naval Engineer", + "tag": "Aba Arthur", + "tagKey": "5d77688c9ab54400214e78fc" + }, + { + "filter": "actor=140620", + "id": "140620", + "role": "Flower Shop Owner", + "tag": "Délé Ogundiran", + "tagKey": "5d77683554f42c001f8c463e", + "thumb": "https://metadata-static.plex.tv/3/people/33e9eea633f784e47144ceb2602f7c19.jpg" + }, + { + "filter": "actor=140621", + "id": "140621", + "role": "Pete", + "tag": "Kevin Changaris", + "tagKey": "5e1653d310faa500400f8eaa", + "thumb": "https://metadata-static.plex.tv/d/people/d25013711d628aea3f0b29ebd2b0a5c3.jpg" + }, + { + "filter": "actor=140622", + "id": "140622", + "role": "Haitian Taxi Passenger", + "tag": "Valerio Dorvillen", + "tagKey": "6370f552136ea85697900676" + }, + { + "filter": "actor=140623", + "id": "140623", + "role": "Haitian Taxi Passenger", + "tag": "Don Castor", + "tagKey": "6370f552136ea85697900677" + }, + { + "filter": "actor=140624", + "id": "140624", + "role": "Haitian Taxi Passenger", + "tag": "Jonathan González Collins", + "tagKey": "6370f552136ea85697900678" + }, + { + "filter": "actor=140625", + "id": "140625", + "role": "Toussaint", + "tag": "Divine Love Konadu-Sun", + "tagKey": "6370f552136ea85697900679", + "thumb": "https://metadata-static.plex.tv/2/people/2a5a9c96ba51088f862e6cfe23509353.jpg" + }, + { + "filter": "actor=110217", + "id": "110217", + "role": "T'Challa / Black Panther (archive footage) (uncredited)", + "tag": "Chadwick Boseman", + "tagKey": "5d77690996b655001fdc8c8f", + "thumb": "https://metadata-static.plex.tv/d/people/d12e4d776c045ce4c8cba456a44e6fb3.jpg" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/crates/plex-api/tests/mocks/transcode/video_dash_h265_aac.json b/crates/plex-api/tests/mocks/transcode/video_dash_h265_aac.json new file mode 100644 index 00000000..da7ddffc --- /dev/null +++ b/crates/plex-api/tests/mocks/transcode/video_dash_h265_aac.json @@ -0,0 +1,857 @@ +{ + "MediaContainer": { + "size": 1, + "allowSync": "1", + "directPlayDecisionCode": 3000, + "directPlayDecisionText": "App cannot direct play this item. Direct play is disabled.", + "generalDecisionCode": 1001, + "generalDecisionText": "Direct play not available; Conversion OK.", + "identifier": "com.plexapp.plugins.library", + "librarySectionID": "1", + "librarySectionTitle": "Movies", + "librarySectionUUID": "a006b58966aa34f3c577ca3106e99c5d1d6ea8b1", + "mediaTagPrefix": "/system/bundle/media/flags/", + "mediaTagVersion": "1676975406", + "resourceSession": "{fa5429a4-98ed-4ae6-b140-d5e99a870938}", + "transcodeDecisionCode": 1001, + "transcodeDecisionText": "Direct play not available; Conversion OK.", + "Metadata": [ + { + "addedAt": 1675330657, + "art": "/library/metadata/159637/art/1675330665", + "audienceRating": 9.4, + "audienceRatingImage": "rottentomatoes://image.rating.upright", + "chapterSource": "media", + "contentRating": "gb/12A", + "duration": 9678688, + "guid": "plex://movie/5d77702e6afb3d0020613fd1", + "key": "/library/metadata/159637", + "lastViewedAt": 1677362803, + "librarySectionID": "1", + "librarySectionKey": "/library/sections/1", + "librarySectionTitle": "Movies", + "originallyAvailableAt": "2022-11-09", + "primaryExtraKey": "/library/metadata/159638", + "rating": 8.4, + "ratingImage": "rottentomatoes://image.rating.ripe", + "ratingKey": "159637", + "studio": "Marvel Studios", + "summary": "Queen Ramonda, Shuri, M'Baku, Okoye and the Dora Milaje fight to protect the kingdom of Wakanda from intervening world powers in the wake of King T'Challa's death. As the Wakandans strive to embrace their next chapter, the heroes must band together with the help of War Dog Nakia and Everett Ross and forge a new path for their nation.", + "tagline": "Forever.", + "thumb": "/library/metadata/159637/thumb/1675330665", + "title": "Black Panther: Wakanda Forever", + "type": "movie", + "updatedAt": 1675330665, + "viewCount": 1, + "year": 2022, + "Media": [ + { + "id": "307380", + "videoProfile": "main 10", + "audioChannels": 2, + "audioCodec": "aac", + "bitrate": 1903, + "container": "mp4", + "duration": 9678688, + "height": 302, + "optimizedForStreaming": true, + "protocol": "dash", + "videoCodec": "hevc", + "videoFrameRate": "24p", + "videoResolution": "SD", + "width": 720, + "selected": true, + "Part": [ + { + "deepAnalysisVersion": "6", + "hasThumbnail": "1", + "id": "320497", + "indexes": "sd", + "requiredBandwidths": "53608,45732,31113,27983,26375,24767,18633,12057", + "videoProfile": "main 10", + "bitrate": 1903, + "container": "mp4", + "duration": 9678688, + "height": 302, + "optimizedForStreaming": true, + "protocol": "dash", + "width": 720, + "decision": "transcode", + "selected": true, + "Stream": [ + { + "bitrate": 1697, + "codec": "hevc", + "default": true, + "displayTitle": "4K HDR10 (HEVC Main 10)", + "extendedDisplayTitle": "4K HDR10 (HEVC Main 10)", + "frameRate": 23.97599983215332, + "height": 302, + "id": "566075", + "requiredBandwidths": "52842,44966,30344,27215,25607,23999,17864,11289", + "streamType": 1, + "width": 720, + "decision": "copy", + "location": "segments-video" + }, + { + "bitrate": 206, + "bitrateMode": "vbr", + "channels": 2, + "codec": "aac", + "default": true, + "displayTitle": "English (EAC3 5.1)", + "extendedDisplayTitle": "English DDP Atmos 5.1 (EAC3)", + "id": "566076", + "language": "English", + "languageCode": "eng", + "languageTag": "en", + "requiredBandwidths": "768,768,768,768,768,768,768,768", + "selected": true, + "streamType": 2, + "decision": "transcode", + "location": "segments-audio" + }, + { + "bitrate": 0, + "burn": "1", + "codec": "srt", + "default": true, + "displayTitle": "English (SRT)", + "extendedDisplayTitle": "English SRT", + "id": "566077", + "language": "English", + "languageCode": "eng", + "languageTag": "en", + "requiredBandwidths": "1,1,1,1,1,1,1,1", + "selected": true, + "streamType": 3, + "title": "English SRT", + "decision": "burn", + "location": "segments-video" + } + ] + } + ] + } + ], + "Genre": [ + { + "filter": "genre=39", + "id": "39", + "tag": "Action" + }, + { + "filter": "genre=130", + "id": "130", + "tag": "Adventure" + }, + { + "filter": "genre=132", + "id": "132", + "tag": "Science Fiction" + }, + { + "filter": "genre=93", + "id": "93", + "tag": "Drama" + }, + { + "filter": "genre=128", + "id": "128", + "tag": "Thriller" + }, + { + "filter": "genre=48", + "id": "48", + "tag": "Fantasy" + } + ], + "Director": [ + { + "filter": "director=109867", + "id": "109867", + "tag": "Ryan Coogler" + } + ], + "Writer": [ + { + "filter": "writer=99532", + "id": "99532", + "tag": "Stan Lee" + }, + { + "filter": "writer=92467", + "id": "92467", + "tag": "Jack Kirby" + }, + { + "filter": "writer=109868", + "id": "109868", + "tag": "Ryan Coogler" + }, + { + "filter": "writer=112263", + "id": "112263", + "tag": "Joe Robert Cole" + } + ], + "Producer": [ + { + "filter": "producer=89536", + "id": "89536", + "tag": "Kevin Feige" + }, + { + "filter": "producer=92508", + "id": "92508", + "tag": "Nate Moore" + } + ], + "Country": [ + { + "filter": "country=55636", + "id": "55636", + "tag": "United States of America" + } + ], + "Rating": [ + { + "image": "imdb://image.rating", + "type": "audience", + "value": "7.2" + }, + { + "image": "rottentomatoes://image.rating.ripe", + "type": "critic", + "value": "8.4" + }, + { + "image": "rottentomatoes://image.rating.upright", + "type": "audience", + "value": "9.4" + }, + { + "image": "themoviedb://image.rating", + "type": "audience", + "value": "7.5" + } + ], + "Collection": [ + { + "filter": "collection=46656", + "id": "46656", + "tag": "Marvel" + } + ], + "Role": [ + { + "filter": "actor=112266", + "id": "112266", + "role": "Shuri", + "tag": "Letitia Wright", + "tagKey": "5d77698896b655001fdd14d1", + "thumb": "https://metadata-static.plex.tv/9/people/95bd7f16f95577ccfae11f60e4995edb.jpg" + }, + { + "filter": "actor=104353", + "id": "104353", + "role": "Nakia", + "tag": "Lupita Nyong'o", + "tagKey": "5d7768ba0ea56a001e2a972f", + "thumb": "https://metadata-static.plex.tv/4/people/47ca5ee0d2b76822f10572edaea0195d.jpg" + }, + { + "filter": "actor=112265", + "id": "112265", + "role": "Okoye", + "tag": "Danai Gurira", + "tagKey": "5d776839f54112001f5bddf9", + "thumb": "https://metadata-static.plex.tv/1/people/1ac9c5f4b757cd615eb6734b2909c74e.jpg" + }, + { + "filter": "actor=112267", + "id": "112267", + "role": "M'Baku", + "tag": "Winston Duke", + "tagKey": "5d776b05fb0d55001f5592d5", + "thumb": "https://metadata-static.plex.tv/8/people/8803800d4ee7bcb73052932af60d3f5d.jpg" + }, + { + "filter": "actor=140579", + "id": "140579", + "role": "Riri Williams / Ironheart", + "tag": "Dominique Thorne", + "tagKey": "5d776d3a7a53e9001e754ddd", + "thumb": "https://metadata-static.plex.tv/b/people/bd298c6f1a8fcca0e1fd65dff210e6e5.jpg" + }, + { + "filter": "actor=131467", + "id": "131467", + "role": "Namor", + "tag": "Tenoch Huerta Mejía", + "tagKey": "5d7768468718ba001e317d8d", + "thumb": "https://metadata-static.plex.tv/2/people/2cb6d643da8a1de3a8901edbd3feb97a.jpg" + }, + { + "filter": "actor=106584", + "id": "106584", + "role": "Ramonda", + "tag": "Angela Bassett", + "tagKey": "5d7768267e9a3c0020c6a9ec", + "thumb": "https://metadata-static.plex.tv/7/people/75c2642f58f0bf47de1865633a4f309f.jpg" + }, + { + "filter": "actor=110223", + "id": "110223", + "role": "Ayo", + "tag": "Florence Kasumba", + "tagKey": "5d77683e7e9a3c0020c6e8e5", + "thumb": "https://metadata-static.plex.tv/people/5d77683e7e9a3c0020c6e8e5.jpg" + }, + { + "filter": "actor=112043", + "id": "112043", + "role": "Aneka", + "tag": "Michaela Coel", + "tagKey": "5d7769b396b655001fdd6fe9", + "thumb": "https://metadata-static.plex.tv/c/people/cc2c7c20d21eb0832c5d03d02fecffdc.jpg" + }, + { + "filter": "actor=140580", + "id": "140580", + "role": "Namora", + "tag": "Mabel Cadena", + "tagKey": "5e16515b27d563003ed660d3", + "thumb": "https://metadata-static.plex.tv/b/people/b97e4f92db01516849788fe1b866e1cb.jpg" + }, + { + "filter": "actor=113112", + "id": "113112", + "role": "Dr. Graham", + "tag": "Lake Bell", + "tagKey": "5d776832151a60001f24d339", + "thumb": "https://metadata-static.plex.tv/b/people/b158320c71ecb5befb7d6521818eddbc.jpg" + }, + { + "filter": "actor=140581", + "id": "140581", + "role": "Attuma", + "tag": "Alex Livinalli", + "tagKey": "5d7768a507c4a5001e67ac21", + "thumb": "https://metadata-static.plex.tv/people/5d7768a507c4a5001e67ac21.jpg" + }, + { + "filter": "actor=140582", + "id": "140582", + "role": "Smitty", + "tag": "Robert John Burke", + "tagKey": "5d77682d8718ba001e3131ac", + "thumb": "https://metadata-static.plex.tv/8/people/81a06f9ee23dd8bc19110a33b9e21d76.jpg" + }, + { + "filter": "actor=112068", + "id": "112068", + "role": "Border Tribe Elder", + "tag": "Danny Sapani", + "tagKey": "5d7768397228e5001f1df331", + "thumb": "https://metadata-static.plex.tv/people/5d7768397228e5001f1df331.jpg" + }, + { + "filter": "actor=112271", + "id": "112271", + "role": "River Tribe Elder", + "tag": "Isaach De Bankolé", + "tagKey": "5d77682485719b001f3a04e1", + "thumb": "https://metadata-static.plex.tv/2/people/2185ff1eaea20f2a34a4544a62be5ea7.jpg" + }, + { + "filter": "actor=112272", + "id": "112272", + "role": "Zawavari", + "tag": "Connie Chiume", + "tagKey": "5d7768472e80df001ebe09e1", + "thumb": "https://metadata-static.plex.tv/people/5d7768472e80df001ebe09e1.jpg" + }, + { + "filter": "actor=94250", + "id": "94250", + "role": "Everett Ross", + "tag": "Martin Freeman", + "tagKey": "5d776826961905001eb9111d", + "thumb": "https://metadata-static.plex.tv/5/people/51899e85031bd16b71bf6e33fa20cda0.jpg" + }, + { + "filter": "actor=116688", + "id": "116688", + "role": "Valentina Allegra de Fontaine", + "tag": "Julia Louis-Dreyfus", + "tagKey": "5d7768275af944001f1f6ec8", + "thumb": "https://metadata-static.plex.tv/4/people/4876e6724400778eff550417cf336045.jpg" + }, + { + "filter": "actor=95382", + "id": "95382", + "role": "U.S. Secretary of State", + "tag": "Richard Schiff", + "tagKey": "5d7768263c3c2a001fbcadd6", + "thumb": "https://metadata-static.plex.tv/6/people/68b16270a9766b8d1c776425bebd785f.jpg" + }, + { + "filter": "actor=109098", + "id": "109098", + "role": "N'Jadaka / Erik 'Killmonger' Stevens", + "tag": "Michael B. Jordan", + "tagKey": "5d7768823ab0e7001f5033c4", + "thumb": "https://metadata-static.plex.tv/8/people/855634fdbe74c41a32b4d0b305d09c18.jpg" + }, + { + "filter": "actor=127988", + "id": "127988", + "role": "Merchant Tribe Elder", + "tag": "Dorothy Steel", + "tagKey": "5d776b05fb0d55001f5592d7", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d7.jpg" + }, + { + "filter": "actor=140583", + "id": "140583", + "role": "Mining Tribe Elder", + "tag": "Zainab Jah", + "tagKey": "5d77684c0ea56a001e2a2aa5", + "thumb": "https://metadata-static.plex.tv/4/people/4124460d703e38bb134922737e79053e.jpg" + }, + { + "filter": "actor=112280", + "id": "112280", + "role": "Sope the Shaman", + "tag": "Sope Aluko", + "tagKey": "5d77692623d5a3001f4f6434", + "thumb": "https://metadata-static.plex.tv/people/5d77692623d5a3001f4f6434.jpg" + }, + { + "filter": "actor=112290", + "id": "112290", + "role": "Griot (voice)", + "tag": "Trevor Noah", + "tagKey": "5d77687eeb5d26001f1edd7e", + "thumb": "https://metadata-static.plex.tv/people/5d77687eeb5d26001f1edd7e.jpg" + }, + { + "filter": "actor=91804", + "id": "91804", + "role": "WDG Scientist", + "tag": "Shawn Roberts", + "tagKey": "5d77682b61141d001fb13e9f", + "thumb": "https://metadata-static.plex.tv/people/5d77682b61141d001fb13e9f.jpg" + }, + { + "filter": "actor=109262", + "id": "109262", + "role": "Zola", + "tag": "Zola Williams", + "tagKey": "5d776b05fb0d55001f5592d9", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d9.jpg" + }, + { + "filter": "actor=112276", + "id": "112276", + "role": "Nomble", + "tag": "Janeshia Adams-Ginyard", + "tagKey": "5d776885fb0d55001f512c08", + "thumb": "https://metadata-static.plex.tv/people/5d776885fb0d55001f512c08.jpg" + }, + { + "filter": "actor=140584", + "id": "140584", + "role": "Jemini", + "tag": "Jemini Powell", + "tagKey": "6370f552136ea85697900668" + }, + { + "filter": "actor=112275", + "id": "112275", + "role": "Dora Milaje", + "tag": "Marija Abney", + "tagKey": "5d776b05fb0d55001f5592d8", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d8.jpg" + }, + { + "filter": "actor=114796", + "id": "114796", + "role": "Dora Milaje", + "tag": "Keisha Tucker", + "tagKey": "5e4fd461a09d3e0037012ea8", + "thumb": "https://metadata-static.plex.tv/2/people/24c05fc4e6f7a23a75db42db842b9765.jpg" + }, + { + "filter": "actor=140585", + "id": "140585", + "role": "Dora Milaje", + "tag": "Ivy Haralson", + "tagKey": "61698da83ccba719f3f2e3c1" + }, + { + "filter": "actor=140586", + "id": "140586", + "role": "Dora Milaje", + "tag": "Maya Macatumpag", + "tagKey": "5f1c7db4cc93a100401e972d" + }, + { + "filter": "actor=140587", + "id": "140587", + "role": "Funeral Singer", + "tag": "Baaba Maal", + "tagKey": "5d77689e7a53e9001e6d4337", + "thumb": "https://metadata-static.plex.tv/2/people/27913b88f8663602f5b016441c197741.jpg" + }, + { + "filter": "actor=140588", + "id": "140588", + "role": "Drummer / Naval Guard", + "tag": "Jabari Exum", + "tagKey": "6370f552136ea85697900666" + }, + { + "filter": "actor=140589", + "id": "140589", + "role": "Drummer", + "tag": "Massamba Diop", + "tagKey": "6370f552136ea85697900667" + }, + { + "filter": "actor=140590", + "id": "140590", + "role": "Drummer", + "tag": "Magatte Saw", + "tagKey": "604e374133732c002c9a65ce" + }, + { + "filter": "actor=140591", + "id": "140591", + "role": "Assembly Chairperson", + "tag": "Gerardo Aldana", + "tagKey": "5d776e62594b2b001e72235a" + }, + { + "filter": "actor=140592", + "id": "140592", + "role": "French Secretary of State", + "tag": "Gigi Bermingham", + "tagKey": "5d7768412ec6b5001f6be380", + "thumb": "https://metadata-static.plex.tv/e/people/e7c50ee53ccea55a9dd63c7c28bdbad4.jpg" + }, + { + "filter": "actor=140593", + "id": "140593", + "role": "Young Mali Technician", + "tag": "Rudolph Massanga", + "tagKey": "6370f552136ea8569790066a" + }, + { + "filter": "actor=108651", + "id": "108651", + "role": "Jackson", + "tag": "Judd Wild", + "tagKey": "5d7768baad5437001f74e684", + "thumb": "https://metadata-static.plex.tv/7/people/7c144923d23b134a1593269e36aa4d5f.jpg" + }, + { + "filter": "actor=140594", + "id": "140594", + "role": "Rita Salazar", + "tag": "Amber Harrington", + "tagKey": "616ade1de8e432810e6581f3" + }, + { + "filter": "actor=140595", + "id": "140595", + "role": "Henderson", + "tag": "Michael Blake Kruse", + "tagKey": "5d776f1b7a53e9001e78b813" + }, + { + "filter": "actor=140596", + "id": "140596", + "role": "Cargo Ship Helo Pilot", + "tag": "Justin James Boykin", + "tagKey": "5d776d1f96b655001fe4033f", + "thumb": "https://metadata-static.plex.tv/6/people/6a7cd7ae8f13700f87f1fa7a7be919eb.jpg" + }, + { + "filter": "actor=108521", + "id": "108521", + "role": "Anderson Cooper", + "tag": "Anderson Cooper", + "tagKey": "5d77683aeb5d26001f1e1e05", + "thumb": "https://metadata-static.plex.tv/people/5d77683aeb5d26001f1e1e05.jpg" + }, + { + "filter": "actor=140597", + "id": "140597", + "role": "River Barrier Naval Guard", + "tag": "Mackenro Alexander", + "tagKey": "5f3fc3333e5306003e55036c" + }, + { + "filter": "actor=140598", + "id": "140598", + "role": "Naval Officer", + "tag": "Kamaru Usman", + "tagKey": "5f4027e804a86500409fd230", + "thumb": "https://metadata-static.plex.tv/0/people/093884ef6b126272f1c56bb11c8f8f7c.jpg" + }, + { + "filter": "actor=123546", + "id": "123546", + "role": "M'Bele", + "tag": "T. Love", + "tagKey": "5d776b05fb0d55001f5592ec", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592ec.jpg" + }, + { + "filter": "actor=117708", + "id": "117708", + "role": "Jabari Warrior", + "tag": "Floyd Anthony Johns Jr.", + "tagKey": "5d776b53ad5437001f79b9b6", + "thumb": "https://metadata-static.plex.tv/people/5d776b53ad5437001f79b9b6.jpg" + }, + { + "filter": "actor=140599", + "id": "140599", + "role": "Jabari Warrior", + "tag": "Jermaine Brantley", + "tagKey": "5e1649d661c6140040d7fc92", + "thumb": "https://metadata-static.plex.tv/people/5e1649d661c6140040d7fc92.jpg" + }, + { + "filter": "actor=140600", + "id": "140600", + "role": "Jabari Warrior", + "tag": "Granger Summerset II", + "tagKey": "5f406c5786422500428c4fd4" + }, + { + "filter": "actor=140601", + "id": "140601", + "role": "MIT Student", + "tag": "Luke Lenza", + "tagKey": "6370f552136ea8569790066d" + }, + { + "filter": "actor=140602", + "id": "140602", + "role": "Federal Agent", + "tag": "Alan Wells", + "tagKey": "5d776838103a2d001f5687ac", + "thumb": "https://metadata-static.plex.tv/people/5d776838103a2d001f5687ac.jpg" + }, + { + "filter": "actor=140603", + "id": "140603", + "role": "FBI Special Agent", + "tag": "Bill Barrett", + "tagKey": "6083d977444a60002da33701" + }, + { + "filter": "actor=140604", + "id": "140604", + "role": "Haitian School Kid", + "tag": "Lieiry J. Perez Escalera", + "tagKey": "6370f552136ea8569790066e" + }, + { + "filter": "actor=140605", + "id": "140605", + "role": "Haitian School Kid", + "tag": "Sevyn Hill", + "tagKey": "6370f552136ea8569790066f" + }, + { + "filter": "actor=140606", + "id": "140606", + "role": "Haitian School Kid", + "tag": "Gavin Macon", + "tagKey": "6370f552136ea85697900670" + }, + { + "filter": "actor=140607", + "id": "140607", + "role": "Haitian School Kid", + "tag": "Skylar Ebron", + "tagKey": "6370f552136ea85697900671" + }, + { + "filter": "actor=140608", + "id": "140608", + "role": "Haitian School Kid", + "tag": "Taylor Holmes", + "tagKey": "6370f552136ea85697900672" + }, + { + "filter": "actor=140609", + "id": "140609", + "role": "Talokanil Guard", + "tag": "Angela Cipra", + "tagKey": "6370f552136ea85697900673" + }, + { + "filter": "actor=140610", + "id": "140610", + "role": "Talokanil Guard", + "tag": "Faya Madrid", + "tagKey": "6370f552136ea85697900674" + }, + { + "filter": "actor=140611", + "id": "140611", + "role": "Female Mayan Elder", + "tag": "María Telón", + "tagKey": "5d77698c7a53e9001e6e936c", + "thumb": "https://image.tmdb.org/t/p/original/tlfwcpVCjSdYYEvLiiVe1jFAuhP.jpg" + }, + { + "filter": "actor=140612", + "id": "140612", + "role": "Namor's Mother", + "tag": "María Mercedes Coroy", + "tagKey": "5d776b69594b2b001e6d947e", + "thumb": "https://metadata-static.plex.tv/people/5d776b69594b2b001e6d947e.jpg" + }, + { + "filter": "actor=140613", + "id": "140613", + "role": "Shaman", + "tag": "Josué Maychi", + "tagKey": "5f3fbf3f1ae7100041fc8314", + "thumb": "https://metadata-static.plex.tv/2/people/28f970559b110170f1ad2e8cc1230453.jpg" + }, + { + "filter": "actor=96885", + "id": "96885", + "role": "Yucatan Elder", + "tag": "Sal Lopez", + "tagKey": "5d776827880197001ec90ae3", + "thumb": "https://metadata-static.plex.tv/8/people/802548390b27f594650892dfe554a135.jpg" + }, + { + "filter": "actor=140614", + "id": "140614", + "role": "Namor's Mother (Older)", + "tag": "Irma Estella La Guerre", + "tagKey": "5d77707631d95e001f1a2193", + "thumb": "https://metadata-static.plex.tv/0/people/09587182629b809b1054232b36f34035.jpg" + }, + { + "filter": "actor=140615", + "id": "140615", + "role": "Young Namor", + "tag": "Manuel Chavez", + "tagKey": "62ff4580b2cc0a7ab1f18d4f", + "thumb": "https://metadata-static.plex.tv/4/people/4dcea4d82e1a43bed4278eaab585ded8.jpg" + }, + { + "filter": "actor=140616", + "id": "140616", + "role": "Hacienda Owner", + "tag": "Leonardo Castro", + "tagKey": "619cf7ff4b44ca915078a945" + }, + { + "filter": "actor=123615", + "id": "123615", + "role": "Friar", + "tag": "Juan Carlos Cantu", + "tagKey": "5d77683c6f4521001ea9d503", + "thumb": "https://metadata-static.plex.tv/people/5d77683c6f4521001ea9d503.jpg" + }, + { + "filter": "actor=109021", + "id": "109021", + "role": "Fisherman", + "tag": "Shawntae Hughes", + "tagKey": "5e69c6d60fdbbd003de628b7", + "thumb": "https://metadata-static.plex.tv/people/5e69c6d60fdbbd003de628b7.jpg" + }, + { + "filter": "actor=140617", + "id": "140617", + "role": "Terrified Man", + "tag": "Corey Hibbert", + "tagKey": "5f3fe5a2bf3e560040b2fb56", + "thumb": "https://metadata-static.plex.tv/0/people/0e8b53f991c43876078e150491d1b4db.jpg" + }, + { + "filter": "actor=140618", + "id": "140618", + "role": "Wakandan Kid", + "tag": "Zaiden James", + "tagKey": "6370f552136ea85697900675" + }, + { + "filter": "actor=140619", + "id": "140619", + "role": "Naval Engineer", + "tag": "Aba Arthur", + "tagKey": "5d77688c9ab54400214e78fc" + }, + { + "filter": "actor=140620", + "id": "140620", + "role": "Flower Shop Owner", + "tag": "Délé Ogundiran", + "tagKey": "5d77683554f42c001f8c463e", + "thumb": "https://metadata-static.plex.tv/3/people/33e9eea633f784e47144ceb2602f7c19.jpg" + }, + { + "filter": "actor=140621", + "id": "140621", + "role": "Pete", + "tag": "Kevin Changaris", + "tagKey": "5e1653d310faa500400f8eaa", + "thumb": "https://metadata-static.plex.tv/d/people/d25013711d628aea3f0b29ebd2b0a5c3.jpg" + }, + { + "filter": "actor=140622", + "id": "140622", + "role": "Haitian Taxi Passenger", + "tag": "Valerio Dorvillen", + "tagKey": "6370f552136ea85697900676" + }, + { + "filter": "actor=140623", + "id": "140623", + "role": "Haitian Taxi Passenger", + "tag": "Don Castor", + "tagKey": "6370f552136ea85697900677" + }, + { + "filter": "actor=140624", + "id": "140624", + "role": "Haitian Taxi Passenger", + "tag": "Jonathan González Collins", + "tagKey": "6370f552136ea85697900678" + }, + { + "filter": "actor=140625", + "id": "140625", + "role": "Toussaint", + "tag": "Divine Love Konadu-Sun", + "tagKey": "6370f552136ea85697900679", + "thumb": "https://metadata-static.plex.tv/2/people/2a5a9c96ba51088f862e6cfe23509353.jpg" + }, + { + "filter": "actor=110217", + "id": "110217", + "role": "T'Challa / Black Panther (archive footage) (uncredited)", + "tag": "Chadwick Boseman", + "tagKey": "5d77690996b655001fdc8c8f", + "thumb": "https://metadata-static.plex.tv/d/people/d12e4d776c045ce4c8cba456a44e6fb3.jpg" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/crates/plex-api/tests/mocks/transcode/video_hls_vp9_pcm.json b/crates/plex-api/tests/mocks/transcode/video_hls_vp9_pcm.json new file mode 100644 index 00000000..8a3a889f --- /dev/null +++ b/crates/plex-api/tests/mocks/transcode/video_hls_vp9_pcm.json @@ -0,0 +1,857 @@ +{ + "MediaContainer": { + "size": 1, + "allowSync": "1", + "directPlayDecisionCode": 3000, + "directPlayDecisionText": "App cannot direct play this item. Direct play is disabled.", + "generalDecisionCode": 1001, + "generalDecisionText": "Direct play not available; Conversion OK.", + "identifier": "com.plexapp.plugins.library", + "librarySectionID": "1", + "librarySectionTitle": "Movies", + "librarySectionUUID": "a006b58966aa34f3c577ca3106e99c5d1d6ea8b1", + "mediaTagPrefix": "/system/bundle/media/flags/", + "mediaTagVersion": "1676975406", + "resourceSession": "{fa5429a4-98ed-4ae6-b140-d5e99a870938}", + "transcodeDecisionCode": 1001, + "transcodeDecisionText": "Direct play not available; Conversion OK.", + "Metadata": [ + { + "addedAt": 1675330657, + "art": "/library/metadata/159637/art/1675330665", + "audienceRating": 9.4, + "audienceRatingImage": "rottentomatoes://image.rating.upright", + "chapterSource": "media", + "contentRating": "gb/12A", + "duration": 9678688, + "guid": "plex://movie/5d77702e6afb3d0020613fd1", + "key": "/library/metadata/159637", + "lastViewedAt": 1677362803, + "librarySectionID": "1", + "librarySectionKey": "/library/sections/1", + "librarySectionTitle": "Movies", + "originallyAvailableAt": "2022-11-09", + "primaryExtraKey": "/library/metadata/159638", + "rating": 8.4, + "ratingImage": "rottentomatoes://image.rating.ripe", + "ratingKey": "159637", + "studio": "Marvel Studios", + "summary": "Queen Ramonda, Shuri, M'Baku, Okoye and the Dora Milaje fight to protect the kingdom of Wakanda from intervening world powers in the wake of King T'Challa's death. As the Wakandans strive to embrace their next chapter, the heroes must band together with the help of War Dog Nakia and Everett Ross and forge a new path for their nation.", + "tagline": "Forever.", + "thumb": "/library/metadata/159637/thumb/1675330665", + "title": "Black Panther: Wakanda Forever", + "type": "movie", + "updatedAt": 1675330665, + "viewCount": 1, + "year": 2022, + "Media": [ + { + "id": "307380", + "videoProfile": "main 10", + "audioChannels": 2, + "audioCodec": "pcm", + "bitrate": 1903, + "container": "mpegts", + "duration": 9678688, + "height": 302, + "optimizedForStreaming": true, + "protocol": "hls", + "videoCodec": "vp9", + "videoFrameRate": "24p", + "videoResolution": "SD", + "width": 720, + "selected": true, + "Part": [ + { + "deepAnalysisVersion": "6", + "hasThumbnail": "1", + "id": "320497", + "indexes": "sd", + "requiredBandwidths": "53608,45732,31113,27983,26375,24767,18633,12057", + "videoProfile": "main 10", + "bitrate": 1903, + "container": "mpegts", + "duration": 9678688, + "height": 302, + "optimizedForStreaming": true, + "protocol": "hls", + "width": 720, + "decision": "transcode", + "selected": true, + "Stream": [ + { + "bitrate": 1697, + "codec": "vp9", + "default": true, + "displayTitle": "4K HDR10 (HEVC Main 10)", + "extendedDisplayTitle": "4K HDR10 (HEVC Main 10)", + "frameRate": 23.97599983215332, + "height": 302, + "id": "566075", + "requiredBandwidths": "52842,44966,30344,27215,25607,23999,17864,11289", + "streamType": 1, + "width": 720, + "decision": "transcode", + "location": "segments-video" + }, + { + "bitrate": 206, + "bitrateMode": "vbr", + "channels": 2, + "codec": "pcm", + "default": true, + "displayTitle": "English (EAC3 5.1)", + "extendedDisplayTitle": "English DDP Atmos 5.1 (EAC3)", + "id": "566076", + "language": "English", + "languageCode": "eng", + "languageTag": "en", + "requiredBandwidths": "768,768,768,768,768,768,768,768", + "selected": true, + "streamType": 2, + "decision": "copy", + "location": "segments-audio" + }, + { + "bitrate": 0, + "burn": "1", + "codec": "srt", + "default": true, + "displayTitle": "English (SRT)", + "extendedDisplayTitle": "English SRT", + "id": "566077", + "language": "English", + "languageCode": "eng", + "languageTag": "en", + "requiredBandwidths": "1,1,1,1,1,1,1,1", + "selected": true, + "streamType": 3, + "title": "English SRT", + "decision": "burn", + "location": "segments-video" + } + ] + } + ] + } + ], + "Genre": [ + { + "filter": "genre=39", + "id": "39", + "tag": "Action" + }, + { + "filter": "genre=130", + "id": "130", + "tag": "Adventure" + }, + { + "filter": "genre=132", + "id": "132", + "tag": "Science Fiction" + }, + { + "filter": "genre=93", + "id": "93", + "tag": "Drama" + }, + { + "filter": "genre=128", + "id": "128", + "tag": "Thriller" + }, + { + "filter": "genre=48", + "id": "48", + "tag": "Fantasy" + } + ], + "Director": [ + { + "filter": "director=109867", + "id": "109867", + "tag": "Ryan Coogler" + } + ], + "Writer": [ + { + "filter": "writer=99532", + "id": "99532", + "tag": "Stan Lee" + }, + { + "filter": "writer=92467", + "id": "92467", + "tag": "Jack Kirby" + }, + { + "filter": "writer=109868", + "id": "109868", + "tag": "Ryan Coogler" + }, + { + "filter": "writer=112263", + "id": "112263", + "tag": "Joe Robert Cole" + } + ], + "Producer": [ + { + "filter": "producer=89536", + "id": "89536", + "tag": "Kevin Feige" + }, + { + "filter": "producer=92508", + "id": "92508", + "tag": "Nate Moore" + } + ], + "Country": [ + { + "filter": "country=55636", + "id": "55636", + "tag": "United States of America" + } + ], + "Rating": [ + { + "image": "imdb://image.rating", + "type": "audience", + "value": "7.2" + }, + { + "image": "rottentomatoes://image.rating.ripe", + "type": "critic", + "value": "8.4" + }, + { + "image": "rottentomatoes://image.rating.upright", + "type": "audience", + "value": "9.4" + }, + { + "image": "themoviedb://image.rating", + "type": "audience", + "value": "7.5" + } + ], + "Collection": [ + { + "filter": "collection=46656", + "id": "46656", + "tag": "Marvel" + } + ], + "Role": [ + { + "filter": "actor=112266", + "id": "112266", + "role": "Shuri", + "tag": "Letitia Wright", + "tagKey": "5d77698896b655001fdd14d1", + "thumb": "https://metadata-static.plex.tv/9/people/95bd7f16f95577ccfae11f60e4995edb.jpg" + }, + { + "filter": "actor=104353", + "id": "104353", + "role": "Nakia", + "tag": "Lupita Nyong'o", + "tagKey": "5d7768ba0ea56a001e2a972f", + "thumb": "https://metadata-static.plex.tv/4/people/47ca5ee0d2b76822f10572edaea0195d.jpg" + }, + { + "filter": "actor=112265", + "id": "112265", + "role": "Okoye", + "tag": "Danai Gurira", + "tagKey": "5d776839f54112001f5bddf9", + "thumb": "https://metadata-static.plex.tv/1/people/1ac9c5f4b757cd615eb6734b2909c74e.jpg" + }, + { + "filter": "actor=112267", + "id": "112267", + "role": "M'Baku", + "tag": "Winston Duke", + "tagKey": "5d776b05fb0d55001f5592d5", + "thumb": "https://metadata-static.plex.tv/8/people/8803800d4ee7bcb73052932af60d3f5d.jpg" + }, + { + "filter": "actor=140579", + "id": "140579", + "role": "Riri Williams / Ironheart", + "tag": "Dominique Thorne", + "tagKey": "5d776d3a7a53e9001e754ddd", + "thumb": "https://metadata-static.plex.tv/b/people/bd298c6f1a8fcca0e1fd65dff210e6e5.jpg" + }, + { + "filter": "actor=131467", + "id": "131467", + "role": "Namor", + "tag": "Tenoch Huerta Mejía", + "tagKey": "5d7768468718ba001e317d8d", + "thumb": "https://metadata-static.plex.tv/2/people/2cb6d643da8a1de3a8901edbd3feb97a.jpg" + }, + { + "filter": "actor=106584", + "id": "106584", + "role": "Ramonda", + "tag": "Angela Bassett", + "tagKey": "5d7768267e9a3c0020c6a9ec", + "thumb": "https://metadata-static.plex.tv/7/people/75c2642f58f0bf47de1865633a4f309f.jpg" + }, + { + "filter": "actor=110223", + "id": "110223", + "role": "Ayo", + "tag": "Florence Kasumba", + "tagKey": "5d77683e7e9a3c0020c6e8e5", + "thumb": "https://metadata-static.plex.tv/people/5d77683e7e9a3c0020c6e8e5.jpg" + }, + { + "filter": "actor=112043", + "id": "112043", + "role": "Aneka", + "tag": "Michaela Coel", + "tagKey": "5d7769b396b655001fdd6fe9", + "thumb": "https://metadata-static.plex.tv/c/people/cc2c7c20d21eb0832c5d03d02fecffdc.jpg" + }, + { + "filter": "actor=140580", + "id": "140580", + "role": "Namora", + "tag": "Mabel Cadena", + "tagKey": "5e16515b27d563003ed660d3", + "thumb": "https://metadata-static.plex.tv/b/people/b97e4f92db01516849788fe1b866e1cb.jpg" + }, + { + "filter": "actor=113112", + "id": "113112", + "role": "Dr. Graham", + "tag": "Lake Bell", + "tagKey": "5d776832151a60001f24d339", + "thumb": "https://metadata-static.plex.tv/b/people/b158320c71ecb5befb7d6521818eddbc.jpg" + }, + { + "filter": "actor=140581", + "id": "140581", + "role": "Attuma", + "tag": "Alex Livinalli", + "tagKey": "5d7768a507c4a5001e67ac21", + "thumb": "https://metadata-static.plex.tv/people/5d7768a507c4a5001e67ac21.jpg" + }, + { + "filter": "actor=140582", + "id": "140582", + "role": "Smitty", + "tag": "Robert John Burke", + "tagKey": "5d77682d8718ba001e3131ac", + "thumb": "https://metadata-static.plex.tv/8/people/81a06f9ee23dd8bc19110a33b9e21d76.jpg" + }, + { + "filter": "actor=112068", + "id": "112068", + "role": "Border Tribe Elder", + "tag": "Danny Sapani", + "tagKey": "5d7768397228e5001f1df331", + "thumb": "https://metadata-static.plex.tv/people/5d7768397228e5001f1df331.jpg" + }, + { + "filter": "actor=112271", + "id": "112271", + "role": "River Tribe Elder", + "tag": "Isaach De Bankolé", + "tagKey": "5d77682485719b001f3a04e1", + "thumb": "https://metadata-static.plex.tv/2/people/2185ff1eaea20f2a34a4544a62be5ea7.jpg" + }, + { + "filter": "actor=112272", + "id": "112272", + "role": "Zawavari", + "tag": "Connie Chiume", + "tagKey": "5d7768472e80df001ebe09e1", + "thumb": "https://metadata-static.plex.tv/people/5d7768472e80df001ebe09e1.jpg" + }, + { + "filter": "actor=94250", + "id": "94250", + "role": "Everett Ross", + "tag": "Martin Freeman", + "tagKey": "5d776826961905001eb9111d", + "thumb": "https://metadata-static.plex.tv/5/people/51899e85031bd16b71bf6e33fa20cda0.jpg" + }, + { + "filter": "actor=116688", + "id": "116688", + "role": "Valentina Allegra de Fontaine", + "tag": "Julia Louis-Dreyfus", + "tagKey": "5d7768275af944001f1f6ec8", + "thumb": "https://metadata-static.plex.tv/4/people/4876e6724400778eff550417cf336045.jpg" + }, + { + "filter": "actor=95382", + "id": "95382", + "role": "U.S. Secretary of State", + "tag": "Richard Schiff", + "tagKey": "5d7768263c3c2a001fbcadd6", + "thumb": "https://metadata-static.plex.tv/6/people/68b16270a9766b8d1c776425bebd785f.jpg" + }, + { + "filter": "actor=109098", + "id": "109098", + "role": "N'Jadaka / Erik 'Killmonger' Stevens", + "tag": "Michael B. Jordan", + "tagKey": "5d7768823ab0e7001f5033c4", + "thumb": "https://metadata-static.plex.tv/8/people/855634fdbe74c41a32b4d0b305d09c18.jpg" + }, + { + "filter": "actor=127988", + "id": "127988", + "role": "Merchant Tribe Elder", + "tag": "Dorothy Steel", + "tagKey": "5d776b05fb0d55001f5592d7", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d7.jpg" + }, + { + "filter": "actor=140583", + "id": "140583", + "role": "Mining Tribe Elder", + "tag": "Zainab Jah", + "tagKey": "5d77684c0ea56a001e2a2aa5", + "thumb": "https://metadata-static.plex.tv/4/people/4124460d703e38bb134922737e79053e.jpg" + }, + { + "filter": "actor=112280", + "id": "112280", + "role": "Sope the Shaman", + "tag": "Sope Aluko", + "tagKey": "5d77692623d5a3001f4f6434", + "thumb": "https://metadata-static.plex.tv/people/5d77692623d5a3001f4f6434.jpg" + }, + { + "filter": "actor=112290", + "id": "112290", + "role": "Griot (voice)", + "tag": "Trevor Noah", + "tagKey": "5d77687eeb5d26001f1edd7e", + "thumb": "https://metadata-static.plex.tv/people/5d77687eeb5d26001f1edd7e.jpg" + }, + { + "filter": "actor=91804", + "id": "91804", + "role": "WDG Scientist", + "tag": "Shawn Roberts", + "tagKey": "5d77682b61141d001fb13e9f", + "thumb": "https://metadata-static.plex.tv/people/5d77682b61141d001fb13e9f.jpg" + }, + { + "filter": "actor=109262", + "id": "109262", + "role": "Zola", + "tag": "Zola Williams", + "tagKey": "5d776b05fb0d55001f5592d9", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d9.jpg" + }, + { + "filter": "actor=112276", + "id": "112276", + "role": "Nomble", + "tag": "Janeshia Adams-Ginyard", + "tagKey": "5d776885fb0d55001f512c08", + "thumb": "https://metadata-static.plex.tv/people/5d776885fb0d55001f512c08.jpg" + }, + { + "filter": "actor=140584", + "id": "140584", + "role": "Jemini", + "tag": "Jemini Powell", + "tagKey": "6370f552136ea85697900668" + }, + { + "filter": "actor=112275", + "id": "112275", + "role": "Dora Milaje", + "tag": "Marija Abney", + "tagKey": "5d776b05fb0d55001f5592d8", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d8.jpg" + }, + { + "filter": "actor=114796", + "id": "114796", + "role": "Dora Milaje", + "tag": "Keisha Tucker", + "tagKey": "5e4fd461a09d3e0037012ea8", + "thumb": "https://metadata-static.plex.tv/2/people/24c05fc4e6f7a23a75db42db842b9765.jpg" + }, + { + "filter": "actor=140585", + "id": "140585", + "role": "Dora Milaje", + "tag": "Ivy Haralson", + "tagKey": "61698da83ccba719f3f2e3c1" + }, + { + "filter": "actor=140586", + "id": "140586", + "role": "Dora Milaje", + "tag": "Maya Macatumpag", + "tagKey": "5f1c7db4cc93a100401e972d" + }, + { + "filter": "actor=140587", + "id": "140587", + "role": "Funeral Singer", + "tag": "Baaba Maal", + "tagKey": "5d77689e7a53e9001e6d4337", + "thumb": "https://metadata-static.plex.tv/2/people/27913b88f8663602f5b016441c197741.jpg" + }, + { + "filter": "actor=140588", + "id": "140588", + "role": "Drummer / Naval Guard", + "tag": "Jabari Exum", + "tagKey": "6370f552136ea85697900666" + }, + { + "filter": "actor=140589", + "id": "140589", + "role": "Drummer", + "tag": "Massamba Diop", + "tagKey": "6370f552136ea85697900667" + }, + { + "filter": "actor=140590", + "id": "140590", + "role": "Drummer", + "tag": "Magatte Saw", + "tagKey": "604e374133732c002c9a65ce" + }, + { + "filter": "actor=140591", + "id": "140591", + "role": "Assembly Chairperson", + "tag": "Gerardo Aldana", + "tagKey": "5d776e62594b2b001e72235a" + }, + { + "filter": "actor=140592", + "id": "140592", + "role": "French Secretary of State", + "tag": "Gigi Bermingham", + "tagKey": "5d7768412ec6b5001f6be380", + "thumb": "https://metadata-static.plex.tv/e/people/e7c50ee53ccea55a9dd63c7c28bdbad4.jpg" + }, + { + "filter": "actor=140593", + "id": "140593", + "role": "Young Mali Technician", + "tag": "Rudolph Massanga", + "tagKey": "6370f552136ea8569790066a" + }, + { + "filter": "actor=108651", + "id": "108651", + "role": "Jackson", + "tag": "Judd Wild", + "tagKey": "5d7768baad5437001f74e684", + "thumb": "https://metadata-static.plex.tv/7/people/7c144923d23b134a1593269e36aa4d5f.jpg" + }, + { + "filter": "actor=140594", + "id": "140594", + "role": "Rita Salazar", + "tag": "Amber Harrington", + "tagKey": "616ade1de8e432810e6581f3" + }, + { + "filter": "actor=140595", + "id": "140595", + "role": "Henderson", + "tag": "Michael Blake Kruse", + "tagKey": "5d776f1b7a53e9001e78b813" + }, + { + "filter": "actor=140596", + "id": "140596", + "role": "Cargo Ship Helo Pilot", + "tag": "Justin James Boykin", + "tagKey": "5d776d1f96b655001fe4033f", + "thumb": "https://metadata-static.plex.tv/6/people/6a7cd7ae8f13700f87f1fa7a7be919eb.jpg" + }, + { + "filter": "actor=108521", + "id": "108521", + "role": "Anderson Cooper", + "tag": "Anderson Cooper", + "tagKey": "5d77683aeb5d26001f1e1e05", + "thumb": "https://metadata-static.plex.tv/people/5d77683aeb5d26001f1e1e05.jpg" + }, + { + "filter": "actor=140597", + "id": "140597", + "role": "River Barrier Naval Guard", + "tag": "Mackenro Alexander", + "tagKey": "5f3fc3333e5306003e55036c" + }, + { + "filter": "actor=140598", + "id": "140598", + "role": "Naval Officer", + "tag": "Kamaru Usman", + "tagKey": "5f4027e804a86500409fd230", + "thumb": "https://metadata-static.plex.tv/0/people/093884ef6b126272f1c56bb11c8f8f7c.jpg" + }, + { + "filter": "actor=123546", + "id": "123546", + "role": "M'Bele", + "tag": "T. Love", + "tagKey": "5d776b05fb0d55001f5592ec", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592ec.jpg" + }, + { + "filter": "actor=117708", + "id": "117708", + "role": "Jabari Warrior", + "tag": "Floyd Anthony Johns Jr.", + "tagKey": "5d776b53ad5437001f79b9b6", + "thumb": "https://metadata-static.plex.tv/people/5d776b53ad5437001f79b9b6.jpg" + }, + { + "filter": "actor=140599", + "id": "140599", + "role": "Jabari Warrior", + "tag": "Jermaine Brantley", + "tagKey": "5e1649d661c6140040d7fc92", + "thumb": "https://metadata-static.plex.tv/people/5e1649d661c6140040d7fc92.jpg" + }, + { + "filter": "actor=140600", + "id": "140600", + "role": "Jabari Warrior", + "tag": "Granger Summerset II", + "tagKey": "5f406c5786422500428c4fd4" + }, + { + "filter": "actor=140601", + "id": "140601", + "role": "MIT Student", + "tag": "Luke Lenza", + "tagKey": "6370f552136ea8569790066d" + }, + { + "filter": "actor=140602", + "id": "140602", + "role": "Federal Agent", + "tag": "Alan Wells", + "tagKey": "5d776838103a2d001f5687ac", + "thumb": "https://metadata-static.plex.tv/people/5d776838103a2d001f5687ac.jpg" + }, + { + "filter": "actor=140603", + "id": "140603", + "role": "FBI Special Agent", + "tag": "Bill Barrett", + "tagKey": "6083d977444a60002da33701" + }, + { + "filter": "actor=140604", + "id": "140604", + "role": "Haitian School Kid", + "tag": "Lieiry J. Perez Escalera", + "tagKey": "6370f552136ea8569790066e" + }, + { + "filter": "actor=140605", + "id": "140605", + "role": "Haitian School Kid", + "tag": "Sevyn Hill", + "tagKey": "6370f552136ea8569790066f" + }, + { + "filter": "actor=140606", + "id": "140606", + "role": "Haitian School Kid", + "tag": "Gavin Macon", + "tagKey": "6370f552136ea85697900670" + }, + { + "filter": "actor=140607", + "id": "140607", + "role": "Haitian School Kid", + "tag": "Skylar Ebron", + "tagKey": "6370f552136ea85697900671" + }, + { + "filter": "actor=140608", + "id": "140608", + "role": "Haitian School Kid", + "tag": "Taylor Holmes", + "tagKey": "6370f552136ea85697900672" + }, + { + "filter": "actor=140609", + "id": "140609", + "role": "Talokanil Guard", + "tag": "Angela Cipra", + "tagKey": "6370f552136ea85697900673" + }, + { + "filter": "actor=140610", + "id": "140610", + "role": "Talokanil Guard", + "tag": "Faya Madrid", + "tagKey": "6370f552136ea85697900674" + }, + { + "filter": "actor=140611", + "id": "140611", + "role": "Female Mayan Elder", + "tag": "María Telón", + "tagKey": "5d77698c7a53e9001e6e936c", + "thumb": "https://image.tmdb.org/t/p/original/tlfwcpVCjSdYYEvLiiVe1jFAuhP.jpg" + }, + { + "filter": "actor=140612", + "id": "140612", + "role": "Namor's Mother", + "tag": "María Mercedes Coroy", + "tagKey": "5d776b69594b2b001e6d947e", + "thumb": "https://metadata-static.plex.tv/people/5d776b69594b2b001e6d947e.jpg" + }, + { + "filter": "actor=140613", + "id": "140613", + "role": "Shaman", + "tag": "Josué Maychi", + "tagKey": "5f3fbf3f1ae7100041fc8314", + "thumb": "https://metadata-static.plex.tv/2/people/28f970559b110170f1ad2e8cc1230453.jpg" + }, + { + "filter": "actor=96885", + "id": "96885", + "role": "Yucatan Elder", + "tag": "Sal Lopez", + "tagKey": "5d776827880197001ec90ae3", + "thumb": "https://metadata-static.plex.tv/8/people/802548390b27f594650892dfe554a135.jpg" + }, + { + "filter": "actor=140614", + "id": "140614", + "role": "Namor's Mother (Older)", + "tag": "Irma Estella La Guerre", + "tagKey": "5d77707631d95e001f1a2193", + "thumb": "https://metadata-static.plex.tv/0/people/09587182629b809b1054232b36f34035.jpg" + }, + { + "filter": "actor=140615", + "id": "140615", + "role": "Young Namor", + "tag": "Manuel Chavez", + "tagKey": "62ff4580b2cc0a7ab1f18d4f", + "thumb": "https://metadata-static.plex.tv/4/people/4dcea4d82e1a43bed4278eaab585ded8.jpg" + }, + { + "filter": "actor=140616", + "id": "140616", + "role": "Hacienda Owner", + "tag": "Leonardo Castro", + "tagKey": "619cf7ff4b44ca915078a945" + }, + { + "filter": "actor=123615", + "id": "123615", + "role": "Friar", + "tag": "Juan Carlos Cantu", + "tagKey": "5d77683c6f4521001ea9d503", + "thumb": "https://metadata-static.plex.tv/people/5d77683c6f4521001ea9d503.jpg" + }, + { + "filter": "actor=109021", + "id": "109021", + "role": "Fisherman", + "tag": "Shawntae Hughes", + "tagKey": "5e69c6d60fdbbd003de628b7", + "thumb": "https://metadata-static.plex.tv/people/5e69c6d60fdbbd003de628b7.jpg" + }, + { + "filter": "actor=140617", + "id": "140617", + "role": "Terrified Man", + "tag": "Corey Hibbert", + "tagKey": "5f3fe5a2bf3e560040b2fb56", + "thumb": "https://metadata-static.plex.tv/0/people/0e8b53f991c43876078e150491d1b4db.jpg" + }, + { + "filter": "actor=140618", + "id": "140618", + "role": "Wakandan Kid", + "tag": "Zaiden James", + "tagKey": "6370f552136ea85697900675" + }, + { + "filter": "actor=140619", + "id": "140619", + "role": "Naval Engineer", + "tag": "Aba Arthur", + "tagKey": "5d77688c9ab54400214e78fc" + }, + { + "filter": "actor=140620", + "id": "140620", + "role": "Flower Shop Owner", + "tag": "Délé Ogundiran", + "tagKey": "5d77683554f42c001f8c463e", + "thumb": "https://metadata-static.plex.tv/3/people/33e9eea633f784e47144ceb2602f7c19.jpg" + }, + { + "filter": "actor=140621", + "id": "140621", + "role": "Pete", + "tag": "Kevin Changaris", + "tagKey": "5e1653d310faa500400f8eaa", + "thumb": "https://metadata-static.plex.tv/d/people/d25013711d628aea3f0b29ebd2b0a5c3.jpg" + }, + { + "filter": "actor=140622", + "id": "140622", + "role": "Haitian Taxi Passenger", + "tag": "Valerio Dorvillen", + "tagKey": "6370f552136ea85697900676" + }, + { + "filter": "actor=140623", + "id": "140623", + "role": "Haitian Taxi Passenger", + "tag": "Don Castor", + "tagKey": "6370f552136ea85697900677" + }, + { + "filter": "actor=140624", + "id": "140624", + "role": "Haitian Taxi Passenger", + "tag": "Jonathan González Collins", + "tagKey": "6370f552136ea85697900678" + }, + { + "filter": "actor=140625", + "id": "140625", + "role": "Toussaint", + "tag": "Divine Love Konadu-Sun", + "tagKey": "6370f552136ea85697900679", + "thumb": "https://metadata-static.plex.tv/2/people/2a5a9c96ba51088f862e6cfe23509353.jpg" + }, + { + "filter": "actor=110217", + "id": "110217", + "role": "T'Challa / Black Panther (archive footage) (uncredited)", + "tag": "Chadwick Boseman", + "tagKey": "5d77690996b655001fdc8c8f", + "thumb": "https://metadata-static.plex.tv/d/people/d12e4d776c045ce4c8cba456a44e6fb3.jpg" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/crates/plex-api/tests/mocks/transcode/video_offline_h264_mp3.json b/crates/plex-api/tests/mocks/transcode/video_offline_h264_mp3.json new file mode 100644 index 00000000..812d5350 --- /dev/null +++ b/crates/plex-api/tests/mocks/transcode/video_offline_h264_mp3.json @@ -0,0 +1,871 @@ +{ + "MediaContainer": { + "size": 1, + "allowSync": "1", + "directPlayDecisionCode": 3000, + "directPlayDecisionText": "App cannot direct play this item. App requested burned subtitles and conversion of video is required to burn subtitles.", + "generalDecisionCode": 1001, + "generalDecisionText": "Direct play not available; Conversion OK.", + "identifier": "com.plexapp.plugins.library", + "librarySectionID": "1", + "librarySectionTitle": "Movies", + "librarySectionUUID": "a006b58966aa34f3c577ca3106e99c5d1d6ea8b1", + "mediaTagPrefix": "/system/bundle/media/flags/", + "mediaTagVersion": "1676975406", + "resourceSession": "{fa5429a4-98ed-4ae6-b140-d5e99a870938}", + "transcodeDecisionCode": 1001, + "transcodeDecisionText": "Direct play not available; Conversion OK.", + "Metadata": [ + { + "addedAt": 1675330657, + "art": "/library/metadata/159637/art/1675330665", + "audienceRating": 9.4, + "audienceRatingImage": "rottentomatoes://image.rating.upright", + "chapterSource": "media", + "contentRating": "gb/12A", + "duration": 9678688, + "guid": "plex://movie/5d77702e6afb3d0020613fd1", + "key": "/library/metadata/159637", + "lastViewedAt": 1677362803, + "librarySectionID": "1", + "librarySectionKey": "/library/sections/1", + "librarySectionTitle": "Movies", + "originallyAvailableAt": "2022-11-09", + "primaryExtraKey": "/library/metadata/159638", + "rating": 8.4, + "ratingImage": "rottentomatoes://image.rating.ripe", + "ratingKey": "159637", + "studio": "Marvel Studios", + "summary": "Queen Ramonda, Shuri, M'Baku, Okoye and the Dora Milaje fight to protect the kingdom of Wakanda from intervening world powers in the wake of King T'Challa's death. As the Wakandans strive to embrace their next chapter, the heroes must band together with the help of War Dog Nakia and Everett Ross and forge a new path for their nation.", + "tagline": "Forever.", + "thumb": "/library/metadata/159637/thumb/1675330665", + "title": "Black Panther: Wakanda Forever", + "type": "movie", + "updatedAt": 1675330665, + "viewCount": 1, + "year": 2022, + "Media": [ + { + "id": "307380", + "videoProfile": "main 10", + "audioChannels": 2, + "audioCodec": "mp3", + "bitrate": 1903, + "container": "mp4", + "duration": 9678688, + "height": 302, + "optimizedForStreaming": true, + "videoCodec": "h264", + "videoFrameRate": "24p", + "videoResolution": "SD", + "width": 720, + "selected": true, + "Part": [ + { + "deepAnalysisVersion": "6", + "hasThumbnail": "1", + "id": "320497", + "indexes": "sd", + "requiredBandwidths": "53608,45732,31113,27983,26375,24767,18633,12057", + "videoProfile": "main 10", + "bitrate": 1903, + "container": "mp4", + "duration": 9678688, + "height": 302, + "optimizedForStreaming": true, + "width": 720, + "decision": "transcode", + "selected": true, + "Stream": [ + { + "bitrate": 1697, + "codec": "h264", + "default": true, + "displayTitle": "4K HDR10 (HEVC Main 10)", + "extendedDisplayTitle": "4K HDR10 (HEVC Main 10)", + "frameRate": 23.97599983215332, + "height": 302, + "id": "566075", + "requiredBandwidths": "52842,44966,30344,27215,25607,23999,17864,11289", + "streamType": 1, + "width": 720, + "decision": "transcode", + "location": "direct" + }, + { + "bitrate": 206, + "bitrateMode": "vbr", + "channels": 2, + "codec": "mp3", + "default": true, + "displayTitle": "English (EAC3 5.1)", + "extendedDisplayTitle": "English DDP Atmos 5.1 (EAC3)", + "id": "566076", + "language": "English", + "languageCode": "eng", + "languageTag": "en", + "requiredBandwidths": "768,768,768,768,768,768,768,768", + "selected": true, + "streamType": 2, + "decision": "transcode", + "location": "direct" + }, + { + "bitrate": 0, + "burn": "1", + "codec": "srt", + "default": true, + "displayTitle": "English (SRT)", + "extendedDisplayTitle": "English SRT", + "id": "566077", + "language": "English", + "languageCode": "eng", + "languageTag": "en", + "requiredBandwidths": "1,1,1,1,1,1,1,1", + "selected": true, + "streamType": 3, + "title": "English SRT", + "decision": "burn", + "location": "direct" + }, + { + "bitrate": 0, + "codec": "srt", + "displayTitle": "English SDH (SRT)", + "extendedDisplayTitle": "English SDH SRT", + "id": "566078", + "ignore": "1", + "language": "English", + "languageCode": "eng", + "languageTag": "en", + "requiredBandwidths": "1,1,1,1,1,1,1,1", + "streamType": 3, + "title": "English SDH SRT", + "decision": "ignore", + "location": "direct" + } + ] + } + ] + } + ], + "Genre": [ + { + "filter": "genre=39", + "id": "39", + "tag": "Action" + }, + { + "filter": "genre=130", + "id": "130", + "tag": "Adventure" + }, + { + "filter": "genre=132", + "id": "132", + "tag": "Science Fiction" + }, + { + "filter": "genre=93", + "id": "93", + "tag": "Drama" + }, + { + "filter": "genre=128", + "id": "128", + "tag": "Thriller" + }, + { + "filter": "genre=48", + "id": "48", + "tag": "Fantasy" + } + ], + "Director": [ + { + "filter": "director=109867", + "id": "109867", + "tag": "Ryan Coogler" + } + ], + "Writer": [ + { + "filter": "writer=99532", + "id": "99532", + "tag": "Stan Lee" + }, + { + "filter": "writer=92467", + "id": "92467", + "tag": "Jack Kirby" + }, + { + "filter": "writer=109868", + "id": "109868", + "tag": "Ryan Coogler" + }, + { + "filter": "writer=112263", + "id": "112263", + "tag": "Joe Robert Cole" + } + ], + "Producer": [ + { + "filter": "producer=89536", + "id": "89536", + "tag": "Kevin Feige" + }, + { + "filter": "producer=92508", + "id": "92508", + "tag": "Nate Moore" + } + ], + "Country": [ + { + "filter": "country=55636", + "id": "55636", + "tag": "United States of America" + } + ], + "Rating": [ + { + "image": "imdb://image.rating", + "type": "audience", + "value": "7.2" + }, + { + "image": "rottentomatoes://image.rating.ripe", + "type": "critic", + "value": "8.4" + }, + { + "image": "rottentomatoes://image.rating.upright", + "type": "audience", + "value": "9.4" + }, + { + "image": "themoviedb://image.rating", + "type": "audience", + "value": "7.5" + } + ], + "Collection": [ + { + "filter": "collection=46656", + "id": "46656", + "tag": "Marvel" + } + ], + "Role": [ + { + "filter": "actor=112266", + "id": "112266", + "role": "Shuri", + "tag": "Letitia Wright", + "tagKey": "5d77698896b655001fdd14d1", + "thumb": "https://metadata-static.plex.tv/9/people/95bd7f16f95577ccfae11f60e4995edb.jpg" + }, + { + "filter": "actor=104353", + "id": "104353", + "role": "Nakia", + "tag": "Lupita Nyong'o", + "tagKey": "5d7768ba0ea56a001e2a972f", + "thumb": "https://metadata-static.plex.tv/4/people/47ca5ee0d2b76822f10572edaea0195d.jpg" + }, + { + "filter": "actor=112265", + "id": "112265", + "role": "Okoye", + "tag": "Danai Gurira", + "tagKey": "5d776839f54112001f5bddf9", + "thumb": "https://metadata-static.plex.tv/1/people/1ac9c5f4b757cd615eb6734b2909c74e.jpg" + }, + { + "filter": "actor=112267", + "id": "112267", + "role": "M'Baku", + "tag": "Winston Duke", + "tagKey": "5d776b05fb0d55001f5592d5", + "thumb": "https://metadata-static.plex.tv/8/people/8803800d4ee7bcb73052932af60d3f5d.jpg" + }, + { + "filter": "actor=140579", + "id": "140579", + "role": "Riri Williams / Ironheart", + "tag": "Dominique Thorne", + "tagKey": "5d776d3a7a53e9001e754ddd", + "thumb": "https://metadata-static.plex.tv/b/people/bd298c6f1a8fcca0e1fd65dff210e6e5.jpg" + }, + { + "filter": "actor=131467", + "id": "131467", + "role": "Namor", + "tag": "Tenoch Huerta Mejía", + "tagKey": "5d7768468718ba001e317d8d", + "thumb": "https://metadata-static.plex.tv/2/people/2cb6d643da8a1de3a8901edbd3feb97a.jpg" + }, + { + "filter": "actor=106584", + "id": "106584", + "role": "Ramonda", + "tag": "Angela Bassett", + "tagKey": "5d7768267e9a3c0020c6a9ec", + "thumb": "https://metadata-static.plex.tv/7/people/75c2642f58f0bf47de1865633a4f309f.jpg" + }, + { + "filter": "actor=110223", + "id": "110223", + "role": "Ayo", + "tag": "Florence Kasumba", + "tagKey": "5d77683e7e9a3c0020c6e8e5", + "thumb": "https://metadata-static.plex.tv/people/5d77683e7e9a3c0020c6e8e5.jpg" + }, + { + "filter": "actor=112043", + "id": "112043", + "role": "Aneka", + "tag": "Michaela Coel", + "tagKey": "5d7769b396b655001fdd6fe9", + "thumb": "https://metadata-static.plex.tv/c/people/cc2c7c20d21eb0832c5d03d02fecffdc.jpg" + }, + { + "filter": "actor=140580", + "id": "140580", + "role": "Namora", + "tag": "Mabel Cadena", + "tagKey": "5e16515b27d563003ed660d3", + "thumb": "https://metadata-static.plex.tv/b/people/b97e4f92db01516849788fe1b866e1cb.jpg" + }, + { + "filter": "actor=113112", + "id": "113112", + "role": "Dr. Graham", + "tag": "Lake Bell", + "tagKey": "5d776832151a60001f24d339", + "thumb": "https://metadata-static.plex.tv/b/people/b158320c71ecb5befb7d6521818eddbc.jpg" + }, + { + "filter": "actor=140581", + "id": "140581", + "role": "Attuma", + "tag": "Alex Livinalli", + "tagKey": "5d7768a507c4a5001e67ac21", + "thumb": "https://metadata-static.plex.tv/people/5d7768a507c4a5001e67ac21.jpg" + }, + { + "filter": "actor=140582", + "id": "140582", + "role": "Smitty", + "tag": "Robert John Burke", + "tagKey": "5d77682d8718ba001e3131ac", + "thumb": "https://metadata-static.plex.tv/8/people/81a06f9ee23dd8bc19110a33b9e21d76.jpg" + }, + { + "filter": "actor=112068", + "id": "112068", + "role": "Border Tribe Elder", + "tag": "Danny Sapani", + "tagKey": "5d7768397228e5001f1df331", + "thumb": "https://metadata-static.plex.tv/people/5d7768397228e5001f1df331.jpg" + }, + { + "filter": "actor=112271", + "id": "112271", + "role": "River Tribe Elder", + "tag": "Isaach De Bankolé", + "tagKey": "5d77682485719b001f3a04e1", + "thumb": "https://metadata-static.plex.tv/2/people/2185ff1eaea20f2a34a4544a62be5ea7.jpg" + }, + { + "filter": "actor=112272", + "id": "112272", + "role": "Zawavari", + "tag": "Connie Chiume", + "tagKey": "5d7768472e80df001ebe09e1", + "thumb": "https://metadata-static.plex.tv/people/5d7768472e80df001ebe09e1.jpg" + }, + { + "filter": "actor=94250", + "id": "94250", + "role": "Everett Ross", + "tag": "Martin Freeman", + "tagKey": "5d776826961905001eb9111d", + "thumb": "https://metadata-static.plex.tv/5/people/51899e85031bd16b71bf6e33fa20cda0.jpg" + }, + { + "filter": "actor=116688", + "id": "116688", + "role": "Valentina Allegra de Fontaine", + "tag": "Julia Louis-Dreyfus", + "tagKey": "5d7768275af944001f1f6ec8", + "thumb": "https://metadata-static.plex.tv/4/people/4876e6724400778eff550417cf336045.jpg" + }, + { + "filter": "actor=95382", + "id": "95382", + "role": "U.S. Secretary of State", + "tag": "Richard Schiff", + "tagKey": "5d7768263c3c2a001fbcadd6", + "thumb": "https://metadata-static.plex.tv/6/people/68b16270a9766b8d1c776425bebd785f.jpg" + }, + { + "filter": "actor=109098", + "id": "109098", + "role": "N'Jadaka / Erik 'Killmonger' Stevens", + "tag": "Michael B. Jordan", + "tagKey": "5d7768823ab0e7001f5033c4", + "thumb": "https://metadata-static.plex.tv/8/people/855634fdbe74c41a32b4d0b305d09c18.jpg" + }, + { + "filter": "actor=127988", + "id": "127988", + "role": "Merchant Tribe Elder", + "tag": "Dorothy Steel", + "tagKey": "5d776b05fb0d55001f5592d7", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d7.jpg" + }, + { + "filter": "actor=140583", + "id": "140583", + "role": "Mining Tribe Elder", + "tag": "Zainab Jah", + "tagKey": "5d77684c0ea56a001e2a2aa5", + "thumb": "https://metadata-static.plex.tv/4/people/4124460d703e38bb134922737e79053e.jpg" + }, + { + "filter": "actor=112280", + "id": "112280", + "role": "Sope the Shaman", + "tag": "Sope Aluko", + "tagKey": "5d77692623d5a3001f4f6434", + "thumb": "https://metadata-static.plex.tv/people/5d77692623d5a3001f4f6434.jpg" + }, + { + "filter": "actor=112290", + "id": "112290", + "role": "Griot (voice)", + "tag": "Trevor Noah", + "tagKey": "5d77687eeb5d26001f1edd7e", + "thumb": "https://metadata-static.plex.tv/people/5d77687eeb5d26001f1edd7e.jpg" + }, + { + "filter": "actor=91804", + "id": "91804", + "role": "WDG Scientist", + "tag": "Shawn Roberts", + "tagKey": "5d77682b61141d001fb13e9f", + "thumb": "https://metadata-static.plex.tv/people/5d77682b61141d001fb13e9f.jpg" + }, + { + "filter": "actor=109262", + "id": "109262", + "role": "Zola", + "tag": "Zola Williams", + "tagKey": "5d776b05fb0d55001f5592d9", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d9.jpg" + }, + { + "filter": "actor=112276", + "id": "112276", + "role": "Nomble", + "tag": "Janeshia Adams-Ginyard", + "tagKey": "5d776885fb0d55001f512c08", + "thumb": "https://metadata-static.plex.tv/people/5d776885fb0d55001f512c08.jpg" + }, + { + "filter": "actor=140584", + "id": "140584", + "role": "Jemini", + "tag": "Jemini Powell", + "tagKey": "6370f552136ea85697900668" + }, + { + "filter": "actor=112275", + "id": "112275", + "role": "Dora Milaje", + "tag": "Marija Abney", + "tagKey": "5d776b05fb0d55001f5592d8", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592d8.jpg" + }, + { + "filter": "actor=114796", + "id": "114796", + "role": "Dora Milaje", + "tag": "Keisha Tucker", + "tagKey": "5e4fd461a09d3e0037012ea8", + "thumb": "https://metadata-static.plex.tv/2/people/24c05fc4e6f7a23a75db42db842b9765.jpg" + }, + { + "filter": "actor=140585", + "id": "140585", + "role": "Dora Milaje", + "tag": "Ivy Haralson", + "tagKey": "61698da83ccba719f3f2e3c1" + }, + { + "filter": "actor=140586", + "id": "140586", + "role": "Dora Milaje", + "tag": "Maya Macatumpag", + "tagKey": "5f1c7db4cc93a100401e972d" + }, + { + "filter": "actor=140587", + "id": "140587", + "role": "Funeral Singer", + "tag": "Baaba Maal", + "tagKey": "5d77689e7a53e9001e6d4337", + "thumb": "https://metadata-static.plex.tv/2/people/27913b88f8663602f5b016441c197741.jpg" + }, + { + "filter": "actor=140588", + "id": "140588", + "role": "Drummer / Naval Guard", + "tag": "Jabari Exum", + "tagKey": "6370f552136ea85697900666" + }, + { + "filter": "actor=140589", + "id": "140589", + "role": "Drummer", + "tag": "Massamba Diop", + "tagKey": "6370f552136ea85697900667" + }, + { + "filter": "actor=140590", + "id": "140590", + "role": "Drummer", + "tag": "Magatte Saw", + "tagKey": "604e374133732c002c9a65ce" + }, + { + "filter": "actor=140591", + "id": "140591", + "role": "Assembly Chairperson", + "tag": "Gerardo Aldana", + "tagKey": "5d776e62594b2b001e72235a" + }, + { + "filter": "actor=140592", + "id": "140592", + "role": "French Secretary of State", + "tag": "Gigi Bermingham", + "tagKey": "5d7768412ec6b5001f6be380", + "thumb": "https://metadata-static.plex.tv/e/people/e7c50ee53ccea55a9dd63c7c28bdbad4.jpg" + }, + { + "filter": "actor=140593", + "id": "140593", + "role": "Young Mali Technician", + "tag": "Rudolph Massanga", + "tagKey": "6370f552136ea8569790066a" + }, + { + "filter": "actor=108651", + "id": "108651", + "role": "Jackson", + "tag": "Judd Wild", + "tagKey": "5d7768baad5437001f74e684", + "thumb": "https://metadata-static.plex.tv/7/people/7c144923d23b134a1593269e36aa4d5f.jpg" + }, + { + "filter": "actor=140594", + "id": "140594", + "role": "Rita Salazar", + "tag": "Amber Harrington", + "tagKey": "616ade1de8e432810e6581f3" + }, + { + "filter": "actor=140595", + "id": "140595", + "role": "Henderson", + "tag": "Michael Blake Kruse", + "tagKey": "5d776f1b7a53e9001e78b813" + }, + { + "filter": "actor=140596", + "id": "140596", + "role": "Cargo Ship Helo Pilot", + "tag": "Justin James Boykin", + "tagKey": "5d776d1f96b655001fe4033f", + "thumb": "https://metadata-static.plex.tv/6/people/6a7cd7ae8f13700f87f1fa7a7be919eb.jpg" + }, + { + "filter": "actor=108521", + "id": "108521", + "role": "Anderson Cooper", + "tag": "Anderson Cooper", + "tagKey": "5d77683aeb5d26001f1e1e05", + "thumb": "https://metadata-static.plex.tv/people/5d77683aeb5d26001f1e1e05.jpg" + }, + { + "filter": "actor=140597", + "id": "140597", + "role": "River Barrier Naval Guard", + "tag": "Mackenro Alexander", + "tagKey": "5f3fc3333e5306003e55036c" + }, + { + "filter": "actor=140598", + "id": "140598", + "role": "Naval Officer", + "tag": "Kamaru Usman", + "tagKey": "5f4027e804a86500409fd230", + "thumb": "https://metadata-static.plex.tv/0/people/093884ef6b126272f1c56bb11c8f8f7c.jpg" + }, + { + "filter": "actor=123546", + "id": "123546", + "role": "M'Bele", + "tag": "T. Love", + "tagKey": "5d776b05fb0d55001f5592ec", + "thumb": "https://metadata-static.plex.tv/people/5d776b05fb0d55001f5592ec.jpg" + }, + { + "filter": "actor=117708", + "id": "117708", + "role": "Jabari Warrior", + "tag": "Floyd Anthony Johns Jr.", + "tagKey": "5d776b53ad5437001f79b9b6", + "thumb": "https://metadata-static.plex.tv/people/5d776b53ad5437001f79b9b6.jpg" + }, + { + "filter": "actor=140599", + "id": "140599", + "role": "Jabari Warrior", + "tag": "Jermaine Brantley", + "tagKey": "5e1649d661c6140040d7fc92", + "thumb": "https://metadata-static.plex.tv/people/5e1649d661c6140040d7fc92.jpg" + }, + { + "filter": "actor=140600", + "id": "140600", + "role": "Jabari Warrior", + "tag": "Granger Summerset II", + "tagKey": "5f406c5786422500428c4fd4" + }, + { + "filter": "actor=140601", + "id": "140601", + "role": "MIT Student", + "tag": "Luke Lenza", + "tagKey": "6370f552136ea8569790066d" + }, + { + "filter": "actor=140602", + "id": "140602", + "role": "Federal Agent", + "tag": "Alan Wells", + "tagKey": "5d776838103a2d001f5687ac", + "thumb": "https://metadata-static.plex.tv/people/5d776838103a2d001f5687ac.jpg" + }, + { + "filter": "actor=140603", + "id": "140603", + "role": "FBI Special Agent", + "tag": "Bill Barrett", + "tagKey": "6083d977444a60002da33701" + }, + { + "filter": "actor=140604", + "id": "140604", + "role": "Haitian School Kid", + "tag": "Lieiry J. Perez Escalera", + "tagKey": "6370f552136ea8569790066e" + }, + { + "filter": "actor=140605", + "id": "140605", + "role": "Haitian School Kid", + "tag": "Sevyn Hill", + "tagKey": "6370f552136ea8569790066f" + }, + { + "filter": "actor=140606", + "id": "140606", + "role": "Haitian School Kid", + "tag": "Gavin Macon", + "tagKey": "6370f552136ea85697900670" + }, + { + "filter": "actor=140607", + "id": "140607", + "role": "Haitian School Kid", + "tag": "Skylar Ebron", + "tagKey": "6370f552136ea85697900671" + }, + { + "filter": "actor=140608", + "id": "140608", + "role": "Haitian School Kid", + "tag": "Taylor Holmes", + "tagKey": "6370f552136ea85697900672" + }, + { + "filter": "actor=140609", + "id": "140609", + "role": "Talokanil Guard", + "tag": "Angela Cipra", + "tagKey": "6370f552136ea85697900673" + }, + { + "filter": "actor=140610", + "id": "140610", + "role": "Talokanil Guard", + "tag": "Faya Madrid", + "tagKey": "6370f552136ea85697900674" + }, + { + "filter": "actor=140611", + "id": "140611", + "role": "Female Mayan Elder", + "tag": "María Telón", + "tagKey": "5d77698c7a53e9001e6e936c", + "thumb": "https://image.tmdb.org/t/p/original/tlfwcpVCjSdYYEvLiiVe1jFAuhP.jpg" + }, + { + "filter": "actor=140612", + "id": "140612", + "role": "Namor's Mother", + "tag": "María Mercedes Coroy", + "tagKey": "5d776b69594b2b001e6d947e", + "thumb": "https://metadata-static.plex.tv/people/5d776b69594b2b001e6d947e.jpg" + }, + { + "filter": "actor=140613", + "id": "140613", + "role": "Shaman", + "tag": "Josué Maychi", + "tagKey": "5f3fbf3f1ae7100041fc8314", + "thumb": "https://metadata-static.plex.tv/2/people/28f970559b110170f1ad2e8cc1230453.jpg" + }, + { + "filter": "actor=96885", + "id": "96885", + "role": "Yucatan Elder", + "tag": "Sal Lopez", + "tagKey": "5d776827880197001ec90ae3", + "thumb": "https://metadata-static.plex.tv/8/people/802548390b27f594650892dfe554a135.jpg" + }, + { + "filter": "actor=140614", + "id": "140614", + "role": "Namor's Mother (Older)", + "tag": "Irma Estella La Guerre", + "tagKey": "5d77707631d95e001f1a2193", + "thumb": "https://metadata-static.plex.tv/0/people/09587182629b809b1054232b36f34035.jpg" + }, + { + "filter": "actor=140615", + "id": "140615", + "role": "Young Namor", + "tag": "Manuel Chavez", + "tagKey": "62ff4580b2cc0a7ab1f18d4f", + "thumb": "https://metadata-static.plex.tv/4/people/4dcea4d82e1a43bed4278eaab585ded8.jpg" + }, + { + "filter": "actor=140616", + "id": "140616", + "role": "Hacienda Owner", + "tag": "Leonardo Castro", + "tagKey": "619cf7ff4b44ca915078a945" + }, + { + "filter": "actor=123615", + "id": "123615", + "role": "Friar", + "tag": "Juan Carlos Cantu", + "tagKey": "5d77683c6f4521001ea9d503", + "thumb": "https://metadata-static.plex.tv/people/5d77683c6f4521001ea9d503.jpg" + }, + { + "filter": "actor=109021", + "id": "109021", + "role": "Fisherman", + "tag": "Shawntae Hughes", + "tagKey": "5e69c6d60fdbbd003de628b7", + "thumb": "https://metadata-static.plex.tv/people/5e69c6d60fdbbd003de628b7.jpg" + }, + { + "filter": "actor=140617", + "id": "140617", + "role": "Terrified Man", + "tag": "Corey Hibbert", + "tagKey": "5f3fe5a2bf3e560040b2fb56", + "thumb": "https://metadata-static.plex.tv/0/people/0e8b53f991c43876078e150491d1b4db.jpg" + }, + { + "filter": "actor=140618", + "id": "140618", + "role": "Wakandan Kid", + "tag": "Zaiden James", + "tagKey": "6370f552136ea85697900675" + }, + { + "filter": "actor=140619", + "id": "140619", + "role": "Naval Engineer", + "tag": "Aba Arthur", + "tagKey": "5d77688c9ab54400214e78fc" + }, + { + "filter": "actor=140620", + "id": "140620", + "role": "Flower Shop Owner", + "tag": "Délé Ogundiran", + "tagKey": "5d77683554f42c001f8c463e", + "thumb": "https://metadata-static.plex.tv/3/people/33e9eea633f784e47144ceb2602f7c19.jpg" + }, + { + "filter": "actor=140621", + "id": "140621", + "role": "Pete", + "tag": "Kevin Changaris", + "tagKey": "5e1653d310faa500400f8eaa", + "thumb": "https://metadata-static.plex.tv/d/people/d25013711d628aea3f0b29ebd2b0a5c3.jpg" + }, + { + "filter": "actor=140622", + "id": "140622", + "role": "Haitian Taxi Passenger", + "tag": "Valerio Dorvillen", + "tagKey": "6370f552136ea85697900676" + }, + { + "filter": "actor=140623", + "id": "140623", + "role": "Haitian Taxi Passenger", + "tag": "Don Castor", + "tagKey": "6370f552136ea85697900677" + }, + { + "filter": "actor=140624", + "id": "140624", + "role": "Haitian Taxi Passenger", + "tag": "Jonathan González Collins", + "tagKey": "6370f552136ea85697900678" + }, + { + "filter": "actor=140625", + "id": "140625", + "role": "Toussaint", + "tag": "Divine Love Konadu-Sun", + "tagKey": "6370f552136ea85697900679", + "thumb": "https://metadata-static.plex.tv/2/people/2a5a9c96ba51088f862e6cfe23509353.jpg" + }, + { + "filter": "actor=110217", + "id": "110217", + "role": "T'Challa / Black Panther (archive footage) (uncredited)", + "tag": "Chadwick Boseman", + "tagKey": "5d77690996b655001fdc8c8f", + "thumb": "https://metadata-static.plex.tv/d/people/d12e4d776c045ce4c8cba456a44e6fb3.jpg" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/crates/plex-api/tests/mocks/transcode/video_offline_refused.json b/crates/plex-api/tests/mocks/transcode/video_offline_refused.json new file mode 100644 index 00000000..7579630c --- /dev/null +++ b/crates/plex-api/tests/mocks/transcode/video_offline_refused.json @@ -0,0 +1,550 @@ +{ + "MediaContainer": { + "size": 1, + "allowSync": "1", + "directPlayDecisionCode": 1000, + "directPlayDecisionText": "Direct play OK.", + "generalDecisionCode": 1000, + "generalDecisionText": "Direct play OK.", + "identifier": "com.plexapp.plugins.library", + "librarySectionID": "1", + "librarySectionTitle": "Movies", + "librarySectionUUID": "a006b58966aa34f3c577ca3106e99c5d1d6ea8b1", + "mediaTagPrefix": "/system/bundle/media/flags/", + "mediaTagVersion": "1676975406", + "resourceSession": "{fa5429a4-98ed-4ae6-b140-d5e99a870938}", + "Metadata": [ + { + "addedAt": 1368992739, + "art": "/library/metadata/1036/art/1677122881", + "audienceRating": 4.9, + "audienceRatingImage": "rottentomatoes://image.rating.spilled", + "contentRating": "gb/12", + "duration": 6118122, + "guid": "plex://movie/5d776830f59e58002189813a", + "key": "/library/metadata/1036", + "lastViewedAt": 1368998603, + "librarySectionID": "1", + "librarySectionKey": "/library/sections/1", + "librarySectionTitle": "Movies", + "originallyAvailableAt": "2002-07-12", + "primaryExtraKey": "/library/metadata/145150", + "rating": 4.2, + "ratingImage": "rottentomatoes://image.rating.rotten", + "ratingKey": "1036", + "studio": "The Zanuck Company", + "summary": "In post-apocalyptic England, an American volunteer and a British survivor team up to fight off a brood of fire-breathing dragons seeking to return to global dominance after centuries of rest underground. The Brit -- leading a clan of survivors to hunt down the King of the Dragons -- has much at stake: His mother was killed by a dragon, but his love is still alive.", + "tagline": "Fight Fire With Fire", + "thumb": "/library/metadata/1036/thumb/1677122881", + "title": "Reign of Fire", + "type": "movie", + "updatedAt": 1677122881, + "viewCount": 1, + "year": 2002, + "Media": [ + { + "aspectRatio": "2.35", + "audioChannels": 2, + "audioCodec": "aac", + "audioProfile": "lc", + "bitrate": 2108, + "container": "mp4", + "duration": 6118122, + "has64bitOffsets": false, + "height": 820, + "id": "307448", + "optimizedForStreaming": true, + "videoCodec": "h264", + "videoFrameRate": "24p", + "videoProfile": "high", + "videoResolution": "1080", + "width": 1920, + "selected": true, + "Part": [ + { + "audioProfile": "lc", + "container": "mp4", + "deepAnalysisVersion": "6", + "duration": 6118122, + "file": "/mnt/media/Libraries/movies/Reign of Fire (2002)/Reign of Fire (2002).mp4", + "has64bitOffsets": false, + "id": "320566", + "indexes": "sd", + "key": "/library/parts/320566/1677272892/file.mp4", + "optimizedForStreaming": true, + "requiredBandwidths": "10847,4485,2434,2434,2434,2434,2434,2434", + "size": 1615558857, + "videoProfile": "high", + "decision": "directplay", + "selected": true, + "Stream": [ + { + "bitDepth": 8, + "bitrate": 2016, + "chromaLocation": "left", + "chromaSubsampling": "4:2:0", + "codec": "h264", + "codedHeight": 832, + "codedWidth": 1920, + "default": true, + "displayTitle": "1080p (H.264)", + "extendedDisplayTitle": "1080p (H.264)", + "frameRate": 23.976, + "hasScalingMatrix": false, + "height": 820, + "id": "566406", + "index": 0, + "level": 40, + "profile": "high", + "refFrames": 5, + "requiredBandwidths": "10755,4393,2342,2342,2342,2342,2342,2342", + "scanType": "progressive", + "streamIdentifier": "1", + "streamType": 1, + "width": 1920, + "location": "direct" + }, + { + "audioChannelLayout": "stereo", + "bitrate": 92, + "channels": 2, + "codec": "aac", + "default": true, + "displayTitle": "Unknown (AAC Stereo)", + "extendedDisplayTitle": "Unknown (AAC Stereo)", + "id": "566407", + "index": 1, + "profile": "lc", + "requiredBandwidths": "93,93,93,93,93,93,93,93", + "samplingRate": 48000, + "selected": true, + "streamIdentifier": "2", + "streamType": 2, + "location": "direct" + }, + { + "codec": "srt", + "displayTitle": "Unknown (SRT External)", + "extendedDisplayTitle": "Unknown (SRT External)", + "file": "/mnt/media/Libraries/movies/Reign of Fire (2002)/Reign of Fire (2002).srt", + "format": "srt", + "id": "566408", + "key": "/library/streams/566408", + "streamType": 3, + "location": "sidecar-subs" + } + ] + } + ] + } + ], + "Genre": [ + { + "filter": "genre=48", + "id": "48", + "tag": "Fantasy" + }, + { + "filter": "genre=128", + "id": "128", + "tag": "Thriller" + }, + { + "filter": "genre=39", + "id": "39", + "tag": "Action" + }, + { + "filter": "genre=130", + "id": "130", + "tag": "Adventure" + } + ], + "Director": [ + { + "filter": "director=92561", + "id": "92561", + "tag": "Rob Bowman" + } + ], + "Writer": [ + { + "filter": "writer=124916", + "id": "124916", + "tag": "Gregg Shabot" + } + ], + "Producer": [ + { + "filter": "producer=92586", + "id": "92586", + "tag": "Richard D. Zanuck" + }, + { + "filter": "producer=92587", + "id": "92587", + "tag": "Roger Birnbaum" + }, + { + "filter": "producer=92588", + "id": "92588", + "tag": "Gary Barber" + }, + { + "filter": "producer=92589", + "id": "92589", + "tag": "Lili Fini Zanuck" + } + ], + "Country": [ + { + "filter": "country=51039", + "id": "51039", + "tag": "Ireland" + }, + { + "filter": "country=113", + "id": "113", + "tag": "United Kingdom" + }, + { + "filter": "country=55636", + "id": "55636", + "tag": "United States of America" + } + ], + "Rating": [ + { + "image": "imdb://image.rating", + "type": "audience", + "value": "6.2" + }, + { + "image": "rottentomatoes://image.rating.rotten", + "type": "critic", + "value": "4.2" + }, + { + "image": "rottentomatoes://image.rating.spilled", + "type": "audience", + "value": "4.9" + }, + { + "image": "themoviedb://image.rating", + "type": "audience", + "value": "6.1" + } + ], + "Role": [ + { + "filter": "actor=89823", + "id": "89823", + "role": "Quinn Abercromby", + "tag": "Christian Bale", + "tagKey": "5d776825880197001ec9038f", + "thumb": "https://metadata-static.plex.tv/f/people/fde8f8b1be96957d9659bee97b0fab30.jpg" + }, + { + "filter": "actor=92563", + "id": "92563", + "role": "Denton Van Zan", + "tag": "Matthew McConaughey", + "tagKey": "5d7768287e9a3c0020c6adeb", + "thumb": "https://metadata-static.plex.tv/8/people/8750c9fb7d18bbb37ac2a14e13b81b3a.jpg" + }, + { + "filter": "actor=92564", + "id": "92564", + "role": "Alex Jensen", + "tag": "Izabella Scorupco", + "tagKey": "5d77682854c0f0001f301f75", + "thumb": "https://metadata-static.plex.tv/d/people/d429e638a59b28634ec6af3140960d2e.jpg" + }, + { + "filter": "actor=92565", + "id": "92565", + "role": "Creedy", + "tag": "Gerard Butler", + "tagKey": "5d776827103a2d001f564587", + "thumb": "https://metadata-static.plex.tv/d/people/dbc4b9437e4ce8025baaae2d732b332c.jpg" + }, + { + "filter": "actor=89320", + "id": "89320", + "role": "Ajay", + "tag": "Alexander Siddig", + "tagKey": "5d7768253c3c2a001fbca997", + "thumb": "https://metadata-static.plex.tv/3/people/361ac76f8a192a9c0ac3456b57bd247d.jpg" + }, + { + "filter": "actor=92566", + "id": "92566", + "role": "Jared Wilke", + "tag": "Scott Moutter", + "tagKey": "5d776830f59e58002189824c", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e58002189824c.jpg" + }, + { + "filter": "actor=92567", + "id": "92567", + "role": "Eddie Stax", + "tag": "David Kennedy", + "tagKey": "5d776824103a2d001f563af2", + "thumb": "https://metadata-static.plex.tv/people/5d776824103a2d001f563af2.jpg" + }, + { + "filter": "actor=92568", + "id": "92568", + "role": "Barlow", + "tag": "Ned Dennehy", + "tagKey": "5d776830f59e58002189824d", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e58002189824d.jpg" + }, + { + "filter": "actor=92569", + "id": "92569", + "role": "Devon", + "tag": "Rory Keenan", + "tagKey": "5d776830f59e58002189824e", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e58002189824e.jpg" + }, + { + "filter": "actor=92570", + "id": "92570", + "role": "Gideon", + "tag": "Terence Maynard", + "tagKey": "5d776830f59e58002189824f", + "thumb": "https://metadata-static.plex.tv/c/people/c575b6dc7431d4e9531e0b0b36964a57.jpg" + }, + { + "filter": "actor=92571", + "id": "92571", + "role": "Goosh", + "tag": "Doug Cockle", + "tagKey": "5d77682a103a2d001f56544b", + "thumb": "https://metadata-static.plex.tv/people/5d77682a103a2d001f56544b.jpg" + }, + { + "filter": "actor=92572", + "id": "92572", + "role": "Burke (Tito)", + "tag": "Randall Carlton", + "tagKey": "5d776830f59e580021898250" + }, + { + "filter": "actor=131769", + "id": "131769", + "role": "Mead", + "tag": "Chris Kelly", + "tagKey": "5f402a2c864225004283df99" + }, + { + "filter": "actor=92574", + "id": "92574", + "role": "Young Quinn", + "tag": "Ben Thornton", + "tagKey": "5d776830f59e580021898252", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e580021898252.jpg" + }, + { + "filter": "actor=92575", + "id": "92575", + "role": "Karen Abercromby", + "tag": "Alice Krige", + "tagKey": "5d7768256f4521001ea989e5", + "thumb": "https://metadata-static.plex.tv/a/people/a6a97be93e67ef006335a3053cebbccc.jpg" + }, + { + "filter": "actor=124918", + "id": "124918", + "role": "Stuart", + "tag": "Malcolm Douglas", + "tagKey": "6323ac6993de28374b3036a6", + "thumb": "https://metadata-static.plex.tv/c/people/c14ed37571ff876919c23eb2afc6bd68.jpg" + }, + { + "filter": "actor=92577", + "id": "92577", + "role": "Construction Worker #1", + "tag": "Berts Folan", + "tagKey": "5d776830f59e580021898254" + }, + { + "filter": "actor=92578", + "id": "92578", + "role": "Construction Worker #2", + "tag": "Brian McGuinness", + "tagKey": "5d776830f59e580021898255", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e580021898255.jpg" + }, + { + "filter": "actor=92579", + "id": "92579", + "role": "Construction Worker #3", + "tag": "Barry Barnes", + "tagKey": "5d776830f59e580021898256", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e580021898256.jpg" + }, + { + "filter": "actor=92580", + "id": "92580", + "role": "Jerry", + "tag": "Gerry O'Brien", + "tagKey": "5d776830f59e580021898257", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e580021898257.jpg" + }, + { + "filter": "actor=92581", + "id": "92581", + "role": "Lin", + "tag": "Laura Pyper", + "tagKey": "5d776830f59e580021898258", + "thumb": "https://metadata-static.plex.tv/5/people/5ccf760be76f2d9a77fc6692a333461b.jpg" + }, + { + "filter": "actor=92582", + "id": "92582", + "role": "Rachel", + "tag": "Maree Duffy", + "tagKey": "5d776830f59e580021898259", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e580021898259.jpg" + }, + { + "filter": "actor=92583", + "id": "92583", + "role": "Jefferson", + "tag": "David Garrick", + "tagKey": "5d776830f59e58002189825a", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e58002189825a.jpg" + }, + { + "filter": "actor=92584", + "id": "92584", + "role": "Rose", + "tag": "Anne Maria McAuley", + "tagKey": "5d776830f59e58002189825b", + "thumb": "https://metadata-static.plex.tv/2/people/22b4d2774882f886b8b552ed35d6e61f.jpg" + }, + { + "filter": "actor=92585", + "id": "92585", + "role": "Jess", + "tag": "Dessie Gallagher", + "tagKey": "5d776830f59e58002189825c", + "thumb": "https://metadata-static.plex.tv/people/5d776830f59e58002189825c.jpg" + }, + { + "filter": "actor=115830", + "id": "115830", + "role": "Kid (uncredited)", + "tag": "Jack Gleeson", + "tagKey": "5d77686a374a5b001fec4f9b", + "thumb": "https://metadata-static.plex.tv/9/people/924ab7470eee26ca525ec2499ec4a6c8.jpg" + } + ], + "Similar": [ + { + "filter": "similar=49276", + "id": "49276", + "tag": "The 6th Day" + }, + { + "filter": "similar=51030", + "id": "51030", + "tag": "The 13th Warrior" + }, + { + "filter": "similar=50430", + "id": "50430", + "tag": "The One" + }, + { + "filter": "similar=53334", + "id": "53334", + "tag": "Outlander" + }, + { + "filter": "similar=49161", + "id": "49161", + "tag": "Sky Captain and the World of Tomorrow" + }, + { + "filter": "similar=51643", + "id": "51643", + "tag": "Paycheck" + }, + { + "filter": "similar=52522", + "id": "52522", + "tag": "DragonHeart" + }, + { + "filter": "similar=51111", + "id": "51111", + "tag": "The Time Machine" + }, + { + "filter": "similar=51434", + "id": "51434", + "tag": "Hollow Man" + }, + { + "filter": "similar=49265", + "id": "49265", + "tag": "Broken Arrow" + }, + { + "filter": "similar=49152", + "id": "49152", + "tag": "Æon Flux" + }, + { + "filter": "similar=51440", + "id": "51440", + "tag": "Sphere" + }, + { + "filter": "similar=49151", + "id": "49151", + "tag": "Final Fantasy: The Spirits Within" + }, + { + "filter": "similar=51441", + "id": "51441", + "tag": "Outbreak" + }, + { + "filter": "similar=50909", + "id": "50909", + "tag": "The League of Extraordinary Gentlemen" + }, + { + "filter": "similar=53335", + "id": "53335", + "tag": "Waterworld" + }, + { + "filter": "similar=53336", + "id": "53336", + "tag": "Doom" + }, + { + "filter": "similar=49372", + "id": "49372", + "tag": "Godzilla" + }, + { + "filter": "similar=49153", + "id": "49153", + "tag": "Daybreakers" + }, + { + "filter": "similar=49267", + "id": "49267", + "tag": "Payback" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/crates/plex-api/tests/mocks/transcode/video_sessions.json b/crates/plex-api/tests/mocks/transcode/video_sessions.json new file mode 100644 index 00000000..045fec62 --- /dev/null +++ b/crates/plex-api/tests/mocks/transcode/video_sessions.json @@ -0,0 +1,33 @@ +{ + "MediaContainer": { + "size": 1, + "TranscodeSession": [ + { + "key": "6c624c15015644a2801002562d2c33e4fdbf54cb", + "throttled": false, + "complete": false, + "progress": 2.5999999046325685, + "size": 33554480, + "speed": 1.2000000476837159, + "error": false, + "duration": 9678688, + "remaining": 8104, + "context": "static", + "sourceVideoCodec": "hevc", + "sourceAudioCodec": "eac3", + "videoDecision": "transcode", + "audioDecision": "transcode", + "subtitleDecision": "burn", + "protocol": "http", + "container": "mkv", + "videoCodec": "h264", + "audioCodec": "mp3", + "audioChannels": 2, + "width": 1280, + "height": 720, + "transcodeHwRequested": true, + "offlineTranscode": true + } + ] + } +} \ No newline at end of file diff --git a/crates/plex-api/tests/transcode.rs b/crates/plex-api/tests/transcode.rs new file mode 100644 index 00000000..c9179f4f --- /dev/null +++ b/crates/plex-api/tests/transcode.rs @@ -0,0 +1,1783 @@ +mod fixtures; + +mod offline { + use std::collections::HashMap; + + use super::fixtures::offline::server::*; + use super::fixtures::offline::Mocked; + use httpmock::prelude::HttpMockRequest; + use httpmock::Method::GET; + use plex_api::AudioCodec; + use plex_api::ContainerFormat; + use plex_api::Decision; + use plex_api::Protocol; + use plex_api::Server; + use plex_api::VideoCodec; + + // Expands a profile query parameter into the list of settings. + fn expand_profile(req: &HttpMockRequest) -> HashMap>> { + let param = req + .query_params + .as_ref() + .unwrap() + .iter() + .filter_map(|(n, v)| { + if n == "X-Plex-Client-Profile-Extra" { + Some(v) + } else { + None + } + }) + .next() + .unwrap(); + + let mut settings: HashMap>> = HashMap::new(); + for setting in param.split('+') { + if setting.ends_with(')') { + if let Some(idx) = setting.find('(') { + let setting_name = setting[0..idx].to_string(); + let params: HashMap = setting[idx + 1..setting.len() - 1] + // Split up the parameters + .split('&') + .filter_map(|v| { + // Split into name=value + v.find('=') + .map(|index| (v[0..index].to_string(), v[index + 1..].to_string())) + }) + .collect(); + + if let Some(list) = settings.get_mut(&setting_name) { + list.push(params); + } else { + settings.insert(setting_name, vec![params]); + } + } + } + } + + settings + } + + fn assert_setting_count( + settings: &HashMap>>, + name: &str, + expected: usize, + ) { + if let Some(s) = settings.get(name) { + assert_eq!(s.len(), expected); + } else { + assert_eq!(0, expected); + } + } + + fn assert_setting( + settings: &HashMap>>, + name: &str, + values: &[(&str, &str)], + ) { + let settings = if let Some(s) = settings.get(name) { + s + } else { + panic!("Failed to find match for {values:#?} in []") + }; + + for setting in settings { + if setting.len() != values.len() { + continue; + } + + let mut matched = true; + for (name, value) in values { + if setting.get(*name) != Some(&value.to_string()) { + matched = false; + } + } + + if matched { + return; + } + } + + panic!("Failed to find match for {values:#?} in {settings:#?}") + } + + #[plex_api_test_helper::offline_test] + async fn transcode_sessions(#[future] server_authenticated: Mocked) { + let server = server_authenticated.await; + let (server, mock_server) = server.split(); + + let mut m = mock_server.mock(|when, then| { + when.method(GET).path("/transcode/sessions"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/video_sessions.json"); + }); + + let sessions = server.transcode_sessions().await.unwrap(); + m.assert(); + m.delete(); + + assert_eq!(sessions.len(), 1); + let session = &sessions[0]; + assert!(session.is_offline()); + assert_eq!( + session.session_id(), + "6c624c15015644a2801002562d2c33e4fdbf54cb" + ); + assert_eq!(session.container(), ContainerFormat::Mkv); + assert_eq!(session.protocol(), Protocol::Http); + assert_eq!( + session.audio_transcode(), + Some((Decision::Transcode, AudioCodec::Mp3)) + ); + assert_eq!( + session.video_transcode(), + Some((Decision::Transcode, VideoCodec::H264)) + ); + + let mut m = mock_server.mock(|when, then| { + when.method(GET).path("/transcode/sessions"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/music_sessions.json"); + }); + + let sessions = server.transcode_sessions().await.unwrap(); + m.assert(); + m.delete(); + + assert_eq!(sessions.len(), 1); + let session = &sessions[0]; + assert!(!session.is_offline()); + assert_eq!(session.session_id(), "dfghtybntbretybrtyb"); + assert_eq!(session.container(), ContainerFormat::Mp4); + assert_eq!(session.protocol(), Protocol::Dash); + assert_eq!( + session.audio_transcode(), + Some((Decision::Copy, AudioCodec::Mp3)) + ); + assert_eq!(session.video_transcode(), None); + + let mut m = mock_server.mock(|when, then| { + when.method(GET) + .path("/transcode/sessions/dfghtybntbretybrtyb"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/music_sessions.json"); + }); + + let session = server + .transcode_session("dfghtybntbretybrtyb") + .await + .unwrap(); + m.assert(); + m.delete(); + + assert!(!session.is_offline()); + assert_eq!(session.session_id(), "dfghtybntbretybrtyb"); + assert_eq!(session.container(), ContainerFormat::Mp4); + assert_eq!(session.protocol(), Protocol::Dash); + assert_eq!( + session.audio_transcode(), + Some((Decision::Copy, AudioCodec::Mp3)) + ); + assert_eq!(session.video_transcode(), None); + + let mut m = mock_server.mock(|when, then| { + when.method(GET).path("/transcode/sessions/gfbrgbrbrfber"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/empty_sessions.json"); + }); + + let error = server + .transcode_session("gfbrgbrbrfber") + .await + .err() + .unwrap(); + m.assert(); + m.delete(); + + assert!(matches!(error, plex_api::Error::ItemNotFound)); + } + + mod movie { + use super::*; + use plex_api::AudioCodec; + use plex_api::AudioSetting; + use plex_api::Constraint; + use plex_api::ContainerFormat; + use plex_api::Decision; + use plex_api::VideoCodec; + use plex_api::VideoSetting; + use plex_api::VideoTranscodeOptions; + use plex_api::{MediaItem, Movie, Protocol, Server}; + + #[plex_api_test_helper::offline_test] + async fn transcode_profile_params(#[future] server_authenticated: Mocked) { + let server = server_authenticated.await; + let (server, mock_server) = server.split(); + + let mut m = mock_server.mock(|when, then| { + when.method(GET).path("/library/metadata/159637"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/metadata_159637.json"); + }); + + let item: Movie = server.item_by_id(159637).await.unwrap().try_into().unwrap(); + m.assert(); + m.delete(); + + let media = &item.media()[0]; + let part = &media.parts()[0]; + + let mut m = mock_server.mock(|when, then| { + when.method(GET) + .path("/video/:/transcode/universal/decision") + .query_param_exists("session") + .query_param("path", "/library/metadata/159637") + .query_param("mediaIndex", "0") + .query_param("partIndex", "0") + .query_param("directPlay", "0") + .query_param("directStream", "1") + .query_param("directStreamAudio", "1") + .query_param("context", "streaming") + .query_param("maxVideoBitrate", "2000") + .query_param("videoBitrate", "2000") + .query_param("videoResolution", "1280x720") + .query_param("subtitles", "burn") + .query_param("protocol", "dash") + .query_param_exists("X-Plex-Client-Profile-Extra") + .matches(|req| { + let settings = expand_profile(req); + + assert_setting_count(&settings, "add-transcode-target", 1); + assert_setting_count(&settings, "add-direct-play-profile", 0); + assert_setting_count(&settings, "append-transcode-target-codec", 1); + assert_setting_count(&settings, "add-transcode-target-audio-codec", 2); + assert_setting_count(&settings, "add-limitation", 0); + + assert_setting( + &settings, + "add-transcode-target", + &[ + ("type", "videoProfile"), + ("context", "streaming"), + ("protocol", "dash"), + ("container", "mp4"), + ("videoCodec", "h264"), + ("audioCodec", "aac,mp3"), + ], + ); + + assert_setting( + &settings, + "append-transcode-target-codec", + &[ + ("type", "videoProfile"), + ("context", "streaming"), + ("protocol", "dash"), + ("videoCodec", "h264"), + ], + ); + + assert_setting( + &settings, + "add-transcode-target-audio-codec", + &[ + ("type", "videoProfile"), + ("context", "streaming"), + ("protocol", "dash"), + ("audioCodec", "aac"), + ], + ); + + assert_setting( + &settings, + "add-transcode-target-audio-codec", + &[ + ("type", "videoProfile"), + ("context", "streaming"), + ("protocol", "dash"), + ("audioCodec", "mp3"), + ], + ); + + true + }); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/video_dash_h264_mp3.json"); + }); + + item.create_streaming_session( + part, + Protocol::Dash, + VideoTranscodeOptions { + bitrate: 2000, + width: 1280, + height: 720, + burn_subtitles: true, + video_codecs: vec![VideoCodec::H264], + audio_codecs: vec![AudioCodec::Aac, AudioCodec::Mp3], + ..Default::default() + }, + ) + .await + .unwrap(); + m.assert(); + m.delete(); + + let media = &item.media()[1]; + let part = &media.parts()[0]; + + let mut m = mock_server.mock(|when, then| { + when.method(GET) + .path("/video/:/transcode/universal/decision") + .query_param_exists("session") + .query_param("path", "/library/metadata/159637") + .query_param("mediaIndex", "1") + .query_param("partIndex", "0") + .query_param("directPlay", "0") + .query_param("directStream", "1") + .query_param("directStreamAudio", "1") + .query_param("context", "streaming") + .query_param("maxVideoBitrate", "1000") + .query_param("videoBitrate", "1000") + .query_param("videoResolution", "1920x1080") + .query_param("protocol", "hls") + .query_param_exists("X-Plex-Client-Profile-Extra") + .matches(|req| { + let settings = expand_profile(req); + + assert_setting_count(&settings, "add-transcode-target", 1); + assert_setting_count(&settings, "add-direct-play-profile", 0); + assert_setting_count(&settings, "append-transcode-target-codec", 2); + assert_setting_count(&settings, "add-transcode-target-audio-codec", 1); + assert_setting_count(&settings, "add-limitation", 3); + + assert_setting( + &settings, + "add-transcode-target", + &[ + ("type", "videoProfile"), + ("context", "streaming"), + ("protocol", "hls"), + ("container", "mpegts"), + ("videoCodec", "vp9,vp8"), + ("audioCodec", "eac3"), + ], + ); + + assert_setting( + &settings, + "append-transcode-target-codec", + &[ + ("type", "videoProfile"), + ("context", "streaming"), + ("protocol", "hls"), + ("videoCodec", "vp9"), + ], + ); + + assert_setting( + &settings, + "append-transcode-target-codec", + &[ + ("type", "videoProfile"), + ("context", "streaming"), + ("protocol", "hls"), + ("videoCodec", "vp8"), + ], + ); + + assert_setting( + &settings, + "add-transcode-target-audio-codec", + &[ + ("type", "videoProfile"), + ("context", "streaming"), + ("protocol", "hls"), + ("audioCodec", "eac3"), + ], + ); + + assert_setting( + &settings, + "add-limitation", + &[ + ("scope", "videoCodec"), + ("scopeName", "*"), + ("name", "video.bitDepth"), + ("type", "upperBound"), + ("value", "8"), + ], + ); + + assert_setting( + &settings, + "add-limitation", + &[ + ("scope", "videoCodec"), + ("scopeName", "vp9"), + ("name", "video.profile"), + ("type", "match"), + ("list", "main|baseline"), + ], + ); + + assert_setting( + &settings, + "add-limitation", + &[ + ("scope", "videoAudioCodec"), + ("scopeName", "*"), + ("name", "audio.channels"), + ("type", "upperBound"), + ("value", "2"), + ], + ); + + true + }); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/video_hls_vp9_pcm.json"); + }); + + item.create_streaming_session( + part, + Protocol::Hls, + VideoTranscodeOptions { + bitrate: 1000, + width: 1920, + height: 1080, + video_codecs: vec![VideoCodec::Vp9, VideoCodec::Vp8], + audio_codecs: vec![AudioCodec::Eac3], + video_limitations: vec![ + (VideoSetting::BitDepth, Constraint::Max("8".to_string())).into(), + ( + VideoCodec::Vp9, + VideoSetting::Profile, + Constraint::Match(vec!["main".to_string(), "baseline".to_string()]), + ) + .into(), + ], + audio_limitations: vec![( + AudioSetting::Channels, + Constraint::Max("2".to_string()), + ) + .into()], + ..Default::default() + }, + ) + .await + .unwrap(); + m.assert(); + m.delete(); + + let media = &item.media()[1]; + let part = &media.parts()[1]; + + let mut m = mock_server.mock(|when, then| { + when.method(GET) + .path("/video/:/transcode/universal/decision") + .query_param_exists("session") + .query_param("path", "/library/metadata/159637") + .query_param("mediaIndex", "1") + .query_param("partIndex", "1") + .query_param("directPlay", "1") + .query_param("directStream", "1") + .query_param("directStreamAudio", "1") + .query_param("context", "static") + .query_param("maxVideoBitrate", "2000") + .query_param("videoBitrate", "2000") + .query_param("videoResolution", "1280x720") + .query_param("subtitles", "burn") + .query_param("offlineTranscode", "1") + .query_param_exists("X-Plex-Client-Profile-Extra") + .matches(|req| { + let settings = expand_profile(req); + + assert_setting_count(&settings, "add-transcode-target", 2); + assert_setting_count(&settings, "add-direct-play-profile", 2); + assert_setting_count(&settings, "append-transcode-target-codec", 1); + assert_setting_count(&settings, "add-transcode-target-audio-codec", 1); + assert_setting_count(&settings, "add-limitation", 0); + + assert_setting( + &settings, + "add-transcode-target", + &[ + ("type", "videoProfile"), + ("context", "static"), + ("protocol", "http"), + ("container", "mp4"), + ("videoCodec", "h264"), + ("audioCodec", "aac"), + ], + ); + + assert_setting( + &settings, + "add-transcode-target", + &[ + ("type", "videoProfile"), + ("context", "static"), + ("protocol", "http"), + ("container", "mkv"), + ("videoCodec", "h264"), + ("audioCodec", "aac"), + ], + ); + + assert_setting( + &settings, + "add-direct-play-profile", + &[ + ("type", "videoProfile"), + ("container", "mp4"), + ("videoCodec", "h264"), + ("audioCodec", "aac"), + ], + ); + + assert_setting( + &settings, + "add-direct-play-profile", + &[ + ("type", "videoProfile"), + ("container", "mkv"), + ("videoCodec", "h264"), + ("audioCodec", "aac"), + ], + ); + + assert_setting( + &settings, + "append-transcode-target-codec", + &[ + ("type", "videoProfile"), + ("context", "static"), + ("protocol", "http"), + ("videoCodec", "h264"), + ], + ); + + assert_setting( + &settings, + "add-transcode-target-audio-codec", + &[ + ("type", "videoProfile"), + ("context", "static"), + ("protocol", "http"), + ("audioCodec", "aac"), + ], + ); + + true + }); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/video_offline_h264_mp3.json"); + }); + + item.create_download_session( + part, + VideoTranscodeOptions { + bitrate: 2000, + width: 1280, + height: 720, + burn_subtitles: true, + video_codecs: vec![VideoCodec::H264], + audio_codecs: vec![AudioCodec::Aac], + ..Default::default() + }, + ) + .await + .unwrap(); + m.assert(); + m.delete(); + } + + #[plex_api_test_helper::offline_test] + async fn transcode_decision(#[future] server_authenticated: Mocked) { + let server = server_authenticated.await; + let (server, mock_server) = server.split(); + + let mut m = mock_server.mock(|when, then| { + when.method(GET).path("/library/metadata/159637"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/metadata_159637.json"); + }); + + let item: Movie = server.item_by_id(159637).await.unwrap().try_into().unwrap(); + m.assert(); + m.delete(); + + let media = &item.media()[0]; + let part = &media.parts()[0]; + + let mut m = mock_server.mock(|when, then| { + when.method(GET) + .path("/video/:/transcode/universal/decision"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/video_dash_h264_mp3.json"); + }); + + let session = item + .create_streaming_session(part, Protocol::Dash, Default::default()) + .await + .unwrap(); + m.assert(); + m.delete(); + + assert!(!session.is_offline()); + assert_eq!(session.container(), ContainerFormat::Mp4); + assert_eq!(session.protocol(), Protocol::Dash); + assert_eq!( + session.audio_transcode(), + Some((Decision::Transcode, AudioCodec::Mp3)) + ); + assert_eq!( + session.video_transcode(), + Some((Decision::Transcode, VideoCodec::H264)) + ); + + let mut m = mock_server.mock(|when, then| { + when.method(GET) + .path("/video/:/transcode/universal/decision"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/video_dash_h265_aac.json"); + }); + + let session = item + .create_streaming_session(part, Protocol::Dash, Default::default()) + .await + .unwrap(); + m.assert(); + m.delete(); + + assert!(!session.is_offline()); + assert_eq!(session.container(), ContainerFormat::Mp4); + assert_eq!(session.protocol(), Protocol::Dash); + assert_eq!( + session.audio_transcode(), + Some((Decision::Transcode, AudioCodec::Aac)) + ); + assert_eq!( + session.video_transcode(), + Some((Decision::Copy, VideoCodec::Hevc)) + ); + + let mut m = mock_server.mock(|when, then| { + when.method(GET) + .path("/video/:/transcode/universal/decision"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/video_hls_vp9_pcm.json"); + }); + + let session = item + .create_streaming_session(part, Protocol::Hls, Default::default()) + .await + .unwrap(); + m.assert(); + m.delete(); + + assert!(!session.is_offline()); + assert_eq!(session.container(), ContainerFormat::MpegTs); + assert_eq!(session.protocol(), Protocol::Hls); + assert_eq!( + session.audio_transcode(), + Some((Decision::Copy, AudioCodec::Pcm)) + ); + assert_eq!( + session.video_transcode(), + Some((Decision::Transcode, VideoCodec::Vp9)) + ); + + let mut m = mock_server.mock(|when, then| { + when.method(GET) + .path("/video/:/transcode/universal/decision"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/video_hls_vp9_pcm.json"); + }); + + let error = item + .create_streaming_session(part, Protocol::Dash, Default::default()) + .await + .err() + .unwrap(); + m.assert(); + m.delete(); + + if let plex_api::Error::TranscodeError(message) = error { + assert_eq!(message, "Server returned an invalid protocol."); + } else { + panic!("Unexpected error {error}"); + } + + let mut m = mock_server.mock(|when, then| { + when.method(GET) + .path("/video/:/transcode/universal/decision"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/video_dash_h264_mp3.json"); + }); + + let error = item + .create_streaming_session(part, Protocol::Hls, Default::default()) + .await + .err() + .unwrap(); + m.assert(); + m.delete(); + + if let plex_api::Error::TranscodeError(message) = error { + assert_eq!(message, "Server returned an invalid protocol."); + } else { + panic!("Unexpected error {error}"); + } + + let mut m = mock_server.mock(|when, then| { + when.method(GET) + .path("/video/:/transcode/universal/decision"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/video_offline_h264_mp3.json"); + }); + + let session = item + .create_download_session(part, Default::default()) + .await + .unwrap(); + m.assert(); + m.delete(); + + assert!(session.is_offline()); + assert_eq!(session.container(), ContainerFormat::Mp4); + assert_eq!(session.protocol(), Protocol::Http); + assert_eq!( + session.audio_transcode(), + Some((Decision::Transcode, AudioCodec::Mp3)) + ); + assert_eq!( + session.video_transcode(), + Some((Decision::Transcode, VideoCodec::H264)) + ); + + let mut m = mock_server.mock(|when, then| { + when.method(GET).path("/library/metadata/1036"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/metadata_1036.json"); + }); + + let item: Movie = server.item_by_id(1036).await.unwrap().try_into().unwrap(); + m.assert(); + m.delete(); + + let media = &item.media()[0]; + let part = &media.parts()[0]; + + let mut m = mock_server.mock(|when, then| { + when.method(GET) + .path("/video/:/transcode/universal/decision"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/video_offline_refused.json"); + }); + + let error = item + .create_download_session(part, Default::default()) + .await + .err() + .unwrap(); + m.assert(); + m.delete(); + + assert!(matches!(error, plex_api::Error::TranscodeRefused)); + } + } + + mod music { + use super::*; + use plex_api::AudioCodec; + use plex_api::AudioSetting; + use plex_api::Constraint; + use plex_api::ContainerFormat; + use plex_api::Decision; + use plex_api::MusicTranscodeOptions; + use plex_api::Track; + use plex_api::{MediaItem, Protocol, Server}; + + #[plex_api_test_helper::offline_test] + async fn transcode_profile_params(#[future] server_authenticated: Mocked) { + let server = server_authenticated.await; + let (server, mock_server) = server.split(); + + let mut m = mock_server.mock(|when, then| { + when.method(GET).path("/library/metadata/157786"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/metadata_157786.json"); + }); + + let item: Track = server.item_by_id(157786).await.unwrap().try_into().unwrap(); + m.assert(); + m.delete(); + + let media = &item.media()[0]; + let part = &media.parts()[0]; + + let mut m = mock_server.mock(|when, then| { + when.method(GET) + .path("/video/:/transcode/universal/decision") + .query_param_exists("session") + .query_param("path", "/library/metadata/157786") + .query_param("mediaIndex", "0") + .query_param("partIndex", "0") + .query_param("directPlay", "0") + .query_param("directStream", "1") + .query_param("directStreamAudio", "1") + .query_param("context", "streaming") + .query_param("musicBitrate", "192") + .query_param("protocol", "dash") + .query_param_exists("X-Plex-Client-Profile-Extra") + .matches(|req| { + let settings = expand_profile(req); + + assert_setting_count(&settings, "add-transcode-target", 1); + assert_setting_count(&settings, "add-direct-play-profile", 0); + assert_setting_count(&settings, "append-transcode-target-codec", 0); + assert_setting_count(&settings, "add-transcode-target-audio-codec", 0); + assert_setting_count(&settings, "add-limitation", 1); + + assert_setting( + &settings, + "add-transcode-target", + &[ + ("type", "musicProfile"), + ("context", "streaming"), + ("protocol", "dash"), + ("container", "mp4"), + ("audioCodec", "mp3,vorbis"), + ], + ); + + assert_setting( + &settings, + "add-limitation", + &[ + ("scope", "audioCodec"), + ("scopeName", "*"), + ("name", "audio.channels"), + ("type", "upperBound"), + ("value", "2"), + ], + ); + + true + }); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/video_dash_h264_mp3.json"); + }); + + item.create_streaming_session( + part, + Protocol::Dash, + MusicTranscodeOptions { + bitrate: 192, + codecs: vec![AudioCodec::Mp3, AudioCodec::Vorbis], + limitations: vec![ + (AudioSetting::Channels, Constraint::Max("2".to_string())).into() + ], + ..Default::default() + }, + ) + .await + .unwrap(); + m.assert(); + m.delete(); + } + + #[plex_api_test_helper::offline_test] + async fn transcode_decision(#[future] server_authenticated: Mocked) { + let server = server_authenticated.await; + let (server, mock_server) = server.split(); + + let mut m = mock_server.mock(|when, then| { + when.method(GET).path("/library/metadata/157786"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/metadata_157786.json"); + }); + + let item: Track = server.item_by_id(157786).await.unwrap().try_into().unwrap(); + m.assert(); + m.delete(); + + let media = &item.media()[0]; + let part = &media.parts()[0]; + + let mut m = mock_server.mock(|when, then| { + when.method(GET) + .path("/video/:/transcode/universal/decision"); + then.status(200) + .header("content-type", "text/json") + .body_from_file("tests/mocks/transcode/music_mp3.json"); + }); + + let session = item + .create_streaming_session(part, Protocol::Dash, Default::default()) + .await + .unwrap(); + m.assert(); + m.delete(); + + assert!(!session.is_offline()); + assert_eq!(session.container(), ContainerFormat::Mp4); + assert_eq!(session.protocol(), Protocol::Dash); + assert_eq!( + session.audio_transcode(), + Some((Decision::Transcode, AudioCodec::Mp3)) + ); + assert_eq!(session.video_transcode(), None); + } + } +} + +mod online { + use std::{thread::sleep, time::Duration}; + + use futures::Future; + use plex_api::{ + AudioCodec, ContainerFormat, Decision, HttpClientBuilder, Protocol, Server, + TranscodeSession, VideoCodec, + }; + + // Delays up to 5 seconds for the predicate to return true. Useful for + // waiting on the server to complete some operation. + async fn wait_for(mut predicate: C) + where + C: FnMut() -> F, + F: Future, + { + for _ in 0..10 { + if predicate().await { + return; + } + + sleep(Duration::from_millis(500)); + } + + panic!("Timeout exceeded"); + } + + /// Generates a "Generic" client. + async fn generify(server: Server) -> Server { + let client = server.client().to_owned(); + + // A web client uses the dash protocol for transcoding. + let client = HttpClientBuilder::from(client) + .set_x_plex_platform("Generic".to_string()) + .build() + .unwrap(); + + let server = Server::new(server.client().api_url.clone(), client) + .await + .unwrap(); + + verify_no_sessions(&server).await; + + server + } + + async fn verify_no_sessions(server: &Server) { + let sessions = server.transcode_sessions().await.unwrap(); + assert_eq!(sessions.len(), 0); + } + + /// Checks the session was correct. + fn verify_session( + session: &TranscodeSession, + protocol: Protocol, + container: ContainerFormat, + audio: Option<(Decision, AudioCodec)>, + video: Option<(Decision, VideoCodec)>, + ) { + assert_eq!(session.is_offline(), protocol == Protocol::Http); + assert_eq!(session.protocol(), protocol); + assert_eq!(session.container(), container); + assert_eq!(session.audio_transcode(), audio); + assert_eq!(session.video_transcode(), video); + } + + /// Checks the server lists a single session matching the one passed. + async fn verify_remote_sessions(server: &Server, session: &TranscodeSession) { + // It can take a few moments for the session to appear. + wait_for(|| async { + let sessions = server.transcode_sessions().await.unwrap(); + !sessions.is_empty() + }) + .await; + + let sessions = server.transcode_sessions().await.unwrap(); + assert_eq!(sessions.len(), 1); + + let remote = &sessions[0]; + assert_eq!(remote.session_id(), session.session_id()); + assert_eq!(remote.is_offline(), session.is_offline()); + assert_eq!(remote.protocol(), session.protocol()); + assert_eq!(remote.container(), session.container()); + assert_eq!(remote.audio_transcode(), session.audio_transcode()); + assert_eq!(remote.video_transcode(), session.video_transcode()); + + let remote = server + .transcode_session(session.session_id()) + .await + .unwrap(); + + assert_eq!(remote.session_id(), session.session_id()); + assert_eq!(remote.is_offline(), session.is_offline()); + assert_eq!(remote.protocol(), session.protocol()); + assert_eq!(remote.container(), session.container()); + assert_eq!(remote.audio_transcode(), session.audio_transcode()); + assert_eq!(remote.video_transcode(), session.video_transcode()); + } + + /// Cancels the session and verifies it is gone from the server. + async fn cancel(server: &Server, session: TranscodeSession) { + session.cancel().await.unwrap(); + + // It can take a few moments for the session to disappear. + wait_for(|| async { + let sessions = server.transcode_sessions().await.unwrap(); + sessions.is_empty() + }) + .await; + } + + mod movie { + use super::super::fixtures::online::server::*; + use super::*; + use hls_m3u8::{tags::VariantStream, MasterPlaylist, MediaPlaylist}; + use isahc::AsyncReadResponseExt; + use mp4::{AvcProfile, MediaType, Mp4Reader, TrackType}; + use plex_api::{ + AudioCodec, ContainerFormat, Decision, MediaItem, MetadataItem, Movie, Protocol, + Server, ServerFeature, VideoCodec, VideoTranscodeOptions, + }; + use std::{io::Cursor, thread::sleep, time::Duration}; + + #[plex_api_test_helper::online_test] + async fn dash_transcode(#[future] server: Server) { + let server = generify(server.await).await; + + let movie: Movie = server.item_by_id(55).await.unwrap().try_into().unwrap(); + assert_eq!(movie.title(), "Big Buck Bunny"); + + let media = &movie.media()[0]; + let part = &media.parts()[0]; + + let session = movie + .create_streaming_session( + part, + Protocol::Dash, + // These settings will force transcoding as the original is too + // high a bitrate and has a different audio codec. + VideoTranscodeOptions { + bitrate: 2000, + video_codecs: vec![VideoCodec::H264], + audio_codecs: vec![AudioCodec::Mp3], + ..Default::default() + }, + ) + .await + .unwrap(); + + verify_session( + &session, + Protocol::Dash, + ContainerFormat::Mp4, + Some((Decision::Transcode, AudioCodec::Mp3)), + Some((Decision::Transcode, VideoCodec::H264)), + ); + + let mut buf: Vec = Vec::new(); + session.download(&mut buf).await.unwrap(); + let index = std::str::from_utf8(&buf).unwrap(); + assert!(dash_mpd::parse(index).is_ok()); + + cancel(&server, session).await; + } + + #[plex_api_test_helper::online_test] + async fn dash_transcode_copy(#[future] server: Server) { + let server = generify(server.await).await; + + let movie: Movie = server.item_by_id(57).await.unwrap().try_into().unwrap(); + assert_eq!(movie.title(), "Sintel"); + + let media = &movie.media()[0]; + let part = &media.parts()[0]; + + let session = movie + .create_streaming_session( + part, + Protocol::Dash, + // These settings should allow for direct streaming of the video + // and audio. + VideoTranscodeOptions { + bitrate: 200000000, + width: 1280, + height: 720, + video_codecs: vec![VideoCodec::H264], + audio_codecs: vec![AudioCodec::Aac], + ..Default::default() + }, + ) + .await + .unwrap(); + + verify_session( + &session, + Protocol::Dash, + ContainerFormat::Mp4, + Some((Decision::Copy, AudioCodec::Aac)), + Some((Decision::Copy, VideoCodec::H264)), + ); + + let mut buf: Vec = Vec::new(); + session.download(&mut buf).await.unwrap(); + let index = std::str::from_utf8(&buf).unwrap(); + assert!(dash_mpd::parse(index).is_ok()); + + cancel(&server, session).await; + } + + #[plex_api_test_helper::online_test] + async fn hls_transcode(#[future] server: Server) { + let server = generify(server.await).await; + + let movie: Movie = server.item_by_id(55).await.unwrap().try_into().unwrap(); + assert_eq!(movie.title(), "Big Buck Bunny"); + + let media = &movie.media()[0]; + let part = &media.parts()[0]; + + let session = movie + .create_streaming_session( + part, + Protocol::Hls, + // These settings will force transcoding as the original is too + // high a bitrate and has a different audio codec. + VideoTranscodeOptions { + bitrate: 2000, + video_codecs: vec![VideoCodec::H264], + audio_codecs: vec![AudioCodec::Mp3], + ..Default::default() + }, + ) + .await + .unwrap(); + + verify_session( + &session, + Protocol::Hls, + ContainerFormat::MpegTs, + Some((Decision::Transcode, AudioCodec::Mp3)), + Some((Decision::Transcode, VideoCodec::H264)), + ); + + let mut buf = Vec::::new(); + session.download(&mut buf).await.unwrap(); + let index = std::str::from_utf8(&buf).unwrap(); + let playlist = MasterPlaylist::try_from(index).unwrap(); + if let VariantStream::ExtXStreamInf { uri, .. } = &playlist.variant_streams[0] { + let path = format!("/video/:/transcode/universal/{uri}"); + let text = server + .client() + .get(path) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + + let _media_playlist = MediaPlaylist::try_from(text.as_str()).unwrap(); + } else { + panic!("Expected a media stream"); + } + + cancel(&server, session).await; + } + + #[plex_api_test_helper::online_test] + async fn hls_transcode_copy(#[future] server: Server) { + let server = generify(server.await).await; + + let movie: Movie = server.item_by_id(55).await.unwrap().try_into().unwrap(); + assert_eq!(movie.title(), "Big Buck Bunny"); + + let media = &movie.media()[0]; + let part = &media.parts()[0]; + + let session = movie + .create_streaming_session( + part, + Protocol::Hls, + // These settings should allow for direct streaming of the video + // and audio. + VideoTranscodeOptions { + bitrate: 200000000, + width: 1280, + height: 720, + video_codecs: vec![VideoCodec::H264], + audio_codecs: vec![AudioCodec::Aac], + ..Default::default() + }, + ) + .await + .unwrap(); + + verify_session( + &session, + Protocol::Hls, + ContainerFormat::MpegTs, + Some((Decision::Copy, AudioCodec::Aac)), + Some((Decision::Copy, VideoCodec::H264)), + ); + + let mut buf = Vec::::new(); + session.download(&mut buf).await.unwrap(); + let index = std::str::from_utf8(&buf).unwrap(); + let playlist = MasterPlaylist::try_from(index).unwrap(); + if let VariantStream::ExtXStreamInf { uri, .. } = &playlist.variant_streams[0] { + let path = format!("/video/:/transcode/universal/{uri}"); + let text = server + .client() + .get(path) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + + let _media_playlist = MediaPlaylist::try_from(text.as_str()).unwrap(); + } else { + panic!("Expected a media stream"); + } + + cancel(&server, session).await; + } + + #[plex_api_test_helper::online_test_claimed_server] + async fn offline_transcode(#[future] server_claimed: Server) { + let server = generify(server_claimed.await).await; + + if !server + .media_container + .owner_features + .contains(&ServerFeature::SyncV3) + { + // Offline transcoding is only supported with a subscription. + return; + } + + let movie: Movie = server.item_by_id(57).await.unwrap().try_into().unwrap(); + assert_eq!(movie.title(), "Sintel"); + + let media = &movie.media()[0]; + let part = &media.parts()[0]; + + let session = movie + .create_download_session( + part, + // These settings will force transcoding as the original is too + // high a bitrate and has a different audio codec. + VideoTranscodeOptions { + bitrate: 2000, + video_codecs: vec![VideoCodec::H264], + audio_codecs: vec![AudioCodec::Mp3], + ..Default::default() + }, + ) + .await + .unwrap(); + + verify_session( + &session, + Protocol::Http, + ContainerFormat::Mp4, + Some((Decision::Transcode, AudioCodec::Mp3)), + Some((Decision::Transcode, VideoCodec::H264)), + ); + + verify_remote_sessions(&server, &session).await; + + cancel(&server, session).await; + } + + #[plex_api_test_helper::online_test_claimed_server] + async fn offline_transcode_copy(#[future] server_claimed: Server) { + let server = generify(server_claimed.await).await; + + if !server + .media_container + .owner_features + .contains(&ServerFeature::SyncV3) + { + // Offline transcoding is only supported with a subscription. + return; + } + + let movie: Movie = server.item_by_id(57).await.unwrap().try_into().unwrap(); + assert_eq!(movie.title(), "Sintel"); + + let media = &movie.media()[0]; + let part = &media.parts()[0]; + + let session = movie + .create_download_session( + part, + // These settings should allow for direct streaming of the video + // and audio but into a different container format. + VideoTranscodeOptions { + bitrate: 200000000, + width: 1280, + height: 720, + containers: vec![ContainerFormat::Mp4], + video_codecs: vec![VideoCodec::H264], + audio_codecs: vec![AudioCodec::Aac], + ..Default::default() + }, + ) + .await + .unwrap(); + + verify_session( + &session, + Protocol::Http, + ContainerFormat::Mp4, + Some((Decision::Copy, AudioCodec::Aac)), + Some((Decision::Copy, VideoCodec::H264)), + ); + + verify_remote_sessions(&server, &session).await; + + // As this transcode is just copying the existing streams into a new + // container format it should complete quickly allowing us to download + // the transcoded file. + + // To avoid download timeouts wait for the transcode to complete. + loop { + let stats = session.stats().await.unwrap(); + if stats.complete { + break; + } + sleep(Duration::from_millis(250)); + } + + let mut buf = Vec::::new(); + session.download(&mut buf).await.unwrap(); + + // Verify that the file is a valid MP4 container and the tracks are + // expected. + let len = buf.len(); + let cursor = Cursor::new(buf); + let mp4 = Mp4Reader::read_header(cursor, len as u64).unwrap(); + + let mut videos = mp4 + .tracks() + .values() + .filter(|t| matches!(t.track_type(), Ok(TrackType::Video))); + + let video = videos.next().unwrap(); + assert!(matches!(video.media_type(), Ok(MediaType::H264))); + assert_eq!(video.width(), 1280); + assert_eq!(video.height(), 720); + assert!(matches!(video.video_profile(), Ok(AvcProfile::AvcMain))); + assert!(videos.next().is_none()); + + let mut audios = mp4 + .tracks() + .values() + .filter(|t| matches!(t.track_type(), Ok(TrackType::Audio))); + + let audio = audios.next().unwrap(); + assert!(matches!(audio.media_type(), Ok(MediaType::AAC))); + assert!(audios.next().is_none()); + + cancel(&server, session).await; + } + + #[plex_api_test_helper::online_test_claimed_server] + async fn offline_transcode_denied(#[future] server_claimed: Server) { + let server = generify(server_claimed.await).await; + + if !server + .media_container + .owner_features + .contains(&ServerFeature::SyncV3) + { + // Offline transcoding is only supported with a subscription. + return; + } + + let movie: Movie = server.item_by_id(57).await.unwrap().try_into().unwrap(); + assert_eq!(movie.title(), "Sintel"); + + let media = &movie.media()[0]; + let part = &media.parts()[0]; + + let error = movie + .create_download_session( + part, + // Here we ask to transcode into a format the movie is already + // in so the server denies the request. + VideoTranscodeOptions { + bitrate: 200000000, + width: 1280, + height: 720, + containers: vec![ContainerFormat::Mkv], + video_codecs: vec![VideoCodec::H264], + audio_codecs: vec![AudioCodec::Aac], + ..Default::default() + }, + ) + .await + .err() + .unwrap(); + + assert!(matches!(error, plex_api::Error::TranscodeRefused)); + } + } + + mod music { + use super::super::fixtures::online::server::*; + use super::*; + use hls_m3u8::{tags::VariantStream, MasterPlaylist, MediaPlaylist}; + use isahc::AsyncReadResponseExt; + use plex_api::{ + AudioCodec, ContainerFormat, Decision, MediaItem, MetadataItem, MusicTranscodeOptions, + Protocol, Server, ServerFeature, Track, + }; + use std::{thread::sleep, time::Duration}; + + #[plex_api_test_helper::online_test] + async fn dash_transcode(#[future] server: Server) { + let server = generify(server.await).await; + + let track: Track = server.item_by_id(158).await.unwrap().try_into().unwrap(); + assert_eq!(track.title(), "TRY IT OUT (NEON MIX)"); + + let media = &track.media()[0]; + let part = &media.parts()[0]; + + let session = track + .create_streaming_session( + part, + Protocol::Dash, + // These settings will force transcoding as the original is too + // high a bitrate and has a different audio codec. + MusicTranscodeOptions { + bitrate: 92, + codecs: vec![AudioCodec::Mp3], + ..Default::default() + }, + ) + .await + .unwrap(); + + verify_session( + &session, + Protocol::Dash, + ContainerFormat::Mp4, + Some((Decision::Transcode, AudioCodec::Mp3)), + None, + ); + + let mut buf: Vec = Vec::new(); + session.download(&mut buf).await.unwrap(); + let index = std::str::from_utf8(&buf).unwrap(); + assert!(dash_mpd::parse(index).is_ok()); + + cancel(&server, session).await; + } + + #[plex_api_test_helper::online_test] + async fn dash_transcode_copy(#[future] server: Server) { + let server = generify(server.await).await; + + let track: Track = server.item_by_id(158).await.unwrap().try_into().unwrap(); + assert_eq!(track.title(), "TRY IT OUT (NEON MIX)"); + + let media = &track.media()[0]; + let part = &media.parts()[0]; + + let session = track + .create_streaming_session( + part, + Protocol::Dash, + // These settings should allow for direct streaming of the music. + MusicTranscodeOptions { + bitrate: 256000, + codecs: vec![AudioCodec::Aac], + ..Default::default() + }, + ) + .await + .unwrap(); + + verify_session( + &session, + Protocol::Dash, + ContainerFormat::Mp4, + Some((Decision::Copy, AudioCodec::Aac)), + None, + ); + + let mut buf: Vec = Vec::new(); + session.download(&mut buf).await.unwrap(); + let index = std::str::from_utf8(&buf).unwrap(); + assert!(dash_mpd::parse(index).is_ok()); + + cancel(&server, session).await; + } + + #[plex_api_test_helper::online_test] + async fn hls_transcode(#[future] server: Server) { + let server = generify(server.await).await; + + let track: Track = server.item_by_id(158).await.unwrap().try_into().unwrap(); + assert_eq!(track.title(), "TRY IT OUT (NEON MIX)"); + + let media = &track.media()[0]; + let part = &media.parts()[0]; + + let session = track + .create_streaming_session( + part, + Protocol::Hls, + // These settings will force transcoding as the original is too + // high a bitrate and has a different audio codec. + MusicTranscodeOptions { + bitrate: 92, + codecs: vec![AudioCodec::Mp3], + ..Default::default() + }, + ) + .await + .unwrap(); + + verify_session( + &session, + Protocol::Hls, + ContainerFormat::MpegTs, + Some((Decision::Transcode, AudioCodec::Mp3)), + None, + ); + + let mut buf = Vec::::new(); + session.download(&mut buf).await.unwrap(); + let index = std::str::from_utf8(&buf).unwrap(); + let playlist = MasterPlaylist::try_from(index).unwrap(); + if let VariantStream::ExtXStreamInf { uri, .. } = &playlist.variant_streams[0] { + let path = format!("/video/:/transcode/universal/{uri}"); + let text = server + .client() + .get(path) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + + let _media_playlist = MediaPlaylist::try_from(text.as_str()).unwrap(); + } else { + panic!("Expected a media stream"); + } + + cancel(&server, session).await; + } + + #[plex_api_test_helper::online_test] + async fn hls_transcode_copy(#[future] server: Server) { + let server = generify(server.await).await; + + let track: Track = server.item_by_id(158).await.unwrap().try_into().unwrap(); + assert_eq!(track.title(), "TRY IT OUT (NEON MIX)"); + + let media = &track.media()[0]; + let part = &media.parts()[0]; + + let sessions = server.transcode_sessions().await.unwrap(); + assert!(sessions.is_empty()); + + let session = track + .create_streaming_session( + part, + Protocol::Hls, + // These settings should allow for direct streaming of the music. + MusicTranscodeOptions { + bitrate: 256000, + codecs: vec![AudioCodec::Aac], + ..Default::default() + }, + ) + .await + .unwrap(); + + verify_session( + &session, + Protocol::Hls, + ContainerFormat::MpegTs, + Some((Decision::Copy, AudioCodec::Aac)), + None, + ); + + let mut buf = Vec::::new(); + session.download(&mut buf).await.unwrap(); + let index = std::str::from_utf8(&buf).unwrap(); + let playlist = MasterPlaylist::try_from(index).unwrap(); + if let VariantStream::ExtXStreamInf { uri, .. } = &playlist.variant_streams[0] { + let path = format!("/video/:/transcode/universal/{uri}"); + let text = server + .client() + .get(path) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + + let _media_playlist = MediaPlaylist::try_from(text.as_str()).unwrap(); + } else { + panic!("Expected a media stream"); + } + + cancel(&server, session).await; + } + + #[plex_api_test_helper::online_test_claimed_server] + async fn offline_transcode(#[future] server_claimed: Server) { + let server = generify(server_claimed.await).await; + + if !server + .media_container + .owner_features + .contains(&ServerFeature::SyncV3) + { + // Offline transcoding is only supported with a subscription. + return; + } + + let track: Track = server.item_by_id(158).await.unwrap().try_into().unwrap(); + assert_eq!(track.title(), "TRY IT OUT (NEON MIX)"); + + let media = &track.media()[0]; + let part = &media.parts()[0]; + + let sessions = server.transcode_sessions().await.unwrap(); + assert!(sessions.is_empty()); + + let session = track + .create_download_session( + part, + // These settings will force transcoding as the original is too + // high a bitrate and has a different audio codec. + MusicTranscodeOptions { + bitrate: 92, + containers: vec![ContainerFormat::Mp3], + codecs: vec![AudioCodec::Mp3], + ..Default::default() + }, + ) + .await + .unwrap(); + + verify_session( + &session, + Protocol::Http, + ContainerFormat::Mp3, + Some((Decision::Transcode, AudioCodec::Mp3)), + None, + ); + + verify_remote_sessions(&server, &session).await; + + // Audio transcoding should be reasonably fast... + + // To avoid download timeouts wait for the transcode to complete. + loop { + let stats = session.stats().await.unwrap(); + if stats.complete { + break; + } + sleep(Duration::from_millis(250)); + } + + let mut buf = Vec::::new(); + session.download(&mut buf).await.unwrap(); + + // Check a few unlikely to change properties about the stream. + let metadata = mp3_metadata::read_from_slice(&buf).unwrap(); + assert_eq!(metadata.duration.as_secs(), 5); + let frame = metadata.frames.get(0).unwrap(); + assert_eq!(frame.layer, mp3_metadata::Layer::Layer3); + assert_eq!(frame.chan_type, mp3_metadata::ChannelType::SingleChannel); + + cancel(&server, session).await; + } + + #[plex_api_test_helper::online_test_claimed_server] + async fn offline_transcode_denied(#[future] server_claimed: Server) { + let server = generify(server_claimed.await).await; + + if !server + .media_container + .owner_features + .contains(&ServerFeature::SyncV3) + { + // Offline transcoding is only supported with a subscription. + return; + } + + let track: Track = server.item_by_id(158).await.unwrap().try_into().unwrap(); + assert_eq!(track.title(), "TRY IT OUT (NEON MIX)"); + + let media = &track.media()[0]; + let part = &media.parts()[0]; + + let error = track + .create_download_session( + part, + // Here we ask to transcode into a format the music is already + // in so the server denies the request. + MusicTranscodeOptions { + bitrate: 200000000, + containers: vec![ContainerFormat::Aac], + codecs: vec![AudioCodec::Aac], + ..Default::default() + }, + ) + .await + .err() + .unwrap(); + + assert!(matches!(error, plex_api::Error::TranscodeRefused)); + } + } +} diff --git a/crates/xtask/src/test.rs b/crates/xtask/src/test.rs index 1a0c6b2d..fc532ad0 100644 --- a/crates/xtask/src/test.rs +++ b/crates/xtask/src/test.rs @@ -169,9 +169,11 @@ impl flags::Test { } let test_name = self.test_name.clone().unwrap_or_default(); + // Tests against a live server can change the server state while running + // so running more than one test in parallel can cause conflicts. let mut test_run_result = cmd!( sh, - "cargo test --workspace --no-fail-fast --features {features} {test_name}" + "cargo test --workspace --no-fail-fast --features {features} {test_name} -- --test-threads=1" ) .run();