plex_api/server/
transcode.rs

1//! Support for transcoding media files into lower quality versions.
2//!
3//! Transcoding comes in two forms:
4//! * Streaming allows for real-time playback of the media using streaming
5//!   protocols such as [HTTP Live Streaming](https://en.wikipedia.org/wiki/HTTP_Live_Streaming)
6//!   and [Dynamic Adaptive Streaming over HTTP](https://en.wikipedia.org/wiki/Dynamic_Adaptive_Streaming_over_HTTP).
7//! * Offline transcoding (the mobile downloads feature) requests that the
8//!   server converts the file in the background allowing it to be downloaded
9//!   later.
10//!
11//! This feature should be considered quite experimental, lots of the API calls
12//! are derived from inspection and guesswork.
13use std::fmt::Display;
14
15use futures::AsyncWrite;
16use http::StatusCode;
17use isahc::AsyncReadResponseExt;
18use serde::{Deserialize, Serialize};
19use serde_plain::derive_display_from_serialize;
20use uuid::Uuid;
21
22use crate::{
23    error,
24    media_container::{
25        server::{
26            library::{
27                AudioCodec, AudioStream, ContainerFormat, Decision, Media as MediaMetadata,
28                Metadata, Protocol, Stream, VideoCodec, VideoStream,
29            },
30            Feature,
31        },
32        MediaContainer, MediaContainerWrapper,
33    },
34    server::library::{MediaItemWithTranscoding, Part},
35    url::{
36        SERVER_TRANSCODE_ART, SERVER_TRANSCODE_DECISION, SERVER_TRANSCODE_DOWNLOAD,
37        SERVER_TRANSCODE_SESSIONS,
38    },
39    Error, HttpClient, Result,
40};
41
42use super::Query;
43
44#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq)]
45#[serde(rename_all = "lowercase")]
46pub enum Context {
47    Streaming,
48    Static,
49    #[cfg(not(feature = "tests_deny_unknown_fields"))]
50    #[serde(other)]
51    Unknown,
52}
53
54derive_display_from_serialize!(Context);
55
56#[derive(Debug, Clone, Deserialize)]
57#[allow(dead_code)]
58#[serde(rename_all = "camelCase")]
59#[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))]
60struct TranscodeDecisionMediaContainer {
61    general_decision_code: Option<u32>,
62    general_decision_text: Option<String>,
63
64    direct_play_decision_code: Option<u32>,
65    direct_play_decision_text: Option<String>,
66
67    transcode_decision_code: Option<u32>,
68    transcode_decision_text: Option<String>,
69
70    allow_sync: String,
71    #[serde(rename = "librarySectionID")]
72    library_section_id: Option<String>,
73    library_section_title: Option<String>,
74    #[serde(rename = "librarySectionUUID")]
75    library_section_uuid: Option<String>,
76    media_tag_prefix: Option<String>,
77    media_tag_version: Option<String>,
78    resource_session: Option<String>,
79
80    #[serde(flatten)]
81    media_container: MediaContainer,
82
83    #[serde(default, rename = "Metadata")]
84    metadata: Vec<Metadata>,
85}
86
87#[derive(Debug, Clone, Deserialize)]
88#[serde(rename_all = "camelCase")]
89#[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))]
90pub struct TranscodeSessionStats {
91    pub key: String,
92    pub throttled: bool,
93    pub complete: bool,
94    // Percentage complete.
95    pub progress: f32,
96    pub size: i64,
97    pub speed: Option<f32>,
98    pub error: bool,
99    pub duration: Option<u32>,
100    // Appears to be the number of seconds that the server thinks remain.
101    pub remaining: Option<u32>,
102    pub context: Context,
103    pub source_video_codec: Option<VideoCodec>,
104    pub source_audio_codec: Option<AudioCodec>,
105    pub video_decision: Option<Decision>,
106    pub audio_decision: Option<Decision>,
107    pub subtitle_decision: Option<Decision>,
108    pub protocol: Protocol,
109    pub container: ContainerFormat,
110    pub video_codec: Option<VideoCodec>,
111    pub audio_codec: Option<AudioCodec>,
112    pub audio_channels: u8,
113    pub width: Option<u32>,
114    pub height: Option<u32>,
115    pub transcode_hw_requested: bool,
116    pub transcode_hw_decoding: Option<String>,
117    pub transcode_hw_encoding: Option<String>,
118    pub transcode_hw_decoding_title: Option<String>,
119    pub transcode_hw_full_pipeline: Option<bool>,
120    pub transcode_hw_encoding_title: Option<String>,
121    #[serde(default)]
122    pub offline_transcode: bool,
123    pub time_stamp: Option<f32>,
124    pub min_offset_available: Option<f32>,
125    pub max_offset_available: Option<f32>,
126}
127
128#[derive(Debug, Clone, Deserialize)]
129#[serde(rename_all = "camelCase")]
130pub(crate) struct TranscodeSessionsMediaContainer {
131    #[serde(default, rename = "TranscodeSession")]
132    pub(crate) transcode_sessions: Vec<TranscodeSessionStats>,
133}
134
135struct ProfileSetting {
136    setting: String,
137    params: Vec<String>,
138}
139
140impl ProfileSetting {
141    fn new(setting: &str) -> Self {
142        Self {
143            setting: setting.to_owned(),
144            params: Vec::new(),
145        }
146    }
147
148    fn param<N: Display, V: Display>(mut self, name: N, value: V) -> Self {
149        self.params.push(format!("{name}={value}"));
150        self
151    }
152}
153
154impl ToString for ProfileSetting {
155    fn to_string(&self) -> String {
156        format!("{}({})", self.setting, self.params.join("&"))
157    }
158}
159
160#[derive(Debug, Copy, Clone)]
161pub enum VideoSetting {
162    /// Video width.
163    Width,
164    /// Video height.
165    Height,
166    /// Colour bit depth.
167    BitDepth,
168    /// h264 level.
169    Level,
170    /// Supported h264 profile.
171    Profile,
172    /// Framerate.
173    FrameRate,
174}
175
176impl ToString for VideoSetting {
177    fn to_string(&self) -> String {
178        match self {
179            VideoSetting::Width => "video.width".to_string(),
180            VideoSetting::Height => "video.height".to_string(),
181            VideoSetting::BitDepth => "video.bitDepth".to_string(),
182            VideoSetting::Level => "video.level".to_string(),
183            VideoSetting::Profile => "video.profile".to_string(),
184            VideoSetting::FrameRate => "video.frameRate".to_string(),
185        }
186    }
187}
188
189#[derive(Debug, Copy, Clone)]
190pub enum AudioSetting {
191    /// Audio channels.
192    Channels,
193    /// Sample rate.
194    SamplingRate,
195    /// Sample bit depth.
196    BitDepth,
197}
198
199impl ToString for AudioSetting {
200    fn to_string(&self) -> String {
201        match self {
202            AudioSetting::Channels => "audio.channels".to_string(),
203            AudioSetting::SamplingRate => "audio.samplingRate".to_string(),
204            AudioSetting::BitDepth => "audio.bitDepth".to_string(),
205        }
206    }
207}
208
209#[derive(Debug, Clone)]
210pub enum Constraint {
211    Max(String),
212    Min(String),
213    Match(Vec<String>),
214    NotMatch(String),
215}
216
217/// Limitations add a constraint to the supported media format.
218///
219/// They generally set the maximum or minimum value of a setting or constrain
220/// the setting to a specific list of values. So for example you can set the
221/// maximum video width or the maximum number of audio channels. Limitations are
222/// either set on a per-codec basis or apply to all codecs.
223#[derive(Debug, Clone)]
224pub struct Limitation<C, S> {
225    pub codec: Option<C>,
226    pub setting: S,
227    pub constraint: Constraint,
228}
229
230impl<C: ToString, S: ToString> Limitation<C, S> {
231    fn build(&self, scope: &str) -> ProfileSetting {
232        let scope_name = if let Some(codec) = &self.codec {
233            codec.to_string()
234        } else {
235            "*".to_string()
236        };
237        let name = self.setting.to_string();
238
239        let setting = ProfileSetting::new("add-limitation")
240            .param("scope", scope)
241            .param("scopeName", scope_name)
242            .param("name", name);
243
244        match &self.constraint {
245            Constraint::Max(v) => setting.param("type", "upperBound").param("value", v),
246            Constraint::Min(v) => setting.param("type", "lowerBound").param("value", v),
247            Constraint::Match(l) => setting.param("type", "match").param(
248                "list",
249                l.iter()
250                    .map(|s| s.to_string())
251                    .collect::<Vec<String>>()
252                    .join("|"),
253            ),
254            Constraint::NotMatch(v) => setting.param("type", "notMatch").param("value", v),
255        }
256    }
257}
258
259impl<C, S> From<(S, Constraint)> for Limitation<C, S> {
260    fn from((setting, constraint): (S, Constraint)) -> Self {
261        Self {
262            codec: None,
263            setting,
264            constraint,
265        }
266    }
267}
268
269impl<C, S> From<(C, S, Constraint)> for Limitation<C, S> {
270    fn from((codec, setting, constraint): (C, S, Constraint)) -> Self {
271        Self {
272            codec: Some(codec),
273            setting,
274            constraint,
275        }
276    }
277}
278
279impl<C, S> From<(Option<C>, S, Constraint)> for Limitation<C, S> {
280    fn from((codec, setting, constraint): (Option<C>, S, Constraint)) -> Self {
281        Self {
282            codec,
283            setting,
284            constraint,
285        }
286    }
287}
288
289pub trait TranscodeOptions {
290    fn transcode_parameters(
291        &self,
292        context: Context,
293        protocol: Protocol,
294        container: Option<ContainerFormat>,
295    ) -> String;
296}
297
298/// Defines the media formats suitable for transcoding video. The server uses
299/// these settings to choose a format to transcode to.
300///
301/// The server is not very clever at choosing codecs that work for a given
302/// container format. It is safest to only list codecs and containers that work
303/// together.
304///
305/// Note that the server maintains default transcode profiles for many devices
306/// which will alter the supported transcode targets. By default for instance if
307/// the server thinks you are an Android client it will only offer stereo audio
308/// in videos. You can see these profiles in `Resources/Profiles` of the media
309/// server install directory. Individual settings in the profile can be
310/// overridden via the API however if you want to be sure of a clean slate use
311/// a [generic client](crate::HttpClientBuilder::generic).
312#[derive(Debug, Clone)]
313pub struct VideoTranscodeOptions {
314    /// Maximum bitrate in kbps.
315    pub bitrate: u32,
316    /// Maximum video width.
317    pub width: u32,
318    /// Maximum video height.
319    pub height: u32,
320    /// Audio gain from 0 to 100.
321    pub audio_boost: Option<u8>,
322    /// Whether to burn the subtitles into the video.
323    pub burn_subtitles: bool,
324    /// Supported media container formats. Ignored for streaming transcodes.
325    pub containers: Vec<ContainerFormat>,
326    /// Supported video codecs.
327    pub video_codecs: Vec<VideoCodec>,
328    /// Limitations to constraint video transcoding options.
329    pub video_limitations: Vec<Limitation<VideoCodec, VideoSetting>>,
330    /// Supported audio codecs.
331    pub audio_codecs: Vec<AudioCodec>,
332    /// Limitations to constraint audio transcoding options.
333    pub audio_limitations: Vec<Limitation<AudioCodec, AudioSetting>>,
334}
335
336impl Default for VideoTranscodeOptions {
337    fn default() -> Self {
338        Self {
339            bitrate: 2000,
340            width: 1280,
341            height: 720,
342            audio_boost: None,
343            burn_subtitles: true,
344            containers: vec![ContainerFormat::Mp4, ContainerFormat::Mkv],
345            video_codecs: vec![VideoCodec::H264],
346            video_limitations: Default::default(),
347            audio_codecs: vec![AudioCodec::Aac, AudioCodec::Mp3],
348            audio_limitations: Default::default(),
349        }
350    }
351}
352
353impl TranscodeOptions for VideoTranscodeOptions {
354    fn transcode_parameters(
355        &self,
356        context: Context,
357        protocol: Protocol,
358        container: Option<ContainerFormat>,
359    ) -> String {
360        let mut query = Query::new()
361            .param("maxVideoBitrate", self.bitrate.to_string())
362            .param("videoBitrate", self.bitrate.to_string())
363            .param("videoResolution", format!("{}x{}", self.width, self.height));
364
365        if self.burn_subtitles {
366            query = query
367                .param("subtitles", "burn")
368                .param("subtitleSize", "100");
369        }
370
371        if let Some(boost) = self.audio_boost {
372            query = query.param("audioBoost", boost.to_string());
373        }
374
375        let video_codecs = self
376            .video_codecs
377            .iter()
378            .map(|c| c.to_string())
379            .collect::<Vec<String>>()
380            .join(",");
381
382        let audio_codecs = self
383            .audio_codecs
384            .iter()
385            .map(|c| c.to_string())
386            .collect::<Vec<String>>()
387            .join(",");
388
389        let containers = if let Some(container) = container {
390            vec![container.to_string()]
391        } else {
392            self.containers.iter().map(ToString::to_string).collect()
393        };
394
395        let mut profile = Vec::new();
396
397        for container in containers {
398            profile.push(
399                ProfileSetting::new("add-transcode-target")
400                    .param("type", "videoProfile")
401                    .param("context", context.to_string())
402                    .param("protocol", protocol.to_string())
403                    .param("container", &container)
404                    .param("videoCodec", &video_codecs)
405                    .param("audioCodec", &audio_codecs)
406                    .to_string(),
407            );
408
409            // Allow potentially direct playing for offline transcodes.
410            if context == Context::Static {
411                profile.push(
412                    ProfileSetting::new("add-direct-play-profile")
413                        .param("type", "videoProfile")
414                        .param("container", container)
415                        .param("videoCodec", &video_codecs)
416                        .param("audioCodec", &audio_codecs)
417                        .to_string(),
418                );
419            }
420        }
421
422        profile.extend(self.video_codecs.iter().map(|codec| {
423            ProfileSetting::new("append-transcode-target-codec")
424                .param("type", "videoProfile")
425                .param("context", context.to_string())
426                .param("protocol", protocol.to_string())
427                .param("videoCodec", codec.to_string())
428                .to_string()
429        }));
430
431        profile.extend(self.audio_codecs.iter().map(|codec| {
432            ProfileSetting::new("add-transcode-target-audio-codec")
433                .param("type", "videoProfile")
434                .param("context", context.to_string())
435                .param("protocol", protocol.to_string())
436                .param("audioCodec", codec.to_string())
437                .to_string()
438        }));
439
440        profile.extend(
441            self.video_limitations
442                .iter()
443                .map(|l| l.build("videoCodec").to_string()),
444        );
445        profile.extend(
446            self.audio_limitations
447                .iter()
448                .map(|l| l.build("videoAudioCodec").to_string()),
449        );
450
451        query
452            .param("X-Plex-Client-Profile-Extra", profile.join("+"))
453            .to_string()
454    }
455}
456
457/// Defines the media formats suitable for transcoding music. The server uses
458/// these settings to choose a format to transcode to.
459///
460/// The server is not very clever at choosing codecs that work for a given
461/// container format. It is safest to only list codecs and containers that work
462/// together.
463///
464/// Note that the server maintains default transcode profiles for many devices
465/// which will alter the supported transcode targets. By default for instance if
466/// the server thinks you are an Android client it will only offer stereo audio
467/// in videos. You can see these profiles in `Resources/Profiles` of the media
468/// server install directory. Individual settings in the profile can be
469/// overridden via the API however if you want to be sure of a clean slate use
470/// a [generic client](crate::HttpClientBuilder::generic).
471#[derive(Debug, Clone)]
472pub struct MusicTranscodeOptions {
473    /// Maximum bitrate in kbps.
474    pub bitrate: u32,
475    /// Supported media container formats. Ignored for streaming transcodes.
476    pub containers: Vec<ContainerFormat>,
477    /// Supported audio codecs.
478    pub codecs: Vec<AudioCodec>,
479    /// Limitations to constraint audio transcoding options.
480    pub limitations: Vec<Limitation<AudioCodec, AudioSetting>>,
481}
482
483impl Default for MusicTranscodeOptions {
484    fn default() -> Self {
485        Self {
486            bitrate: 192,
487            containers: vec![ContainerFormat::Mp3],
488            codecs: vec![AudioCodec::Mp3],
489            limitations: Default::default(),
490        }
491    }
492}
493
494impl TranscodeOptions for MusicTranscodeOptions {
495    fn transcode_parameters(
496        &self,
497        context: Context,
498        protocol: Protocol,
499        container: Option<ContainerFormat>,
500    ) -> String {
501        let query = Query::new().param("musicBitrate", self.bitrate.to_string());
502
503        let audio_codecs = self
504            .codecs
505            .iter()
506            .map(|c| c.to_string())
507            .collect::<Vec<String>>()
508            .join(",");
509
510        let containers = if let Some(container) = container {
511            vec![container.to_string()]
512        } else {
513            self.containers.iter().map(ToString::to_string).collect()
514        };
515
516        let mut profile = Vec::new();
517
518        for container in containers {
519            profile.push(
520                ProfileSetting::new("add-transcode-target")
521                    .param("type", "musicProfile")
522                    .param("context", context.to_string())
523                    .param("protocol", protocol.to_string())
524                    .param("container", &container)
525                    .param("audioCodec", &audio_codecs)
526                    .to_string(),
527            );
528
529            // Allow potentially direct playing for offline transcodes.
530            if context == Context::Static {
531                profile.push(
532                    ProfileSetting::new("add-direct-play-profile")
533                        .param("type", "musicProfile")
534                        .param("container", container)
535                        .param("audioCodec", &audio_codecs)
536                        .to_string(),
537                );
538            }
539        }
540
541        profile.extend(
542            self.limitations
543                .iter()
544                .map(|l| l.build("audioCodec").to_string()),
545        );
546
547        query
548            .param("X-Plex-Client-Profile-Extra", profile.join("+"))
549            .to_string()
550    }
551}
552
553/// Generates a unique session id. This appears to just be any random string.
554fn session_id() -> String {
555    Uuid::new_v4().as_simple().to_string()
556}
557
558fn bs(val: bool) -> String {
559    if val {
560        "1".to_string()
561    } else {
562        "0".to_string()
563    }
564}
565
566fn get_transcode_params<M: MediaItemWithTranscoding>(
567    id: &str,
568    context: Context,
569    protocol: Protocol,
570    item_metadata: &Metadata,
571    part: &Part<M>,
572    options: M::Options,
573) -> Result<String> {
574    let container = match (context, protocol) {
575        (Context::Static, _) => None,
576        (_, Protocol::Dash) => Some(ContainerFormat::Mp4),
577        (_, Protocol::Hls) => Some(ContainerFormat::MpegTs),
578        _ => return Err(error::Error::InvalidTranscodeSettings),
579    };
580
581    let mut query = Query::new()
582        .param("session", id)
583        // It's not clear what this parameter is for. Mobile clients send a
584        // hyphenated UUID that differes from the session id
585        // above but using the same id seems to work.
586        .param("X-Plex-Session-Identifier", id)
587        .param("path", item_metadata.key.clone())
588        .param("mediaIndex", part.media_index.to_string())
589        .param("partIndex", part.part_index.to_string())
590        // Setting this to true tells the server that we're willing to directly
591        // play the item if needed. That probably makes sense for downloads but
592        // not streaming (where we need the DASH/HLS protocol).
593        .param("directPlay", bs(context == Context::Static))
594        // Allows using the original video stream if possible.
595        .param("directStream", bs(true))
596        // Allows using the original audio stream if possible.
597        .param("directStreamAudio", bs(true))
598        .param("protocol", protocol.to_string())
599        .param("context", context.to_string())
600        .param("location", "lan")
601        .param("fastSeek", bs(true));
602
603    if context == Context::Static {
604        query = query.param("offlineTranscode", bs(true));
605    }
606
607    let query = query.to_string();
608
609    let params = options.transcode_parameters(context, protocol, container);
610
611    Ok(format!("{query}&{params}"))
612}
613
614async fn transcode_decision<'a, M: MediaItemWithTranscoding>(
615    part: &Part<'a, M>,
616    params: &str,
617) -> Result<MediaMetadata> {
618    let path = format!("{SERVER_TRANSCODE_DECISION}?{params}");
619
620    let mut response = part
621        .client
622        .get(path)
623        .header("Accept", "application/json")
624        .send()
625        .await?;
626
627    let text = match response.status() {
628        StatusCode::OK => response.text().await?,
629        _ => return Err(crate::Error::from_response(response).await),
630    };
631
632    let wrapper: MediaContainerWrapper<TranscodeDecisionMediaContainer> =
633        serde_json::from_str(&text)?;
634
635    if wrapper.media_container.general_decision_code == Some(2011)
636        && wrapper.media_container.general_decision_text
637            == Some("Downloads not allowed".to_string())
638    {
639        return Err(error::Error::SubscriptionFeatureNotAvailable(
640            Feature::SyncV3,
641        ));
642    }
643
644    if wrapper.media_container.direct_play_decision_code == Some(1000) {
645        return Err(error::Error::TranscodeRefused);
646    }
647
648    wrapper
649        .media_container
650        .metadata
651        .into_iter()
652        .next()
653        .and_then(|m| m.media)
654        .and_then(|m| m.into_iter().find(|m| m.selected == Some(true)))
655        .ok_or_else(|| {
656            if let Some(text) = wrapper.media_container.transcode_decision_text {
657                error::Error::TranscodeError(text)
658            } else {
659                error::Error::UnexpectedApiResponse {
660                    status_code: response.status().as_u16(),
661                    content: text,
662                }
663            }
664        })
665}
666
667pub(super) async fn create_transcode_session<'a, M: MediaItemWithTranscoding>(
668    item_metadata: &'a Metadata,
669    part: &Part<'a, M>,
670    context: Context,
671    target_protocol: Protocol,
672    options: M::Options,
673) -> Result<TranscodeSession> {
674    let id = session_id();
675
676    let params = get_transcode_params(&id, context, target_protocol, item_metadata, part, options)?;
677
678    let media_data = transcode_decision(part, &params).await?;
679
680    if target_protocol != media_data.protocol.unwrap_or(Protocol::Http) {
681        return Err(error::Error::TranscodeError(
682            "Server returned an invalid protocol.".to_string(),
683        ));
684    }
685
686    TranscodeSession::from_metadata(
687        id,
688        part.client.clone(),
689        media_data,
690        context == Context::Static,
691        params,
692    )
693}
694
695pub(crate) async fn transcode_session_stats(
696    client: &HttpClient,
697    session_id: &str,
698) -> Result<TranscodeSessionStats> {
699    let wrapper: MediaContainerWrapper<TranscodeSessionsMediaContainer> = match client
700        .get(format!("{SERVER_TRANSCODE_SESSIONS}/{session_id}"))
701        .json()
702        .await
703    {
704        Ok(w) => w,
705        Err(Error::UnexpectedApiResponse {
706            status_code: 404,
707            content: _,
708        }) => {
709            return Err(crate::Error::ItemNotFound);
710        }
711        Err(e) => return Err(e),
712    };
713    wrapper
714        .media_container
715        .transcode_sessions
716        .get(0)
717        .cloned()
718        .ok_or(crate::Error::ItemNotFound)
719}
720
721#[derive(Clone, Copy)]
722pub enum TranscodeStatus {
723    Complete,
724    Error,
725    Transcoding {
726        // The server's estimate of how many seconds are left until complete.
727        remaining: Option<u32>,
728        // Percent complete (0-100).
729        progress: f32,
730    },
731}
732
733pub struct TranscodeSession {
734    id: String,
735    client: HttpClient,
736    offline: bool,
737    protocol: Protocol,
738    container: ContainerFormat,
739    video_transcode: Option<(Decision, VideoCodec)>,
740    audio_transcode: Option<(Decision, AudioCodec)>,
741    params: String,
742}
743
744impl TranscodeSession {
745    pub(crate) fn from_stats(client: HttpClient, stats: TranscodeSessionStats) -> Self {
746        Self {
747            client,
748            // Once the transcode session is started we only need the session ID
749            // to download.
750            params: format!("session={}", stats.key),
751            offline: stats.offline_transcode,
752            container: stats.container,
753            protocol: stats.protocol,
754            video_transcode: stats.video_decision.zip(stats.video_codec),
755            audio_transcode: stats.audio_decision.zip(stats.audio_codec),
756            id: stats.key,
757        }
758    }
759
760    fn from_metadata(
761        id: String,
762        client: HttpClient,
763        media_data: MediaMetadata,
764        offline: bool,
765        params: String,
766    ) -> Result<Self> {
767        let part_data = media_data
768            .parts
769            .iter()
770            .find(|p| p.selected == Some(true))
771            .ok_or_else(|| {
772                error::Error::TranscodeError("Server returned unexpected response".to_string())
773            })?;
774
775        let streams = part_data.streams.as_ref().ok_or_else(|| {
776            error::Error::TranscodeError("Server returned unexpected response".to_string())
777        })?;
778
779        let video_streams = streams
780            .iter()
781            .filter_map(|s| match s {
782                Stream::Video(s) => Some(s),
783                _ => None,
784            })
785            .collect::<Vec<&VideoStream>>();
786
787        let video_transcode = video_streams
788            .iter()
789            .find(|s| s.selected == Some(true))
790            .or_else(|| video_streams.get(0))
791            .map(|s| (s.decision.unwrap(), s.codec));
792
793        let audio_streams = streams
794            .iter()
795            .filter_map(|s| match s {
796                Stream::Audio(s) => Some(s),
797                _ => None,
798            })
799            .collect::<Vec<&AudioStream>>();
800
801        let audio_transcode = audio_streams
802            .iter()
803            .find(|s| s.selected == Some(true))
804            .or_else(|| audio_streams.get(0))
805            .map(|s| (s.decision.unwrap(), s.codec));
806
807        Ok(Self {
808            id,
809            client,
810            offline,
811            params,
812            container: media_data.container.unwrap(),
813            protocol: media_data.protocol.unwrap_or(Protocol::Http),
814            video_transcode,
815            audio_transcode,
816        })
817    }
818
819    /// The session ID allows for re-retrieving this session at a later date.
820    pub fn session_id(&self) -> &str {
821        &self.id
822    }
823
824    pub fn is_offline(&self) -> bool {
825        self.offline
826    }
827
828    /// The selected protocol.
829    pub fn protocol(&self) -> Protocol {
830        self.protocol
831    }
832
833    /// The selected container.
834    pub fn container(&self) -> ContainerFormat {
835        self.container
836    }
837
838    // The target video codec and the transcode decision.
839    pub fn video_transcode(&self) -> Option<(Decision, VideoCodec)> {
840        self.video_transcode
841    }
842
843    // The target audio codec and the transcode decision.
844    pub fn audio_transcode(&self) -> Option<(Decision, AudioCodec)> {
845        self.audio_transcode
846    }
847
848    /// Downloads the transcoded data to the provided writer.
849    ///
850    /// For streaming transcodes (MPEG-DASH or HLS) this will return the
851    /// playlist data. This crate doesn't contain any support for processing
852    /// these streaming formats and figuring out how to use them is currently
853    /// left as an exercise for the caller.
854    ///
855    /// For offline transcodes it is possible to start downloading before the
856    /// transcode is complete. In this case any data already transcoded is
857    /// downloaded and then the connection will remain open and more data will
858    /// be delivered to the writer as it becomes available. This can mean
859    /// that the HTTP connection is idle for long periods of time waiting for
860    /// more data to be transcoded and so the normal timeouts are disabled for
861    /// offline transcode downloads.
862    ///
863    /// Unfortunately there does not appear to be any way to restart downloads
864    /// from a specific point in the file. So if the download fails for
865    /// any reason you have to start downloading all over again. It may make
866    /// more sense to wait until the transcode is complete or nearly complete
867    /// before attempting download.
868    #[tracing::instrument(level = "debug", skip_all)]
869    pub async fn download<W>(&self, writer: W) -> Result<()>
870    where
871        W: AsyncWrite + Unpin,
872    {
873        // Strictly speaking it doesn't appear that the requested extension
874        // matters but we'll attempt to match other clients anyway.
875        let ext = match (self.protocol, self.container) {
876            (Protocol::Dash, _) => "mpd".to_string(),
877            (Protocol::Hls, _) => "m3u8".to_string(),
878            (_, container) => container.to_string(),
879        };
880
881        let path = format!("{SERVER_TRANSCODE_DOWNLOAD}/start.{}?{}", ext, self.params);
882
883        let mut builder = self.client.get(path);
884        if self.offline {
885            builder = builder.timeout(None)
886        }
887        let mut response = builder.send().await?;
888
889        match response.status() {
890            StatusCode::OK => {
891                response.copy_to(writer).await?;
892                Ok(())
893            }
894            _ => Err(crate::Error::from_response(response).await),
895        }
896    }
897
898    #[tracing::instrument(level = "debug", skip_all)]
899    pub async fn status(&self) -> Result<TranscodeStatus> {
900        let stats = self.stats().await?;
901
902        if stats.error {
903            Ok(TranscodeStatus::Error)
904        } else if stats.complete {
905            Ok(TranscodeStatus::Complete)
906        } else {
907            Ok(TranscodeStatus::Transcoding {
908                remaining: stats.remaining,
909                progress: stats.progress,
910            })
911        }
912    }
913
914    /// Retrieves the current transcode stats.
915    #[tracing::instrument(level = "debug", skip_all)]
916    pub async fn stats(&self) -> Result<TranscodeSessionStats> {
917        transcode_session_stats(&self.client, &self.id).await
918    }
919
920    /// Cancels the transcode and removes any transcoded data from the server.
921    ///
922    /// NB! Be careful with cancelling sessions too often! Cancelling a few transcoding
923    /// sessions in a short succession, or cancelling a session shortly after it was
924    /// initiated might crash the Plex server. At least the one running inside a Linux
925    /// Docker Container.
926    #[tracing::instrument(level = "debug", skip_all)]
927    pub async fn cancel(self) -> Result<()> {
928        let mut response = self
929            .client
930            .get(format!(
931                "/video/:/transcode/universal/stop?session={}",
932                self.id
933            ))
934            .send()
935            .await?;
936
937        match response.status() {
938            // Sometimes the server will respond not found but still cancel the
939            // session.
940            StatusCode::OK | StatusCode::NOT_FOUND => Ok(response.consume().await?),
941            _ => Err(crate::Error::from_response(response).await),
942        }
943    }
944}
945
946#[derive(Debug, Clone, Copy)]
947pub struct ArtTranscodeOptions {
948    /// If true and the source image is smaller than that requested it will be
949    /// upscaled.
950    pub upscale: bool,
951    /// Sets whether the requested size is the minimum size desired or the
952    /// maximum.
953    pub min_size: bool,
954}
955
956impl Default for ArtTranscodeOptions {
957    fn default() -> Self {
958        Self {
959            upscale: true,
960            min_size: true,
961        }
962    }
963}
964
965pub(crate) async fn transcode_artwork<W>(
966    client: &HttpClient,
967    art: &str,
968    width: u32,
969    height: u32,
970    options: ArtTranscodeOptions,
971    writer: W,
972) -> Result<()>
973where
974    W: AsyncWrite + Unpin,
975{
976    let query = Query::new()
977        .param("url", art)
978        .param("upscale", bs(options.upscale))
979        .param("minSize", bs(options.min_size))
980        .param("width", width.to_string())
981        .param("height", height.to_string());
982
983    let mut response = client
984        .get(format!("{SERVER_TRANSCODE_ART}?{query}"))
985        .send()
986        .await?;
987
988    match response.status() {
989        // Sometimes the server will respond not found but still cancel the
990        // session.
991        StatusCode::OK => {
992            response.copy_to(writer).await?;
993            Ok(())
994        }
995        _ => Err(crate::Error::from_response(response).await),
996    }
997}