aboutsummaryrefslogtreecommitdiff
path: root/server/src
diff options
context:
space:
mode:
Diffstat (limited to 'server/src')
-rw-r--r--server/src/routes/compat/jellyfin.rs548
-rw-r--r--server/src/routes/mod.rs16
2 files changed, 472 insertions, 92 deletions
diff --git a/server/src/routes/compat/jellyfin.rs b/server/src/routes/compat/jellyfin.rs
index 18f0242..7815c09 100644
--- a/server/src/routes/compat/jellyfin.rs
+++ b/server/src/routes/compat/jellyfin.rs
@@ -3,32 +3,41 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-use crate::routes::ui::{
- account::{login_logic, session::Session},
- assets::rocket_uri_macro_r_item_poster,
- error::MyResult,
- node::{aspect_class, DatabaseNodeUserDataExt},
+use crate::routes::{
+ stream::rocket_uri_macro_r_stream,
+ ui::{
+ account::{login_logic, session::Session},
+ assets::rocket_uri_macro_r_item_poster,
+ error::MyResult,
+ node::{aspect_class, DatabaseNodeUserDataExt},
+ },
};
-use anyhow::Context;
+use anyhow::{anyhow, Context};
use jellybase::{database::Database, CONF};
-use jellycommon::{user::WatchedState, NodeID, NodeKind};
+use jellycommon::{
+ stream::{StreamFormat, StreamSpec},
+ user::{NodeUserData, WatchedState},
+ MediaInfo, Node, NodeID, NodeKind, SourceTrack, SourceTrackKind, Visibility,
+};
use rocket::{get, post, response::Redirect, serde::json::Json, State};
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
-use std::net::IpAddr;
+use std::{collections::BTreeMap, net::IpAddr};
const SERVER_ID: &'static str = "1694a95daf70708147f16103ce7b7566";
const USER_ID: &'static str = "33f772aae6c2495ca89fe00340dbd17c";
const VERSION: &'static str = "10.10.0";
+const LOCAL_ADDRESS: &'static str = "http://127.0.0.1:8000";
#[get("/System/Info/Public")]
pub fn r_jellyfin_system_info_public_case() -> Json<Value> {
r_jellyfin_system_info_public()
}
+
#[get("/system/info/public")]
pub fn r_jellyfin_system_info_public() -> Json<Value> {
Json(json!({
- "LocalAddress": "http://127.0.0.1:8000",
+ "LocalAddress": LOCAL_ADDRESS,
"ServerName": CONF.brand.clone(),
"Version": VERSION,
"ProductName": "Jellything",
@@ -65,16 +74,16 @@ pub fn r_jellyfin_system_endpoint(_session: Session) -> Json<Value> {
}))
}
-// use rocket_ws::{Message, Stream, WebSocket};
-// #[get("/socket")]
-// pub fn r_jellyfin_socket(_session: Session, ws: WebSocket) -> Stream!['static] {
-// Stream! { ws =>
-// for await message in ws {
-// eprintln!("{message:?}");
-// }
-// yield Message::Text("test".to_string())
-// }
-// }
+use rocket_ws::{Message, Stream, WebSocket};
+#[get("/socket")]
+pub fn r_jellyfin_socket(_session: Session, ws: WebSocket) -> Stream!['static] {
+ Stream! { ws =>
+ for await message in ws {
+ eprintln!("{message:?}");
+ }
+ yield Message::Text("test".to_string())
+ }
+}
#[get("/System/Info")]
pub fn r_jellyfin_system_info(_session: Session) -> Json<Value> {
@@ -87,18 +96,18 @@ pub fn r_jellyfin_system_info(_session: Session) -> Json<Value> {
"CompletedInstallations": [],
"CanSelfRestart": true,
"CanLaunchWebBrowser": false,
- "ProgramDataPath": "/config",
- "WebPath": "/jellyfin/jellyfin-web",
- "ItemsByNamePath": "/var/lib/jellyfin/metadata",
- "CachePath": "/var/cache/jellyfin",
- "LogPath": "/config/log",
- "InternalMetadataPath": "/var/lib/jellyfin/metadata",
- "TranscodingTempPath": "/var/lib/jellyfin/transcodes",
+ "ProgramDataPath": "/path/to/data",
+ "WebPath": "/path/to/web",
+ "ItemsByNamePath": "/path/to/items",
+ "CachePath": "/path/to/cache",
+ "LogPath": "/path/to/log",
+ "InternalMetadataPath": "/path/to/metadata",
+ "TranscodingTempPath": "/path/to/transcodes",
"CastReceiverApplications": [],
"HasUpdateAvailable": false,
"EncoderLocation": "System",
"SystemArchitecture": "X64",
- "LocalAddress": "http://127.0.0.1:8000",
+ "LocalAddress": LOCAL_ADDRESS,
"ServerName": CONF.brand,
"Version": VERSION,
"OperatingSystem": "",
@@ -140,6 +149,50 @@ pub fn r_jellyfin_items_image_primary(
Redirect::permanent(rocket::uri!(r_item_poster(id, fillWidth)))
}
+#[get("/Users/<uid>/Items/<id>")]
+pub fn r_jellyfin_users_items(
+ session: Session,
+ database: &State<Database>,
+ uid: &str,
+ id: &str,
+) -> MyResult<Json<Value>> {
+ let _ = uid;
+ let (n, ud) = database.get_node_with_userdata(NodeID::from_slug(id), &session)?;
+ Ok(Json(node_object(&n, &ud)))
+}
+
+#[get("/Users/<uid>/Items?<StartIndex>&<ParentId>&<Limit>")]
+#[allow(non_snake_case)]
+pub fn r_jellyfin_items(
+ session: Session,
+ database: &State<Database>,
+ uid: &str,
+ StartIndex: Option<usize>,
+ ParentId: &str,
+ Limit: usize,
+) -> MyResult<Json<Value>> {
+ let _ = uid;
+ let children = database.get_node_children(NodeID::from_slug(ParentId))?;
+
+ let mut items = Vec::new();
+ for nid in children
+ .into_iter()
+ .skip(StartIndex.unwrap_or_default())
+ .take(Limit)
+ {
+ let (n, ud) = database.get_node_with_userdata(nid, &session)?;
+ if n.visibility >= Visibility::Reduced {
+ items.push(node_object(&n, &ud))
+ }
+ }
+
+ Ok(Json(json!({
+ "Items": items,
+ "TotalRecordCount": items.len(),
+ "StartIndex": 0
+ })))
+}
+
#[get("/UserViews?<userId>")]
#[allow(non_snake_case)]
pub fn r_jellyfin_users_views(
@@ -156,67 +209,9 @@ pub fn r_jellyfin_users_views(
let mut items = Vec::new();
for nid in toplevel {
let (n, ud) = database.get_node_with_userdata(nid, &session)?;
-
- items.push(json!({
- "Name": n.title,
- "ServerId": SERVER_ID,
- "Id": n.slug.clone(),
- "Etag": "blob",
- "DateCreated": "0001-01-01T00:00:00.0000000Z",
- "CanDelete": false,
- "CanDownload": false,
- "PreferredMetadataLanguage": "",
- "PreferredMetadataCountryCode": "",
- "SortName": n.slug.clone(),
- "ForcedSortName": "",
- "ExternalUrls": [],
- "Path": "/why/does/it/matter",
- "EnableMediaSourceDisplay": true,
- "CustomRating": "",
- "ChannelId": null,
- "Overview": "",
- "Taglines": [],
- "Genres": [],
- "PlayAccess": "Full",
- "RemoteTrailers": [],
- "ProviderIds": {},
- "IsFolder": true,
- "ParentId": "todo-parent", // TODO
- "Type": match n.kind {
- NodeKind::Collection | _ => "CollectionFolder",
- },
- "People": [],
- "Studios": [],
- "GenreItems": [],
- "LocalTrailerCount": 0,
- "UserData": {
- "PlaybackPositionTicks": 0,
- "PlayCount": if ud.watched == WatchedState::Watched { 1 } else { 0 },
- "IsFavorite": ud.rating > 0,
- "Played": ud.watched == WatchedState::Watched,
- "Key": "7a2175bc-cb1f-1a94-152c-bd2b2bae8f6d",
- "ItemId": "00000000000000000000000000000000"
- },
- "ChildCount": 2,
- "SpecialFeatureCount": 0,
- "DisplayPreferencesId": n.slug.clone(),
- "Tags": [],
- "PrimaryImageAspectRatio": match aspect_class(n.kind) {
- "aspect-thumb" => 16. / 9.,
- "aspect-land" => 2f32.sqrt(),
- "aspect-port" => 1. / 2f32.sqrt(),
- "aspect-square" | _ => 1.,
- },
- "CollectionType": "movies",
- "ImageTags": {
- "Primary": "the-image"
- },
- "BackdropImageTags": [],
- "LocationType": "FileSystem",
- "MediaType": "Unknown",
- "LockedFields": [],
- "LockData": false
- }))
+ if n.visibility >= Visibility::Reduced {
+ items.push(node_object(&n, &ud))
+ }
}
Ok(Json(json!({
@@ -235,6 +230,80 @@ pub fn r_jellyfin_livetv_programs_recommended(_session: Session) -> Json<Value>
}))
}
+#[get("/Users/<uid>/Items/<id>/Intros")]
+pub fn r_jellyfin_items_intros(_session: Session, uid: &str, id: &str) -> Json<Value> {
+ let _ = (uid, id);
+ Json(json!({
+ "Items": [],
+ "TotalRecordCount": 0,
+ "StartIndex": 0
+ }))
+}
+
+#[post("/Items/<id>/PlaybackInfo")]
+pub fn r_jellyfin_items_playbackinfo(
+ _session: Session,
+ database: &State<Database>,
+ id: &str,
+) -> MyResult<Json<Value>> {
+ let node = database
+ .get_node_slug(id)?
+ .ok_or(anyhow!("node does not exist"))?;
+ let media = node.media.as_ref().ok_or(anyhow!("node has no media"))?;
+ let ms = media_source_object(&node, media);
+ Ok(Json(json!({
+ "MediaSources": [ms],
+ "PlaySessionId": "why do we need this id?"
+ })))
+}
+
+#[get("/Videos/<id>/stream.mkv")]
+pub fn r_jellyfin_video_stream(_session: Session, id: &str) -> Redirect {
+ Redirect::temporary(rocket::uri!(r_stream(
+ id,
+ StreamSpec {
+ format: StreamFormat::Matroska,
+ webm: Some(true),
+ track: vec![0, 1],
+ index: None,
+ profile: None,
+ }
+ )))
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "PascalCase")]
+struct JellyfinProgressData {
+ item_id: String,
+ position_ticks: f64,
+}
+#[post("/Sessions/Playing/Progress", data = "<data>")]
+#[allow(private_interfaces)]
+pub fn r_jellyfin_sessions_playing_progress(
+ session: Session,
+ database: &State<Database>,
+ data: Json<JellyfinProgressData>,
+) -> MyResult<()> {
+ let position = data.position_ticks / 10_000_000.;
+ database.update_node_udata(
+ NodeID::from_slug(&data.item_id),
+ &session.user.name,
+ |udata| {
+ udata.watched = match udata.watched {
+ WatchedState::None | WatchedState::Pending | WatchedState::Progress(_) => {
+ WatchedState::Progress(position)
+ }
+ WatchedState::Watched => WatchedState::Watched,
+ };
+ Ok(())
+ },
+ )?;
+ Ok(())
+}
+
+#[post("/Sessions/Playing")]
+pub fn r_jellyfin_sessions_playing(_session: Session) {}
+
#[get("/Playback/BitrateTest?<Size>")]
#[allow(non_snake_case)]
pub fn r_jellyfin_playback_bitratetest(_session: Session, Size: usize) -> Vec<u8> {
@@ -317,6 +386,307 @@ pub fn r_jellyfin_users_authenticatebyname(
})))
}
+#[derive(Clone, Serialize)]
+enum JellyfinMediaStreamType {
+ Video,
+ Audio,
+ Subtitle,
+}
+
+#[derive(Clone, Serialize)]
+#[serde(rename_all = "PascalCase")]
+struct JellyfinMediaStream {
+ codec: String,
+ time_base: String,
+ video_range: String,
+ video_range_type: String,
+ audio_spatial_format: String,
+ display_title: String,
+ is_interlaced: bool,
+ is_avc: bool,
+ bit_rate: usize,
+ bit_depth: usize,
+ ref_frames: usize,
+ is_default: bool,
+ is_forced: bool,
+ is_hearing_impaired: bool,
+ height: Option<u64>,
+ width: Option<u64>,
+ average_frame_rate: Option<f64>,
+ real_frame_rate: Option<f64>,
+ reference_frame_rate: Option<f64>,
+ profile: String,
+ r#type: JellyfinMediaStreamType,
+ aspect_ratio: String,
+ index: usize,
+ is_external: bool,
+ is_text_subtitle_stream: bool,
+ supports_external_stream: bool,
+ pixel_format: String,
+ level: usize,
+ is_anamorphic: bool,
+ channel_layout: Option<String>,
+ channels: Option<usize>,
+ sample_rate: Option<f64>,
+ localized_default: String,
+ localized_external: String,
+}
+
+#[derive(Clone, Serialize)]
+enum JellyfinMediaSourceProtocol {
+ File,
+}
+#[derive(Clone, Serialize)]
+enum JellyfinMediaSourceType {
+ Default,
+}
+
+#[derive(Clone, Serialize)]
+enum JellyfinVideoType {
+ VideoFile,
+}
+
+#[derive(Clone, Serialize)]
+#[serde(rename_all = "PascalCase")]
+struct JellyfinMediaSource {
+ protocol: JellyfinMediaSourceProtocol,
+ id: String,
+ path: String,
+ r#type: JellyfinMediaSourceType,
+ container: String,
+ size: usize,
+ name: String,
+ is_remote: bool,
+ e_tag: String,
+ run_time_ticks: f64,
+ read_at_native_framerate: bool,
+ ignore_dts: bool,
+ ignore_index: bool,
+ gen_pts_input: bool,
+ supports_transcoding: bool,
+ supports_direct_stream: bool,
+ supports_direct_play: bool,
+ is_infinite_stream: bool,
+ use_most_compatible_transcoding_profile: bool,
+ requires_opening: bool,
+ requires_closing: bool,
+ requires_looping: bool,
+ supports_probing: bool,
+ video_type: JellyfinVideoType,
+ media_streams: Vec<JellyfinMediaStream>,
+ media_attachments: Vec<()>,
+ formats: Vec<()>,
+ bitrate: usize,
+ required_http_headers: BTreeMap<(), ()>,
+ transcoding_sub_protocol: String,
+ default_audio_stream_index: usize,
+ default_subtitle_stream_index: usize,
+ has_segments: bool,
+}
+
+fn track_object(index: usize, track: &SourceTrack) -> JellyfinMediaStream {
+ let fr = if let SourceTrackKind::Video { fps, .. } = &track.kind {
+ Some(fps.unwrap_or_default())
+ } else {
+ None
+ };
+ JellyfinMediaStream {
+ codec: match track.codec.as_str() {
+ "V_HEVC" => "hevc",
+ "V_AV1" => "av1",
+ "V_VP8" => "vp8",
+ "V_VP9" => "vp9",
+ "A_AAC" => "aac",
+ "A_OPUS" => "opus",
+ _ => "unknown",
+ }
+ .to_string(),
+ time_base: "1/1000".to_string(), // TODO unsure what that means
+ video_range: if track.kind.letter() == 'v' {
+ "SDR"
+ } else {
+ "Unknown"
+ }
+ .to_string(),
+ video_range_type: if track.kind.letter() == 'v' {
+ "SDR"
+ } else {
+ "Unknown"
+ }
+ .to_string(),
+ audio_spatial_format: "None".to_string(),
+ display_title: track.to_string(),
+ is_interlaced: false, // TODO assuming that
+ is_avc: track.codec.as_str() == "V_AVC",
+ bit_rate: 5_000_000, // TODO totally
+ bit_depth: 8,
+ ref_frames: 1,
+ is_default: true,
+ is_forced: false,
+ is_hearing_impaired: false,
+ height: if let SourceTrackKind::Video { height, .. } = &track.kind {
+ Some(*height)
+ } else {
+ None
+ },
+ width: if let SourceTrackKind::Video { width, .. } = &track.kind {
+ Some(*width)
+ } else {
+ None
+ },
+ average_frame_rate: fr,
+ real_frame_rate: fr,
+ reference_frame_rate: fr,
+ profile: "Main".to_string(),
+ r#type: match track.kind {
+ SourceTrackKind::Audio { .. } => JellyfinMediaStreamType::Audio,
+ SourceTrackKind::Video { .. } => JellyfinMediaStreamType::Video,
+ SourceTrackKind::Subtitles => JellyfinMediaStreamType::Subtitle,
+ },
+ aspect_ratio: "1:1".to_string(), // TODO aaa
+ index,
+ is_external: false,
+ is_text_subtitle_stream: false,
+ supports_external_stream: false,
+ pixel_format: "yuv420p".to_string(),
+ level: 150, // TODO what this mean?
+ is_anamorphic: false,
+ channel_layout: if let SourceTrackKind::Audio { .. } = &track.kind {
+ Some("5.1".to_string()) // TODO aaa
+ } else {
+ None
+ },
+ channels: if let SourceTrackKind::Audio { channels, .. } = &track.kind {
+ Some(*channels)
+ } else {
+ None
+ },
+ sample_rate: if let SourceTrackKind::Audio { sample_rate, .. } = &track.kind {
+ Some(*sample_rate)
+ } else {
+ None
+ },
+ localized_default: "Default".to_string(),
+ localized_external: "External".to_string(),
+ }
+}
+
+fn media_source_object(node: &Node, m: &MediaInfo) -> JellyfinMediaSource {
+ JellyfinMediaSource {
+ protocol: JellyfinMediaSourceProtocol::File,
+ id: node.slug.clone(),
+ path: format!("/path/to/{}.mkv", node.slug),
+ r#type: JellyfinMediaSourceType::Default,
+ container: "mkv".to_string(),
+ size: 1_000_000_000,
+ name: node.slug.clone(),
+ is_remote: false,
+ e_tag: "blub".to_string(),
+ run_time_ticks: m.duration * 10_000_000.,
+ read_at_native_framerate: false,
+ ignore_dts: false,
+ ignore_index: false,
+ gen_pts_input: false,
+ supports_transcoding: true,
+ supports_direct_stream: true,
+ supports_direct_play: true,
+ is_infinite_stream: false,
+ use_most_compatible_transcoding_profile: false,
+ requires_opening: false,
+ requires_closing: false,
+ requires_looping: false,
+ supports_probing: true,
+ video_type: JellyfinVideoType::VideoFile,
+ media_streams: m
+ .tracks
+ .iter()
+ .enumerate()
+ .map(|(i, t)| track_object(i, t))
+ .collect::<Vec<_>>(),
+ media_attachments: Vec::new(),
+ formats: Vec::new(),
+ bitrate: 10_000_000,
+ required_http_headers: BTreeMap::new(),
+ transcoding_sub_protocol: "http".to_string(),
+ default_audio_stream_index: 1, // TODO
+ default_subtitle_stream_index: 2, // TODO
+ has_segments: false,
+ }
+}
+
+fn node_object(node: &Node, userdata: &NodeUserData) -> Value {
+ let media_source = node.media.as_ref().map(|m| media_source_object(node, m));
+
+ json!({
+ "Name": node.title,
+ "ServerId": SERVER_ID,
+ "Id": node.slug.clone(),
+ "Etag": "blob",
+ "DateCreated": "0001-01-01T00:00:00.0000000Z",
+ "CanDelete": false,
+ "CanDownload": true,
+ "PreferredMetadataLanguage": "",
+ "PreferredMetadataCountryCode": "",
+ "SortName": node.slug.clone(),
+ "ForcedSortName": "",
+ "ExternalUrls": [],
+ "EnableMediaSourceDisplay": true,
+ "CustomRating": "",
+ "ChannelId": null,
+ "Overview": "",
+ "Taglines": [],
+ "Genres": [],
+ "PlayAccess": "Full",
+ "RemoteTrailers": [],
+ "ProviderIds": {},
+ "IsFolder": !node.media.is_some(),
+ "ParentId": "todo-parent", // TODO
+ "Type": match node.kind {
+ NodeKind::Movie | NodeKind::Video | NodeKind::ShortFormVideo => "Movie",
+ NodeKind::Collection | _ => "CollectionFolder",
+ },
+ "People": [],
+ "Studios": [],
+ "GenreItems": [],
+ "LocalTrailerCount": 0,
+ "SpecialFeatureCount": 0,
+ "ChildCount": 0,
+ "LockedFields": [],
+ "LockData": false,
+ "Tags": [],
+ "UserData": {
+ "PlaybackPositionTicks": 0,
+ "PlayCount": if userdata.watched == WatchedState::Watched { 1 } else { 0 },
+ "IsFavorite": userdata.rating > 0,
+ "Played": userdata.watched == WatchedState::Watched,
+ "Key": "7a2175bc-cb1f-1a94-152c-bd2b2bae8f6d",
+ "ItemId": "00000000000000000000000000000000"
+ },
+ "DisplayPreferencesId": node.slug.clone(),
+ "PrimaryImageAspectRatio": match aspect_class(node.kind) {
+ "aspect-thumb" => 16. / 9.,
+ "aspect-land" => 2f32.sqrt(),
+ "aspect-port" => 1. / 2f32.sqrt(),
+ "aspect-square" | _ => 1.,
+ },
+ "CollectionType": "unknown",
+ "ImageTags": {
+ "Primary": "the-image"
+ },
+ "BackdropImageTags": [],
+ "LocationType": "FileSystem",
+ "MediaType": if node.media.is_some() { "Video" } else { "Unknown" },
+ "VideoType": node.media.as_ref().map(|_| "VideoFile"),
+ "LocationType": node.media.as_ref().map(|_| "FileSystem"),
+ "PlayAccess": node.media.as_ref().map(|_| "Full"),
+ "Container": node.media.as_ref().map(|_| "mkv"),
+ "RunTimeTicks": if let Some(m) = &node.media { Some((m.duration * 10_000_000.) as i64) } else { None },
+ "MediaSources": media_source.as_ref().map(|s|vec![s.clone()]),
+ "MediaStreams": media_source.as_ref().map(|s|s.media_streams.clone()),
+ "Path": node.media.as_ref().map(|_|format!("/path/to/{}.mkv", node.slug)),
+ })
+}
+
fn user_object(username: String) -> Value {
json!({
"Name": username,
diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs
index f2cfdfd..93a832f 100644
--- a/server/src/routes/mod.rs
+++ b/server/src/routes/mod.rs
@@ -10,12 +10,15 @@ use base64::Engine;
use compat::{
jellyfin::{
r_jellyfin_branding_configuration, r_jellyfin_branding_css,
- r_jellyfin_displaypreferences_usersettings, r_jellyfin_items_image_primary,
+ r_jellyfin_displaypreferences_usersettings, r_jellyfin_items,
+ r_jellyfin_items_image_primary, r_jellyfin_items_intros, r_jellyfin_items_playbackinfo,
r_jellyfin_livetv_programs_recommended, r_jellyfin_playback_bitratetest,
r_jellyfin_quickconnect_enabled, r_jellyfin_sessions_capabilities_full,
+ r_jellyfin_sessions_playing, r_jellyfin_sessions_playing_progress, r_jellyfin_socket,
r_jellyfin_system_endpoint, r_jellyfin_system_info, r_jellyfin_system_info_public,
r_jellyfin_system_info_public_case, r_jellyfin_users_authenticatebyname,
- r_jellyfin_users_id, r_jellyfin_users_public, r_jellyfin_users_views,
+ r_jellyfin_users_id, r_jellyfin_users_items, r_jellyfin_users_public,
+ r_jellyfin_users_views, r_jellyfin_video_stream,
},
youtube::{r_youtube_channel, r_youtube_watch},
};
@@ -173,7 +176,14 @@ pub fn build_rocket(database: Database, federation: Federation) -> Rocket<Build>
r_jellyfin_users_views,
r_jellyfin_items_image_primary,
r_jellyfin_livetv_programs_recommended,
- // r_jellyfin_socket,
+ r_jellyfin_users_items,
+ r_jellyfin_items,
+ r_jellyfin_items_intros,
+ r_jellyfin_items_playbackinfo,
+ r_jellyfin_video_stream,
+ r_jellyfin_sessions_playing,
+ r_jellyfin_sessions_playing_progress,
+ r_jellyfin_socket,
],
)
}