plex_api/server/transcode/
mod.rs

1//! Support for transcoding media files into lower quality versions.
2//!
3//! Transcoding comes in three 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 old mobile downloads feature) requests that the
8//!   server converts the file in the background allowing it to be downloaded
9//!   later.
10//! * Download queue is a newer version of offline transcoding that allows
11//!   adding as many items to the queue as desired. The server will process
12//!   them in order and when available they can be downloaded using byte range
13//!   requests. This appears to be far more stable than the previous offline
14//!   transcoding option.
15//!
16//! This feature should be considered quite experimental, lots of the API calls
17//! are derived from inspection and guesswork.
18
19pub(crate) mod download_queue;
20pub(crate) mod session;
21
22use std::{collections::HashMap, fmt::Display};
23
24use futures::AsyncWrite;
25use http::StatusCode;
26use isahc::AsyncReadResponseExt;
27use serde::{Deserialize, Serialize};
28use serde_plain::derive_display_from_serialize;
29use uuid::Uuid;
30
31use crate::{
32    error,
33    isahc_compat::StatusCodeExt,
34    media_container::server::library::{
35        AudioCodec, ContainerFormat, Decision, Protocol, SubtitleCodec, VideoCodec,
36    },
37    url::SERVER_TRANSCODE_ART,
38    HttpClient, Result,
39};
40
41use super::Query;
42
43pub use download_queue::{DownloadQueue, QueueItem, QueueItemStatus};
44pub use session::{TranscodeSession, TranscodeStatus};
45
46#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq)]
47#[serde(rename_all = "lowercase")]
48pub enum Context {
49    Streaming,
50    Static,
51    #[cfg(not(feature = "tests_deny_unknown_fields"))]
52    #[serde(other)]
53    Unknown,
54}
55
56derive_display_from_serialize!(Context);
57
58#[derive(Debug, Clone, Deserialize)]
59#[allow(dead_code)]
60#[serde(rename_all = "camelCase")]
61#[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))]
62struct DecisionResult {
63    available_bandwidth: Option<u32>,
64
65    mde_decision_code: Option<u32>,
66    mde_decision_text: Option<String>,
67
68    general_decision_code: Option<u32>,
69    general_decision_text: Option<String>,
70
71    direct_play_decision_code: Option<u32>,
72    direct_play_decision_text: Option<String>,
73
74    transcode_decision_code: Option<u32>,
75    transcode_decision_text: Option<String>,
76}
77
78#[derive(Debug, Clone, Deserialize)]
79#[serde(rename_all = "camelCase")]
80#[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))]
81pub struct TranscodeSessionStats {
82    pub key: String,
83    pub throttled: bool,
84    pub complete: bool,
85    // Percentage complete.
86    pub progress: f32,
87    pub size: i64,
88    pub speed: Option<f32>,
89    pub error: bool,
90    pub duration: Option<u64>,
91    // Appears to be the number of seconds that the server thinks remain.
92    pub remaining: Option<u32>,
93    pub context: Context,
94    pub source_video_codec: Option<VideoCodec>,
95    pub source_audio_codec: Option<AudioCodec>,
96    pub video_decision: Option<Decision>,
97    pub audio_decision: Option<Decision>,
98    pub subtitle_decision: Option<Decision>,
99    pub protocol: Protocol,
100    pub container: ContainerFormat,
101    pub video_codec: Option<VideoCodec>,
102    pub audio_codec: Option<AudioCodec>,
103    pub audio_channels: u8,
104    pub width: Option<u32>,
105    pub height: Option<u32>,
106    pub transcode_hw_requested: bool,
107    pub transcode_hw_decoding: Option<String>,
108    pub transcode_hw_encoding: Option<String>,
109    pub transcode_hw_decoding_title: Option<String>,
110    pub transcode_hw_full_pipeline: Option<bool>,
111    pub transcode_hw_encoding_title: Option<String>,
112    #[serde(default)]
113    pub offline_transcode: bool,
114    pub time_stamp: Option<f32>,
115    pub min_offset_available: Option<f32>,
116    pub max_offset_available: Option<f32>,
117}
118
119struct ProfileSetting {
120    setting: String,
121    params: Vec<String>,
122}
123
124impl ProfileSetting {
125    fn new(setting: &str) -> Self {
126        Self {
127            setting: setting.to_owned(),
128            params: Vec::new(),
129        }
130    }
131
132    fn param<N: Display, V: Display>(mut self, name: N, value: V) -> Self {
133        self.params.push(format!("{name}={value}"));
134        self
135    }
136}
137
138impl Display for ProfileSetting {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        write!(f, "{}({})", self.setting, self.params.join("&"))
141    }
142}
143
144#[derive(Debug, Copy, Clone)]
145pub enum VideoSetting {
146    /// Video width.
147    Width,
148    /// Video height.
149    Height,
150    /// Colour bit depth.
151    BitDepth,
152    /// h264 level.
153    Level,
154    /// Supported h264 profile.
155    Profile,
156    /// Framerate.
157    FrameRate,
158}
159
160impl Display for VideoSetting {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        write!(
163            f,
164            "{}",
165            match self {
166                VideoSetting::Width => "video.width",
167                VideoSetting::Height => "video.height",
168                VideoSetting::BitDepth => "video.bitDepth",
169                VideoSetting::Level => "video.level",
170                VideoSetting::Profile => "video.profile",
171                VideoSetting::FrameRate => "video.frameRate",
172            }
173        )
174    }
175}
176
177#[derive(Debug, Copy, Clone)]
178pub enum AudioSetting {
179    /// Audio channels.
180    Channels,
181    /// Sample rate.
182    SamplingRate,
183    /// Sample bit depth.
184    BitDepth,
185}
186
187impl Display for AudioSetting {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        write!(
190            f,
191            "{}",
192            match self {
193                AudioSetting::Channels => "audio.channels",
194                AudioSetting::SamplingRate => "audio.samplingRate",
195                AudioSetting::BitDepth => "audio.bitDepth",
196            }
197        )
198    }
199}
200
201#[derive(Debug, Clone)]
202pub enum Constraint {
203    Max(String),
204    Min(String),
205    Match(String),
206    MatchList(Vec<String>),
207    NotMatch(String),
208}
209
210/// Limitations add a constraint to the supported media format.
211///
212/// They generally set the maximum or minimum value of a setting or constrain
213/// the setting to a specific list of values. So for example you can set the
214/// maximum video width or the maximum number of audio channels. Limitations are
215/// either set on a per-codec basis or apply to all codecs.
216#[derive(Debug, Clone)]
217pub struct Limitation<C, S> {
218    pub codec: Option<C>,
219    pub setting: S,
220    pub constraint: Constraint,
221}
222
223impl<C: ToString, S: ToString> Limitation<C, S> {
224    fn build(&self, scope: &str) -> ProfileSetting {
225        let scope_name = if let Some(codec) = &self.codec {
226            codec.to_string()
227        } else {
228            "*".to_string()
229        };
230        let name = self.setting.to_string();
231
232        let setting = ProfileSetting::new("add-limitation")
233            .param("scope", scope)
234            .param("scopeName", scope_name)
235            .param("name", name);
236
237        match &self.constraint {
238            Constraint::Max(v) => setting.param("type", "upperBound").param("value", v),
239            Constraint::Min(v) => setting.param("type", "lowerBound").param("value", v),
240            Constraint::Match(v) => setting.param("type", "match").param("value", v),
241            Constraint::MatchList(l) => setting.param("type", "match").param(
242                "list",
243                l.iter()
244                    .map(|s| s.to_string())
245                    .collect::<Vec<String>>()
246                    .join("|"),
247            ),
248            Constraint::NotMatch(v) => setting.param("type", "notMatch").param("value", v),
249        }
250    }
251}
252
253impl<C, S> From<(S, Constraint)> for Limitation<C, S> {
254    fn from((setting, constraint): (S, Constraint)) -> Self {
255        Self {
256            codec: None,
257            setting,
258            constraint,
259        }
260    }
261}
262
263impl<C, S> From<(C, S, Constraint)> for Limitation<C, S> {
264    fn from((codec, setting, constraint): (C, S, Constraint)) -> Self {
265        Self {
266            codec: Some(codec),
267            setting,
268            constraint,
269        }
270    }
271}
272
273impl<C, S> From<(Option<C>, S, Constraint)> for Limitation<C, S> {
274    fn from((codec, setting, constraint): (Option<C>, S, Constraint)) -> Self {
275        Self {
276            codec,
277            setting,
278            constraint,
279        }
280    }
281}
282
283pub trait TranscodeOptions {
284    fn transcode_parameters(
285        &self,
286        context: Context,
287        protocol: Protocol,
288        container: Option<ContainerFormat>,
289    ) -> HashMap<String, String>;
290}
291
292/// Defines the media formats suitable for transcoding video. The server uses
293/// these settings to choose a format to transcode to.
294///
295/// The server is not very clever at choosing codecs that work for a given
296/// container format. It is safest to only list codecs and containers that work
297/// together.
298///
299/// Note that the server maintains default transcode profiles for many devices
300/// which will alter the supported transcode targets. By default for instance if
301/// the server thinks you are an Android client it will only offer stereo audio
302/// in videos. You can see these profiles in `Resources/Profiles` of the media
303/// server install directory. Individual settings in the profile can be
304/// overridden via the API however if you want to be sure of a clean slate use
305/// a [generic client](crate::HttpClientBuilder::generic).
306#[derive(Debug, Clone)]
307pub struct VideoTranscodeOptions {
308    /// Maximum bitrate in kbps.
309    ///
310    /// Note that if the requested bitrate is too low Plex will choose to reduce the dimensions of
311    /// the video. 4Mbps is a reasonable minimum for 720p video, 9Mbps for 1080p.
312    pub bitrate: u32,
313    /// Maximum video width.
314    pub width: u32,
315    /// Maximum video height.
316    pub height: u32,
317    /// Transcode video quality from 0 to 99.
318    pub video_quality: Option<u32>,
319    /// Audio gain from 0 to 100.
320    pub audio_boost: Option<u8>,
321    /// Whether to burn the subtitles into the video. If false the server will decide.
322    pub burn_subtitles: bool,
323    /// Supported media container formats. Ignored for streaming transcodes.
324    pub containers: Vec<ContainerFormat>,
325    /// Supported video codecs.
326    pub video_codecs: Vec<VideoCodec>,
327    /// Limitations to constraint video transcoding options.
328    pub video_limitations: Vec<Limitation<VideoCodec, VideoSetting>>,
329    /// Supported audio codecs.
330    pub audio_codecs: Vec<AudioCodec>,
331    /// Limitations to constraint audio transcoding options.
332    pub audio_limitations: Vec<Limitation<AudioCodec, AudioSetting>>,
333    /// Supported subtitle codecs.
334    pub subtitle_codecs: Vec<SubtitleCodec>,
335}
336
337impl Default for VideoTranscodeOptions {
338    fn default() -> Self {
339        Self {
340            bitrate: 4000,
341            width: 1280,
342            height: 720,
343            video_quality: None,
344            audio_boost: None,
345            burn_subtitles: false,
346            containers: vec![ContainerFormat::Mp4, ContainerFormat::Mkv],
347            video_codecs: vec![VideoCodec::H264],
348            video_limitations: Default::default(),
349            audio_codecs: vec![AudioCodec::Aac, AudioCodec::Mp3],
350            audio_limitations: Default::default(),
351            subtitle_codecs: Default::default(),
352        }
353    }
354}
355
356impl TranscodeOptions for VideoTranscodeOptions {
357    fn transcode_parameters(
358        &self,
359        context: Context,
360        protocol: Protocol,
361        container: Option<ContainerFormat>,
362    ) -> HashMap<String, String> {
363        let mut query = Query::new()
364            .param("maxVideoBitrate", self.bitrate.to_string())
365            .param("videoBitrate", self.bitrate.to_string())
366            .param("videoResolution", format!("{}x{}", self.width, self.height))
367            .param("transcodeType", "video");
368
369        if self.burn_subtitles {
370            query = query
371                .param("subtitles", "burn")
372                .param("subtitleSize", "100");
373        } else {
374            query = query
375                .param("subtitles", "auto")
376                .param("subtitleSize", "100");
377        }
378
379        if let Some(boost) = self.audio_boost {
380            query = query.param("audioBoost", boost.to_string());
381        }
382
383        if let Some(q) = self.video_quality {
384            query = query.param("videoQuality", q.clamp(0, 99).to_string());
385        }
386
387        let video_codecs = self
388            .video_codecs
389            .iter()
390            .map(ToString::to_string)
391            .collect::<Vec<String>>()
392            .join(",");
393
394        let audio_codecs = self
395            .audio_codecs
396            .iter()
397            .map(ToString::to_string)
398            .collect::<Vec<String>>()
399            .join(",");
400
401        let subtitle_codecs = self
402            .subtitle_codecs
403            .iter()
404            .map(ToString::to_string)
405            .collect::<Vec<String>>()
406            .join(",");
407
408        let containers = if let Some(container) = container {
409            vec![container.to_string()]
410        } else {
411            self.containers.iter().map(ToString::to_string).collect()
412        };
413
414        let mut profile = Vec::new();
415
416        let mut is_first = true;
417        for container in &containers {
418            let mut setting = ProfileSetting::new("add-transcode-target")
419                .param("type", "videoProfile")
420                .param("context", context.to_string())
421                .param("protocol", protocol.to_string())
422                .param("container", container)
423                .param("videoCodec", &video_codecs)
424                .param("audioCodec", &audio_codecs)
425                .param("subtitleCodec", &subtitle_codecs);
426
427            if is_first {
428                is_first = false;
429                // Instructs the server to remove any pre-existing transcode targets from the device profile.
430                setting = setting.param("replace", "true");
431            }
432
433            profile.push(setting.to_string());
434        }
435
436        // Allow potentially direct playing for offline transcodes.
437        if context == Context::Static {
438            profile.push(
439                ProfileSetting::new("add-direct-play-profile")
440                    .param("type", "videoProfile")
441                    .param("container", containers.join(","))
442                    .param("videoCodec", &video_codecs)
443                    .param("audioCodec", &audio_codecs)
444                    .param("subtitleCodec", &subtitle_codecs)
445                    .param("replace", "true")
446                    .to_string(),
447            );
448        }
449
450        profile.extend(
451            self.video_limitations
452                .iter()
453                .map(|l| l.build("videoCodec").to_string()),
454        );
455        profile.extend(
456            self.audio_limitations
457                .iter()
458                .map(|l| l.build("videoAudioCodec").to_string()),
459        );
460
461        query
462            .param("X-Plex-Client-Profile-Extra", profile.join("+"))
463            .into()
464    }
465}
466
467/// Defines the media formats suitable for transcoding music. The server uses
468/// these settings to choose a format to transcode to.
469///
470/// The server is not very clever at choosing codecs that work for a given
471/// container format. It is safest to only list codecs and containers that work
472/// together.
473///
474/// Note that the server maintains default transcode profiles for many devices
475/// which will alter the supported transcode targets. By default for instance if
476/// the server thinks you are an Android client it will only offer stereo audio
477/// in videos. You can see these profiles in `Resources/Profiles` of the media
478/// server install directory. Individual settings in the profile can be
479/// overridden via the API however if you want to be sure of a clean slate use
480/// a [generic client](crate::HttpClientBuilder::generic).
481#[derive(Debug, Clone)]
482pub struct MusicTranscodeOptions {
483    /// Maximum bitrate in kbps.
484    pub bitrate: u32,
485    /// Supported media container formats. Ignored for streaming transcodes.
486    pub containers: Vec<ContainerFormat>,
487    /// Supported audio codecs.
488    pub codecs: Vec<AudioCodec>,
489    /// Limitations to constraint audio transcoding options.
490    pub limitations: Vec<Limitation<AudioCodec, AudioSetting>>,
491}
492
493impl Default for MusicTranscodeOptions {
494    fn default() -> Self {
495        Self {
496            bitrate: 192,
497            containers: vec![ContainerFormat::Mp3],
498            codecs: vec![AudioCodec::Mp3],
499            limitations: Default::default(),
500        }
501    }
502}
503
504impl TranscodeOptions for MusicTranscodeOptions {
505    fn transcode_parameters(
506        &self,
507        context: Context,
508        protocol: Protocol,
509        container: Option<ContainerFormat>,
510    ) -> HashMap<String, String> {
511        let query = Query::new()
512            .param("musicBitrate", self.bitrate.to_string())
513            .param("transcodeType", "music");
514
515        let audio_codecs = self
516            .codecs
517            .iter()
518            .map(|c| c.to_string())
519            .collect::<Vec<String>>()
520            .join(",");
521
522        let containers = if let Some(container) = container {
523            vec![container.to_string()]
524        } else {
525            self.containers.iter().map(ToString::to_string).collect()
526        };
527
528        let mut profile = Vec::new();
529
530        let mut is_first = true;
531        for container in &containers {
532            let mut setting = ProfileSetting::new("add-transcode-target")
533                .param("type", "musicProfile")
534                .param("context", context.to_string())
535                .param("protocol", protocol.to_string())
536                .param("container", container)
537                .param("audioCodec", &audio_codecs);
538
539            if is_first {
540                is_first = false;
541                // Instructs the server to remove any pre-existing transcode targets from the device profile.
542                setting = setting.param("replace", "true");
543            }
544
545            profile.push(setting.to_string());
546        }
547
548        // Allow potentially direct playing for offline transcodes.
549        if context == Context::Static {
550            profile.push(
551                ProfileSetting::new("add-direct-play-profile")
552                    .param("type", "musicProfile")
553                    .param("container", containers.join(","))
554                    .param("audioCodec", &audio_codecs)
555                    .to_string(),
556            );
557        }
558
559        profile.extend(
560            self.limitations
561                .iter()
562                .map(|l| l.build("audioCodec").to_string()),
563        );
564
565        query
566            .param("X-Plex-Client-Profile-Extra", profile.join("+"))
567            .into()
568    }
569}
570
571/// Generates a unique session id. This appears to just be any random string.
572fn session_id() -> String {
573    Uuid::new_v4().as_simple().to_string()
574}
575
576fn bs(val: bool) -> String {
577    if val {
578        "1".to_string()
579    } else {
580        "0".to_string()
581    }
582}
583
584fn get_transcode_params<O: TranscodeOptions>(
585    id: &str,
586    context: Context,
587    protocol: Protocol,
588    media_index: Option<usize>,
589    part_index: Option<usize>,
590    options: O,
591) -> Result<Query> {
592    let container = match (context, protocol) {
593        (Context::Static, _) => None,
594        (_, Protocol::Dash) => Some(ContainerFormat::Mp4),
595        (_, Protocol::Hls) => Some(ContainerFormat::MpegTs),
596        _ => return Err(error::Error::InvalidTranscodeSettings),
597    };
598
599    let mut query = Query::new()
600        // The API docs claim this should be sent as `transcodeSessionId` but
601        // that doesn't seem to work and isn't what other clients do.
602        .param("session", id)
603        .param("transcodeSessionId", id)
604        // Setting this to true tells the server that we're willing to directly
605        // play the item if needed. That probably makes sense for downloads but
606        // not streaming (where we need the DASH/HLS protocol).
607        .param("directPlay", bs(context == Context::Static))
608        // Allows using the original video stream if possible.
609        .param("directStream", bs(true))
610        // Allows using the original audio stream if possible.
611        .param("directStreamAudio", bs(true))
612        .param("protocol", protocol.to_string())
613        .param("context", context.to_string())
614        .param("location", "lan")
615        .param("fastSeek", bs(true));
616
617    if let Some(index) = media_index {
618        query = query.param("mediaIndex", index.to_string());
619    } else {
620        query = query.param("mediaIndex", "-1");
621    }
622
623    if let Some(index) = part_index {
624        query = query.param("partIndex", index.to_string());
625    } else {
626        query = query.param("partIndex", "-1");
627    }
628
629    Ok(query.append(options.transcode_parameters(context, protocol, container)))
630}
631
632#[derive(Debug, Clone, Copy)]
633pub struct ArtTranscodeOptions {
634    /// If true and the source image is smaller than that requested it will be
635    /// upscaled.
636    pub upscale: bool,
637    /// Sets whether the requested size is the minimum size desired or the
638    /// maximum.
639    pub min_size: bool,
640}
641
642impl Default for ArtTranscodeOptions {
643    fn default() -> Self {
644        Self {
645            upscale: true,
646            min_size: true,
647        }
648    }
649}
650
651pub(crate) async fn transcode_artwork<W>(
652    client: &HttpClient,
653    art: &str,
654    width: u32,
655    height: u32,
656    options: ArtTranscodeOptions,
657    writer: W,
658) -> Result<()>
659where
660    W: AsyncWrite + Unpin,
661{
662    let query = Query::new()
663        .param("url", art)
664        .param("upscale", bs(options.upscale))
665        .param("minSize", bs(options.min_size))
666        .param("width", width.to_string())
667        .param("height", height.to_string());
668
669    let mut response = client
670        .get(format!("{SERVER_TRANSCODE_ART}?{query}"))
671        .send()
672        .await?;
673
674    match response.status().as_http_status() {
675        // Sometimes the server will respond not found but still cancel the
676        // session.
677        StatusCode::OK => {
678            response.copy_to(writer).await?;
679            Ok(())
680        }
681        _ => Err(crate::Error::from_response(response).await),
682    }
683}