diff options
Diffstat (limited to 'server/src/routes')
-rw-r--r-- | server/src/routes/compat/jellyfin.rs | 548 | ||||
-rw-r--r-- | server/src/routes/mod.rs | 16 |
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, ], ) } |