aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--common/src/config.rs71
-rw-r--r--common/src/stream.rs5
-rw-r--r--import/src/infojson.rs2
-rw-r--r--server/src/routes/compat/jellyfin/mod.rs18
-rw-r--r--stream/src/fragment.rs1
-rw-r--r--stream/src/lib.rs91
-rw-r--r--stream/src/stream_info.rs164
-rw-r--r--stream/src/webvtt.rs18
-rw-r--r--transcoder/src/fragment.rs74
9 files changed, 239 insertions, 205 deletions
diff --git a/common/src/config.rs b/common/src/config.rs
index a0dc459..3a48fea 100644
--- a/common/src/config.rs
+++ b/common/src/config.rs
@@ -8,36 +8,42 @@ use crate::user::PermissionSet;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, path::PathBuf};
-#[rustfmt::skip]
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct GlobalConfig {
pub hostname: String,
pub brand: String,
pub slogan: String,
- #[serde(default = "return_true" )] pub tls: bool,
- #[serde(default = "default::asset_path")] pub asset_path: PathBuf,
- #[serde(default = "default::database_path")] pub database_path: PathBuf,
- #[serde(default = "default::cache_path")] pub cache_path: PathBuf,
- #[serde(default = "default::media_path")] pub media_path: PathBuf,
- #[serde(default = "default::secrets_path")] pub secrets_path: PathBuf,
- #[serde(default = "default::max_in_memory_cache_size")] pub max_in_memory_cache_size: usize,
- #[serde(default)] pub admin_username: Option<String>,
- #[serde(default = "default::login_expire")] pub login_expire: i64,
- #[serde(default)] pub default_permission_set: PermissionSet,
- #[serde(default)] encoders: EncoderPreferences,
+ #[serde(default = "return_true")]
+ pub tls: bool,
+ pub asset_path: PathBuf,
+ pub database_path: PathBuf,
+ pub cache_path: PathBuf,
+ pub media_path: PathBuf,
+ pub secrets_path: PathBuf,
+ #[serde(default = "max_in_memory_cache_size")]
+ pub max_in_memory_cache_size: usize,
+ #[serde(default)]
+ pub admin_username: Option<String>,
+ #[serde(default = "login_expire")]
+ pub login_expire: i64,
+ #[serde(default)]
+ pub default_permission_set: PermissionSet,
+ #[serde(default)]
+ pub encoders: EncoderPreferences,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct EncoderPreferences {
- avc: Option<EncoderClass>,
- hevc: Option<EncoderClass>,
- vp8: Option<EncoderClass>,
- vp9: Option<EncoderClass>,
- av1: Option<EncoderClass>,
+ pub avc: Option<EncoderClass>,
+ pub hevc: Option<EncoderClass>,
+ pub vp8: Option<EncoderClass>,
+ pub vp9: Option<EncoderClass>,
+ pub av1: Option<EncoderClass>,
}
#[derive(Debug, Deserialize, Serialize)]
-enum EncoderClass {
+#[serde(rename_all = "snake_case")]
+pub enum EncoderClass {
Aom,
Svt,
X26n,
@@ -77,30 +83,11 @@ pub struct ApiSecrets {
pub trakt: Option<String>,
}
-mod default {
- use std::path::PathBuf;
-
- pub fn login_expire() -> i64 {
- 60 * 60 * 24
- }
- pub fn asset_path() -> PathBuf {
- "data/assets".into()
- }
- pub fn database_path() -> PathBuf {
- "data/database".into()
- }
- pub fn cache_path() -> PathBuf {
- "data/cache".into()
- }
- pub fn media_path() -> PathBuf {
- "data/media".into()
- }
- pub fn secrets_path() -> PathBuf {
- "data/secrets.yaml".into()
- }
- pub fn max_in_memory_cache_size() -> usize {
- 50_000_000
- }
+fn login_expire() -> i64 {
+ 60 * 60 * 24
+}
+fn max_in_memory_cache_size() -> usize {
+ 50_000_000
}
fn return_true() -> bool {
diff --git a/common/src/stream.rs b/common/src/stream.rs
index a14fd57..555a5d0 100644
--- a/common/src/stream.rs
+++ b/common/src/stream.rs
@@ -87,7 +87,7 @@ pub enum TrackKind {
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct StreamFormatInfo {
pub codec: String,
- pub byterate: f64,
+ pub bitrate: f64,
pub remux: bool,
pub containers: Vec<StreamContainer>,
@@ -104,6 +104,7 @@ pub enum StreamContainer {
WebM,
Matroska,
WebVTT,
+ MPEG4,
JVTT,
}
@@ -203,6 +204,7 @@ impl Display for StreamContainer {
StreamContainer::Matroska => "matroska",
StreamContainer::WebVTT => "webvtt",
StreamContainer::JVTT => "jvtt",
+ StreamContainer::MPEG4 => "mp4",
})
}
}
@@ -214,6 +216,7 @@ impl FromStr for StreamContainer {
"matroska" => StreamContainer::Matroska,
"webvtt" => StreamContainer::WebVTT,
"jvtt" => StreamContainer::JVTT,
+ "mp4" => StreamContainer::MPEG4,
_ => return Err(()),
})
}
diff --git a/import/src/infojson.rs b/import/src/infojson.rs
index 3a8d76e..1efbae9 100644
--- a/import/src/infojson.rs
+++ b/import/src/infojson.rs
@@ -86,7 +86,7 @@ pub struct YFormat {
pub fps: Option<f64>,
pub columns: Option<u32>,
pub fragments: Option<Vec<YFragment>>,
- pub resolution: String,
+ pub resolution: Option<String>,
pub dynamic_range: Option<String>,
pub aspect_ratio: Option<f64>,
pub http_headers: HashMap<String, String>,
diff --git a/server/src/routes/compat/jellyfin/mod.rs b/server/src/routes/compat/jellyfin/mod.rs
index 6066760..7393c5f 100644
--- a/server/src/routes/compat/jellyfin/mod.rs
+++ b/server/src/routes/compat/jellyfin/mod.rs
@@ -5,18 +5,14 @@
*/
pub mod models;
-use crate::routes::{
- stream::rocket_uri_macro_r_stream,
- ui::{
- account::{login_logic, session::Session},
- assets::{
- rocket_uri_macro_r_asset, rocket_uri_macro_r_item_backdrop,
- rocket_uri_macro_r_item_poster,
- },
- error::MyResult,
- node::{aspect_class, DatabaseNodeUserDataExt},
- sort::{filter_and_sort_nodes, FilterProperty, NodeFilterSort, SortOrder, SortProperty},
+use crate::routes::ui::{
+ account::{login_logic, session::Session},
+ assets::{
+ rocket_uri_macro_r_asset, rocket_uri_macro_r_item_backdrop, rocket_uri_macro_r_item_poster,
},
+ error::MyResult,
+ node::{aspect_class, DatabaseNodeUserDataExt},
+ sort::{filter_and_sort_nodes, FilterProperty, NodeFilterSort, SortOrder, SortProperty},
};
use anyhow::{anyhow, Context};
use jellybase::{database::Database, CONF};
diff --git a/stream/src/fragment.rs b/stream/src/fragment.rs
index b2e254b..e0644aa 100644
--- a/stream/src/fragment.rs
+++ b/stream/src/fragment.rs
@@ -54,6 +54,7 @@ pub async fn fragment_stream(
let location = transcode(
&format!("{path:?} {track_num} {index} {format_num} {container}"), // TODO maybe not use the entire source
format,
+ container,
move |b| {
tokio::task::spawn_blocking(move || {
if let Err(err) = jellyremuxer::write_fragment_into(
diff --git a/stream/src/lib.rs b/stream/src/lib.rs
index eb56529..18ad2a7 100644
--- a/stream/src/lib.rs
+++ b/stream/src/lib.rs
@@ -7,6 +7,7 @@
pub mod fragment;
pub mod fragment_index;
pub mod hls;
+pub mod stream_info;
pub mod webvtt;
use anyhow::{anyhow, Context, Result};
@@ -14,18 +15,14 @@ use fragment::fragment_stream;
use fragment_index::fragment_index_stream;
use hls::{hls_master_stream, hls_variant_stream};
use jellybase::common::{
- stream::{
- StreamContainer, StreamFormatInfo, StreamInfo, StreamSegmentInfo, StreamSpec,
- StreamTrackInfo, TrackKind,
- },
+ stream::{StreamContainer, StreamSpec},
Node,
};
-use jellyremuxer::metadata::{matroska_metadata, MatroskaMetadata};
use std::{collections::BTreeSet, io::SeekFrom, ops::Range, path::PathBuf, sync::Arc};
+use stream_info::{stream_info, write_stream_info};
use tokio::{
fs::File,
io::{duplex, AsyncReadExt, AsyncSeekExt, AsyncWriteExt, DuplexStream},
- task::spawn_blocking,
};
use tokio_util::io::SyncIoBridge;
@@ -50,6 +47,7 @@ pub fn stream_head(spec: &StreamSpec) -> StreamHead {
StreamContainer::Matroska => "video/x-matroska",
StreamContainer::WebVTT => "text/vtt",
StreamContainer::JVTT => "application/jellything-vtt+json",
+ StreamContainer::MPEG4 => "video/mp4",
};
match spec {
StreamSpec::Whep { .. } => cons("application/x-todo", false),
@@ -103,87 +101,6 @@ pub async fn stream(
Ok(a)
}
-async fn async_matroska_metadata(path: PathBuf) -> Result<Arc<MatroskaMetadata>> {
- Ok(spawn_blocking(move || matroska_metadata(&path)).await??)
-}
-
-pub(crate) struct InternalStreamInfo {
- pub paths: Vec<PathBuf>,
- pub metadata: Vec<Arc<MatroskaMetadata>>,
- pub track_to_file: Vec<(usize, u64)>,
-}
-
-async fn stream_info(info: Arc<SMediaInfo>) -> Result<(InternalStreamInfo, StreamInfo)> {
- let mut metadata = Vec::new();
- let mut paths = Vec::new();
- for path in &info.files {
- metadata.push(async_matroska_metadata(path.clone()).await?);
- paths.push(path.clone());
- }
-
- let mut tracks = Vec::new();
- let mut track_to_file = Vec::new();
-
- for (i, m) in metadata.iter().enumerate() {
- if let Some(t) = &m.tracks {
- for t in &t.entries {
- let mut formats = Vec::new();
- formats.push(StreamFormatInfo {
- codec: t.codec_id.to_string(),
- remux: true,
- byterate: 10., // TODO
- containers: [StreamContainer::Matroska].to_vec(),
- bit_depth: t.audio.as_ref().and_then(|a| a.bit_depth.map(|e| e as u8)),
- samplerate: t.audio.as_ref().map(|a| a.sampling_frequency),
- channels: t.audio.as_ref().map(|a| a.channels as usize),
- width: t.video.as_ref().map(|v| v.pixel_width),
- height: t.video.as_ref().map(|v| v.pixel_height),
- ..Default::default()
- });
- tracks.push(StreamTrackInfo {
- name: None,
- kind: match t.track_type {
- 1 => TrackKind::Video,
- 2 => TrackKind::Audio,
- 17 => TrackKind::Subtitle,
- _ => todo!(),
- },
- formats,
- });
- track_to_file.push((i, t.track_number));
- }
- }
- }
-
- let segment = StreamSegmentInfo {
- name: None,
- duration: metadata[0]
- .info
- .as_ref()
- .unwrap()
- .duration
- .unwrap_or_default(),
- tracks,
- };
- Ok((
- InternalStreamInfo {
- metadata,
- paths,
- track_to_file,
- },
- StreamInfo {
- name: info.info.title.clone(),
- segments: vec![segment],
- },
- ))
-}
-
-async fn write_stream_info(info: Arc<SMediaInfo>, mut b: DuplexStream) -> Result<()> {
- let (_, info) = stream_info(info).await?;
- b.write_all(&serde_json::to_vec(&info)?).await?;
- Ok(())
-}
-
async fn remux_stream(
node: Arc<Node>,
spec: StreamSpec,
diff --git a/stream/src/stream_info.rs b/stream/src/stream_info.rs
new file mode 100644
index 0000000..9d3d741
--- /dev/null
+++ b/stream/src/stream_info.rs
@@ -0,0 +1,164 @@
+use anyhow::Result;
+use ebml_struct::matroska::TrackEntry;
+use jellybase::{
+ common::stream::{
+ StreamContainer, StreamFormatInfo, StreamInfo, StreamSegmentInfo, StreamTrackInfo,
+ TrackKind,
+ },
+ CONF,
+};
+use jellyremuxer::metadata::{matroska_metadata, MatroskaMetadata};
+use std::{path::PathBuf, sync::Arc};
+use tokio::{
+ io::{AsyncWriteExt, DuplexStream},
+ spawn,
+ task::spawn_blocking,
+};
+
+use crate::SMediaInfo;
+
+async fn async_matroska_metadata(path: PathBuf) -> Result<Arc<MatroskaMetadata>> {
+ Ok(spawn_blocking(move || matroska_metadata(&path)).await??)
+}
+
+pub(crate) struct InternalStreamInfo {
+ pub paths: Vec<PathBuf>,
+ pub metadata: Vec<Arc<MatroskaMetadata>>,
+ pub track_to_file: Vec<(usize, u64)>,
+}
+
+pub(crate) async fn stream_info(info: Arc<SMediaInfo>) -> Result<(InternalStreamInfo, StreamInfo)> {
+ let mut metadata = Vec::new();
+ let mut paths = Vec::new();
+ for path in &info.files {
+ metadata.push(async_matroska_metadata(path.clone()).await?);
+ paths.push(path.clone());
+ }
+ let mut tracks = Vec::new();
+ let mut track_to_file = Vec::new();
+
+ for (i, m) in metadata.iter().enumerate() {
+ if let Some(t) = &m.tracks {
+ for t in &t.entries {
+ tracks.push(StreamTrackInfo {
+ name: None,
+ kind: match t.track_type {
+ 1 => TrackKind::Video,
+ 2 => TrackKind::Audio,
+ 17 => TrackKind::Subtitle,
+ _ => todo!(),
+ },
+ formats: stream_formats(t),
+ });
+ track_to_file.push((i, t.track_number));
+ }
+ }
+ }
+
+ let segment = StreamSegmentInfo {
+ name: None,
+ duration: metadata[0]
+ .info
+ .as_ref()
+ .unwrap()
+ .duration
+ .unwrap_or_default(),
+ tracks,
+ };
+ Ok((
+ InternalStreamInfo {
+ metadata,
+ paths,
+ track_to_file,
+ },
+ StreamInfo {
+ name: info.info.title.clone(),
+ segments: vec![segment],
+ },
+ ))
+}
+
+fn stream_formats(t: &TrackEntry) -> Vec<StreamFormatInfo> {
+ let mut formats = Vec::new();
+ formats.push(StreamFormatInfo {
+ codec: t.codec_id.to_string(),
+ remux: true,
+ bitrate: 2_000_000., // TODO
+ containers: containers_by_codec(&t.codec_id),
+ bit_depth: t.audio.as_ref().and_then(|a| a.bit_depth.map(|e| e as u8)),
+ samplerate: t.audio.as_ref().map(|a| a.sampling_frequency),
+ channels: t.audio.as_ref().map(|a| a.channels as usize),
+ width: t.video.as_ref().map(|v| v.pixel_width),
+ height: t.video.as_ref().map(|v| v.pixel_height),
+ ..Default::default()
+ });
+
+ match t.track_type {
+ 1 => {
+ let sw = t.video.as_ref().unwrap().pixel_width;
+ let sh = t.video.as_ref().unwrap().pixel_height;
+ for (w, br) in [(3840, 8e6), (1920, 5e6), (1280, 3e6), (640, 1e6)] {
+ if w > sw {
+ continue;
+ }
+ let h = (w * sh) / sw;
+ for (cid, enable) in [
+ ("V_AV1", CONF.encoders.av1.is_some()),
+ ("V_VP8", CONF.encoders.vp8.is_some()),
+ ("V_VP9", CONF.encoders.vp9.is_some()),
+ ("V_AVC", CONF.encoders.avc.is_some()),
+ ("V_HEVC", CONF.encoders.hevc.is_some()),
+ ] {
+ if enable {
+ formats.push(StreamFormatInfo {
+ codec: cid.to_string(),
+ bitrate: br,
+ remux: false,
+ containers: containers_by_codec(cid),
+ width: Some(w),
+ height: Some(h),
+ samplerate: None,
+ channels: None,
+ bit_depth: None,
+ });
+ }
+ }
+ }
+ }
+ 2 => {
+ for br in [256e3, 128e3, 64e3] {
+ formats.push(StreamFormatInfo {
+ codec: "A_OPUS".to_string(),
+ bitrate: br,
+ remux: false,
+ containers: containers_by_codec("A_OPUS"),
+ width: None,
+ height: None,
+ samplerate: Some(48e3),
+ channels: Some(2),
+ bit_depth: Some(32),
+ });
+ }
+ }
+ 17 => {}
+ _ => {}
+ }
+
+ formats
+}
+
+fn containers_by_codec(codec: &str) -> Vec<StreamContainer> {
+ use StreamContainer::*;
+ match codec {
+ "V_VP8" | "V_VP9" | "V_AV1" | "A_OPUS" | "A_VORBIS" => vec![Matroska, WebM],
+ "V_AVC" | "A_AAC" => vec![Matroska, MPEG4],
+ "S_TEXT/UTF8" | "S_TEXT/WEBVTT" => vec![Matroska, WebVTT, WebM, JVTT],
+ _ => vec![Matroska],
+ }
+}
+
+pub(crate) async fn write_stream_info(info: Arc<SMediaInfo>, mut b: DuplexStream) -> Result<()> {
+ let (_, info) = stream_info(info).await?;
+ spawn(async move { b.write_all(&serde_json::to_vec(&info)?).await });
+ Ok(())
+}
diff --git a/stream/src/webvtt.rs b/stream/src/webvtt.rs
index 960849c..e9f0181 100644
--- a/stream/src/webvtt.rs
+++ b/stream/src/webvtt.rs
@@ -3,23 +3,21 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-use anyhow::{anyhow, Context, Result};
-use jellybase::{
- cache::async_cache_memory,
- common::{stream::StreamSpec, Node},
- CONF,
-};
-use jellyremuxer::extract::extract_track;
-use jellytranscoder::subtitles::{parse_subtitles, write_webvtt};
+use anyhow::Result;
+use jellybase::common::{stream::StreamSpec, Node};
use std::sync::Arc;
-use tokio::io::{AsyncWriteExt, DuplexStream};
+use tokio::io::DuplexStream;
pub async fn vtt_stream(
json: bool,
node: Arc<Node>,
spec: StreamSpec,
- mut b: DuplexStream,
+ b: DuplexStream,
) -> Result<()> {
+ let _ = b;
+ let _ = spec;
+ let _ = node;
+ let _ = json;
// TODO cache
// TODO should use fragments too? big films take too long...
diff --git a/transcoder/src/fragment.rs b/transcoder/src/fragment.rs
index b88339c..3cb4c40 100644
--- a/transcoder/src/fragment.rs
+++ b/transcoder/src/fragment.rs
@@ -7,7 +7,7 @@
use crate::LOCAL_VIDEO_TRANSCODING_TASKS;
use jellybase::{
cache::{async_cache_file, CachePath},
- common::stream::StreamFormatInfo,
+ common::stream::{StreamContainer, StreamFormatInfo},
};
use log::{debug, info};
use std::process::Stdio;
@@ -21,73 +21,41 @@ use tokio::{
pub async fn transcode(
key: &str,
- enc: &StreamFormatInfo,
+ format: &StreamFormatInfo,
+ container: StreamContainer,
input: impl FnOnce(ChildStdin),
) -> anyhow::Result<CachePath> {
async_cache_file(
- &["frag-tc", key, &format!("{enc:?}")],
+ &["frag-tc", key, &format!("{format:?}")],
move |mut output| async move {
let _permit = LOCAL_VIDEO_TRANSCODING_TASKS.acquire().await?;
- debug!("transcoding fragment with {enc:?}");
+ debug!("transcoding fragment with {format:?}");
let mut args = Vec::<String>::new();
- // match enc {
- // EncodingProfile::Video {
- // codec,
- // preset,
- // bitrate,
- // width,
- // } => {
- // if let Some(width) = width {
- // args.push("-vf".to_string());
- // args.push(format!("scale={width}:-1"));
- // }
- // args.push("-c:v".to_string());
- // args.push(codec.to_string());
- // if let Some(preset) = preset {
- // args.push("-preset".to_string());
- // args.push(format!("{preset}"));
- // }
- // args.push("-b:v".to_string());
- // args.push(format!("{bitrate}"));
- // }
- // EncodingProfile::Audio {
- // codec,
- // bitrate,
- // sample_rate,
- // channels,
- // } => {
- // if let Some(channels) = channels {
- // args.push("-ac".to_string());
- // args.push(format!("{channels}"))
- // }
- // if let Some(sample_rate) = sample_rate {
- // args.push("-ar".to_string());
- // args.push(format!("{sample_rate}"))
- // }
- // args.push("-c:a".to_string());
- // args.push(codec.to_string());
- // args.push("-b:a".to_string());
- // args.push(format!("{bitrate}"));
- // }
- // EncodingProfile::Subtitles { codec } => {
- // args.push("-c:s".to_string());
- // args.push(codec.to_string());
- // }
- // };
+
+ match format.codec.as_str() {
+ "V_AVC" => {}
+
+ _ => unreachable!(),
+ }
+
info!("encoding with {:?}", args.join(" "));
+ let container = match container {
+ StreamContainer::WebM => "webm",
+ StreamContainer::Matroska => "matroska",
+ StreamContainer::WebVTT => "vtt",
+ StreamContainer::MPEG4 => "mp4",
+ StreamContainer::JVTT => unreachable!(),
+ };
+
let mut proc = Command::new("ffmpeg")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.args(["-f", "matroska", "-i", "pipe:0"])
.args(args)
- .args(["-f", "webm", "pipe:1"])
+ .args(["-f", container, "pipe:1"])
.spawn()?;
- // let mut proc = Command::new("cat")
- // .stdin(Stdio::piped())
- // .stdout(Stdio::piped())
- // .spawn()?;
let stdin = proc.stdin.take().unwrap();
let mut stdout = proc.stdout.take().unwrap();