From 8118667fc80bdf0246b16dff08f5a522efea27cf Mon Sep 17 00:00:00 2001 From: metamuffin Date: Tue, 4 Feb 2025 14:12:58 +0100 Subject: jellyfin to multiple files --- server/src/routes/compat/jellyfin.rs | 929 ---------------------------- server/src/routes/compat/jellyfin/mod.rs | 805 ++++++++++++++++++++++++ server/src/routes/compat/jellyfin/models.rs | 199 ++++++ server/src/routes/mod.rs | 130 ++-- 4 files changed, 1073 insertions(+), 990 deletions(-) delete mode 100644 server/src/routes/compat/jellyfin.rs create mode 100644 server/src/routes/compat/jellyfin/mod.rs create mode 100644 server/src/routes/compat/jellyfin/models.rs (limited to 'server/src') diff --git a/server/src/routes/compat/jellyfin.rs b/server/src/routes/compat/jellyfin.rs deleted file mode 100644 index 17ddcb6..0000000 --- a/server/src/routes/compat/jellyfin.rs +++ /dev/null @@ -1,929 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -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::{anyhow, Context}; -use jellybase::{database::Database, CONF}; -use jellycommon::{ - stream::{StreamFormat, StreamSpec}, - user::{NodeUserData, WatchedState}, - MediaInfo, Node, NodeID, NodeKind, SourceTrack, SourceTrackKind, Visibility, -}; -use rocket::{get, post, response::Redirect, serde::json::Json, FromForm, State}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -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 { - r_jellyfin_system_info_public() -} - -#[get("/system/info/public")] -pub fn r_jellyfin_system_info_public() -> Json { - Json(json!({ - "LocalAddress": LOCAL_ADDRESS, - "ServerName": CONF.brand.clone(), - "Version": VERSION, - "ProductName": "Jellything", - "OperatingSystem": "", - "Id": SERVER_ID, - "StartupWizardCompleted": true, - })) -} - -#[get("/Branding/Configuration")] -pub fn r_jellyfin_branding_configuration() -> Json { - Json(json!({ - "LoginDisclaimer": format!("{} - {}", CONF.brand, CONF.slogan), - "CustomCss": "", - "SplashscreenEnabled": false, - })) -} - -#[get("/users/public")] -pub fn r_jellyfin_users_public() -> Json { - Json(json!([])) -} - -#[get("/QuickConnect/Enabled")] -pub fn r_jellyfin_quickconnect_enabled(_session: Session) -> Json { - Json(json!(false)) -} - -#[get("/System/Endpoint")] -pub fn r_jellyfin_system_endpoint(_session: Session) -> Json { - Json(json!({ - "IsLocal": false, - "IsInNetwork": false, - })) -} - -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 { - Json(json!({ - "OperatingSystemDisplayName": "", - "HasPendingRestart": false, - "IsShuttingDown": false, - "SupportsLibraryMonitor": true, - "WebSocketPortNumber": 8096, - "CompletedInstallations": [], - "CanSelfRestart": true, - "CanLaunchWebBrowser": false, - "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": LOCAL_ADDRESS, - "ServerName": CONF.brand, - "Version": VERSION, - "OperatingSystem": "", - "Id": SERVER_ID - })) -} - -#[get("/DisplayPreferences/usersettings")] -pub fn r_jellyfin_displaypreferences_usersettings(_session: Session) -> Json { - Json(json!({ - "Id": "3ce5b65d-e116-d731-65d1-efc4a30ec35c", - "SortBy": "SortName", - "RememberIndexing": false, - "PrimaryImageHeight": 250, - "PrimaryImageWidth": 250, - "CustomPrefs": false, - "ScrollDirection": "Horizontal", - "ShowBackdrop": true, - "RememberSorting": false, - "SortOrder": "Ascending", - "ShowSidebar": false, - "Client": "emby", - })) -} - -#[get("/Users/")] -pub fn r_jellyfin_users_id(session: Session, id: &str) -> Json { - let _ = id; - Json(user_object(session.user.name)) -} - -#[get("/Items//Images/Primary?")] -#[allow(non_snake_case)] -pub fn r_jellyfin_items_image_primary( - _session: Session, - id: &str, - fillWidth: Option, -) -> Redirect { - Redirect::permanent(rocket::uri!(r_item_poster(id, fillWidth))) -} - -#[get("/Users//Items/")] -#[allow(private_interfaces)] -pub fn r_jellyfin_users_items_item( - session: Session, - database: &State, - uid: &str, - id: &str, -) -> MyResult> { - let _ = uid; - let (n, ud) = database.get_node_with_userdata(NodeID::from_slug(id), &session)?; - Ok(Json(item_object(&n, &ud))) -} - -#[derive(Debug, FromForm)] -struct JellyfinItemQuery { - #[field(name = uncased("searchterm"))] - search_term: Option, - #[field(name = uncased("limit"))] - limit: usize, - #[field(name = uncased("parentid"))] - parent_id: Option, - #[field(name = uncased("startindex"))] - start_index: Option, - #[field(name = uncased("includeitemtypes"))] - include_item_types: Option, - - internal_artists: bool, - internal_persons: bool, -} - -#[get("/Users//Items?")] -#[allow(private_interfaces)] -pub fn r_jellyfin_users_items( - session: Session, - database: &State, - uid: &str, - query: JellyfinItemQuery, -) -> MyResult> { - let _ = uid; - r_jellyfin_items(session, database, query) -} - -#[get("/Artists?")] -#[allow(private_interfaces)] -pub fn r_jellyfin_artists( - session: Session, - database: &State, - mut query: JellyfinItemQuery, -) -> MyResult> { - query.internal_artists = true; - r_jellyfin_items(session, database, query)?; // TODO - Ok(Json(json!({ - "Items": [], - "TotalRecordCount": 0, - "StartIndex": 0 - }))) -} - -#[get("/Persons?")] -#[allow(private_interfaces)] -pub fn r_jellyfin_persons( - session: Session, - database: &State, - mut query: JellyfinItemQuery, -) -> MyResult> { - query.internal_persons = true; - r_jellyfin_items(session, database, query)?; // TODO - Ok(Json(json!({ - "Items": [], - "TotalRecordCount": 0, - "StartIndex": 0 - }))) -} - -#[get("/Items?")] -#[allow(private_interfaces)] -pub fn r_jellyfin_items( - session: Session, - database: &State, - query: JellyfinItemQuery, -) -> MyResult> { - let mut items = Vec::new(); - - let nodes = if let Some(q) = query.search_term { - database - .search(&q, query.limit, query.start_index.unwrap_or_default())? - .1 - } else if let Some(parent) = query.parent_id { - database - .get_node_children(NodeID::from_slug(&parent))? - .into_iter() - .skip(query.start_index.unwrap_or_default()) - .take(query.limit) - .collect() - } else { - vec![] - }; - - let filter_kind = query - .include_item_types - .map(|n| match n.as_str() { - "Movie" => NodeKind::Movie, - "Audio" => NodeKind::Music, - "Video" => NodeKind::Video, - _ => NodeKind::Unknown, - }) - .or(if query.internal_artists { - Some(NodeKind::Channel) - } else { - None - }) - .or(if query.internal_persons { - Some(NodeKind::Channel) - } else { - None - }); - for nid in nodes { - let (n, ud) = database.get_node_with_userdata(nid, &session)?; - if let Some(fk) = filter_kind { - if n.kind != fk { - continue; - } - } - if n.visibility >= Visibility::Reduced { - items.push(item_object(&n, &ud)) - } - } - - Ok(Json(json!({ - "Items": items, - "TotalRecordCount": items.len(), - "StartIndex": query.start_index.unwrap_or_default() - }))) -} - -#[get("/UserViews?")] -#[allow(non_snake_case)] -pub fn r_jellyfin_users_views( - session: Session, - database: &State, - userId: &str, -) -> MyResult> { - let _ = userId; - - let toplevel = database - .get_node_children(NodeID::from_slug("library")) - .context("root node missing")?; - - let mut items = Vec::new(); - for nid in toplevel { - let (n, ud) = database.get_node_with_userdata(nid, &session)?; - if n.visibility >= Visibility::Reduced { - items.push(item_object(&n, &ud)) - } - } - - Ok(Json(json!({ - "Items": items, - "TotalRecordCount": items.len(), - "StartIndex": 0 - }))) -} - -#[get("/LiveTv/Programs/Recommended")] -pub fn r_jellyfin_livetv_programs_recommended(_session: Session) -> Json { - Json(json!({ - "Items": [], - "TotalRecordCount": 0, - "StartIndex": 0 - })) -} - -#[get("/Users//Items//Intros")] -pub fn r_jellyfin_items_intros(_session: Session, uid: &str, id: &str) -> Json { - let _ = (uid, id); - Json(json!({ - "Items": [], - "TotalRecordCount": 0, - "StartIndex": 0 - })) -} - -#[post("/Items//PlaybackInfo")] -pub fn r_jellyfin_items_playbackinfo( - _session: Session, - database: &State, - id: &str, -) -> MyResult> { - 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//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 = "")] -#[allow(private_interfaces)] -pub fn r_jellyfin_sessions_playing_progress( - session: Session, - database: &State, - data: Json, -) -> 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?")] -#[allow(non_snake_case)] -pub fn r_jellyfin_playback_bitratetest(_session: Session, Size: usize) -> Vec { - vec![0; Size.min(1_000_000)] -} - -#[get("/Branding/Css")] -pub fn r_jellyfin_branding_css(_session: Session) -> String { - "".to_string() -} - -#[post("/Sessions/Capabilities/Full")] -pub fn r_jellyfin_sessions_capabilities_full(_session: Session) -> () { - () -} - -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -struct AuthData { - pw: String, - username: String, -} - -#[post("/Users/authenticatebyname", data = "")] -#[allow(private_interfaces)] -pub fn r_jellyfin_users_authenticatebyname( - client_addr: IpAddr, - database: &State, - data: Json, -) -> MyResult> { - let token = login_logic(database, &data.username, &data.pw, None, None)?; - - Ok(Json(json!({ - "User": user_object(data.username.clone()), - "SessionInfo": { - "PlayState": { - "CanSeek": false, - "IsPaused": false, - "IsMuted": false, - "RepeatMode": "RepeatNone", - "PlaybackOrder": "Default" - }, - "AdditionalUsers": [], - "Capabilities": { - "PlayableMediaTypes": [ - "Audio", - "Video" - ], - "SupportedCommands": [], - // "MoveUp", "MoveDown", "MoveLeft", "MoveRight", "PageUp", "PageDown", "PreviousLetter", "NextLetter", "ToggleOsd", "ToggleContextMenu", "Select", "Back", "SendKey", "SendString", "GoHome", "GoToSettings", "VolumeUp", "VolumeDown", "Mute", "Unmute", "ToggleMute", "SetVolume", "SetAudioStreamIndex", "SetSubtitleStreamIndex", "DisplayContent", "GoToSearch", "DisplayMessage", "SetRepeatMode", "SetShuffleQueue", "ChannelUp", "ChannelDown", "PlayMediaSource", "PlayTrailers" - "SupportsMediaControl": true, - "SupportsPersistentIdentifier": false - }, - "RemoteEndPoint": client_addr, - "PlayableMediaTypes": [ - "Audio", - "Video" - ], - "Id": "6e05fbb4fe33477b991455c97a57e25d", - "UserId": USER_ID, - "UserName": data.username.clone(), - "Client": "Jellyfin Web", - "LastActivityDate": "0001-01-01T00:00:00.0000000Z", - "LastPlaybackCheckIn": "0001-01-01T00:00:00.0000000Z", - "DeviceName": "blub blub blub", - "DeviceId": "wagening", - "ApplicationVersion": VERSION, - "IsActive": true, - "SupportsMediaControl": false, - "SupportsRemoteControl": false, - "NowPlayingQueue": [], - "NowPlayingQueueFullItems": [], - "HasCustomDeviceName": false, - "ServerId": SERVER_ID, - "SupportedCommands": [] - // "MoveUp", "MoveDown", "MoveLeft", "MoveRight", "PageUp", "PageDown", "PreviousLetter", "NextLetter", "ToggleOsd", "ToggleContextMenu", "Select", "Back", "SendKey", "SendString", "GoHome", "GoToSettings", "VolumeUp", "VolumeDown", "Mute", "Unmute", "ToggleMute", "SetVolume", "SetAudioStreamIndex", "SetSubtitleStreamIndex", "DisplayContent", "GoToSearch", "DisplayMessage", "SetRepeatMode", "SetShuffleQueue", "ChannelUp", "ChannelDown", "PlayMediaSource", "PlayTrailers" - }, - "AccessToken": token, - "ServerId": SERVER_ID - }))) -} - -#[derive(Debug, Serialize, Deserialize)] -enum JellyfinItemType { - AudioBook, - Movie, - BoxSet, - Book, - Photo, - PhotoAlbum, - TvChannel, - LiveTvProgram, - Video, - Audio, - MusicAlbum, - CollectionFolder, -} - -#[derive(Debug, Clone, Serialize)] -enum JellyfinMediaStreamType { - Video, - Audio, - Subtitle, -} - -#[derive(Debug, 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, - width: Option, - average_frame_rate: Option, - real_frame_rate: Option, - reference_frame_rate: Option, - 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, - channels: Option, - sample_rate: Option, - localized_default: String, - localized_external: String, -} - -#[derive(Debug, Clone, Serialize)] -enum JellyfinMediaSourceProtocol { - File, -} -#[derive(Debug, Clone, Serialize)] -enum JellyfinMediaSourceType { - Default, -} - -#[derive(Debug, Clone, Serialize)] -enum JellyfinVideoType { - VideoFile, -} - -#[derive(Debug, 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, - 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::>(), - 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, - } -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "PascalCase")] -struct JellyfinItem { - name: String, - server_id: String, - id: String, - e_tag: String, - date_created: String, - can_delete: bool, - can_download: bool, - preferred_metadata_language: String, - preferred_metadata_country_code: String, - sort_name: String, - forced_sort_name: String, - external_urls: Vec<()>, - enable_media_source_display: bool, - custom_rating: String, - channel_id: Option, - overview: String, - taglines: Vec, - genres: Vec<()>, - play_access: Option, - remote_trailers: Vec<()>, - provider_ids: BTreeMap<(), ()>, - is_folder: bool, - parent_id: String, - r#type: JellyfinItemType, - people: Vec<()>, - studios: Vec<()>, - genre_items: Vec<()>, - local_trailer_count: usize, - special_feature_count: usize, - child_count: usize, - locked_fields: Vec<()>, - lock_data: bool, - tags: Vec, - user_data: Value, - display_preferences_id: String, - primary_image_aspect_ratio: f64, - collection_type: String, - image_tags: BTreeMap, - backdrop_image_tags: Vec<()>, - location_type: Option, - media_type: String, - video_type: Option, - container: Option, - run_time_ticks: Option, - media_sources: Option>, - media_streams: Option>, - path: Option, -} - -fn item_object(node: &Node, userdata: &NodeUserData) -> JellyfinItem { - let media_source = node.media.as_ref().map(|m| media_source_object(node, m)); - - JellyfinItem { - name: node.title.clone().unwrap_or_default(), - server_id: SERVER_ID.to_owned(), - id: node.slug.clone(), - e_tag: "blob".to_owned(), - date_created: "0001-01-01T00:00:00.0000000Z".to_owned(), - can_delete: false, - can_download: true, - preferred_metadata_language: "".to_owned(), - preferred_metadata_country_code: "".to_owned(), - sort_name: node.slug.clone(), - forced_sort_name: "".to_owned(), - external_urls: vec![], - enable_media_source_display: true, - custom_rating: "".to_owned(), - channel_id: None, - overview: node.description.clone().unwrap_or_default(), - taglines: vec![node.tagline.clone().unwrap_or_default()], - genres: vec![], - remote_trailers: vec![], - provider_ids: BTreeMap::new(), - is_folder: !node.media.is_some(), - parent_id: "todo-parent".to_owned(), // TODO - r#type: match node.kind { - NodeKind::Movie | NodeKind::Video | NodeKind::ShortFormVideo => JellyfinItemType::Movie, - NodeKind::Collection | _ => JellyfinItemType::CollectionFolder, - }, - people: vec![], - studios: vec![], - genre_items: vec![], - local_trailer_count: 0, - special_feature_count: 0, - child_count: 0, - locked_fields: vec![], - lock_data: false, - tags: vec![], - user_data: json!({ - "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" - }), - display_preferences_id: node.slug.clone(), - primary_image_aspect_ratio: match aspect_class(node.kind) { - "aspect-thumb" => 16. / 9., - "aspect-land" => 2f64.sqrt(), - "aspect-port" => 1. / 2f64.sqrt(), - "aspect-square" | _ => 1., - }, - collection_type: "unknown".to_owned(), - image_tags: BTreeMap::from_iter([("Primary".to_string(), "the-image".to_string())]), - backdrop_image_tags: vec![], - media_type: if node.media.is_some() { - "Video".to_owned() - } else { - "Unknown".to_owned() - }, - video_type: node.media.as_ref().map(|_| "VideoFile".to_owned()), - location_type: node.media.as_ref().map(|_| "FileSystem".to_owned()), - play_access: node.media.as_ref().map(|_| "Full".to_owned()), - container: node.media.as_ref().map(|_| "mkv".to_owned()), - run_time_ticks: if let Some(m) = &node.media { - Some((m.duration * 10_000_000.) as i64) - } else { - None - }, - media_sources: media_source.as_ref().map(|s| vec![s.clone()]), - media_streams: 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, - "ServerId": SERVER_ID, - "Id": USER_ID, - "HasPassword": true, - "HasConfiguredPassword": true, - "HasConfiguredEasyPassword": false, - "EnableAutoLogin": false, - "LastLoginDate": "0001-01-01T00:00:00.0000000Z", - "LastActivityDate": "0001-01-01T00:00:00.0000000Z", - "Configuration": { - "PlayDefaultAudioTrack": true, - "SubtitleLanguagePreference": "", - "DisplayMissingEpisodes": false, - "GroupedFolders": [], - "SubtitleMode": "Default", - "DisplayCollectionsView": false, - "EnableLocalPassword": false, - "OrderedViews": [], - "LatestItemsExcludes": [], - "MyMediaExcludes": [], - "HidePlayedInLatest": true, - "RememberAudioSelections": true, - "RememberSubtitleSelections": true, - "EnableNextEpisodeAutoPlay": true, - "CastReceiverId": "F007D354" - }, - "Policy": { - "IsAdministrator": false, - "IsHidden": true, - "EnableCollectionManagement": false, - "EnableSubtitleManagement": false, - "EnableLyricManagement": false, - "IsDisabled": false, - "BlockedTags": [], - "AllowedTags": [], - "EnableUserPreferenceAccess": true, - "AccessSchedules": [], - "BlockUnratedItems": [], - "EnableRemoteControlOfOtherUsers": false, - "EnableSharedDeviceControl": false, - "EnableRemoteAccess": true, - "EnableLiveTvManagement": false, - "EnableLiveTvAccess": true, - "EnableMediaPlayback": true, - "EnableAudioPlaybackTranscoding": true, - "EnableVideoPlaybackTranscoding": true, - "EnablePlaybackRemuxing": true, - "ForceRemoteSourceTranscoding": false, - "EnableContentDeletion": false, - "EnableContentDeletionFromFolders": [], - "EnableContentDownloading": true, - "EnableSyncTranscoding": true, - "EnableMediaConversion": true, - "EnabledDevices": [], - "EnableAllDevices": true, - "EnabledChannels": [], - "EnableAllChannels": false, - "EnabledFolders": [], - "EnableAllFolders": true, - "InvalidLoginAttemptCount": 0, - "LoginAttemptsBeforeLockout": -1, - "MaxActiveSessions": 0, - "EnablePublicSharing": true, - "BlockedMediaFolders": [], - "BlockedChannels": [], - "RemoteClientBitrateLimit": 0, - "AuthenticationProviderId": "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", - "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", - "SyncPlayAccess": "CreateAndJoinGroups" - } - }) -} diff --git a/server/src/routes/compat/jellyfin/mod.rs b/server/src/routes/compat/jellyfin/mod.rs new file mode 100644 index 0000000..0fed578 --- /dev/null +++ b/server/src/routes/compat/jellyfin/mod.rs @@ -0,0 +1,805 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin +*/ +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_poster}, + error::MyResult, + node::{aspect_class, DatabaseNodeUserDataExt}, + }, +}; +use anyhow::{anyhow, Context}; +use jellybase::{database::Database, CONF}; +use jellycommon::{ + stream::{StreamFormat, StreamSpec}, + user::{NodeUserData, WatchedState}, + MediaInfo, Node, NodeID, NodeKind, SourceTrack, SourceTrackKind, Visibility, +}; +use models::*; +use rocket::{get, post, response::Redirect, serde::json::Json, FromForm, State}; +use serde::Deserialize; +use serde_json::{json, Value}; +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 { + r_jellyfin_system_info_public() +} + +#[get("/system/info/public")] +pub fn r_jellyfin_system_info_public() -> Json { + Json(json!({ + "LocalAddress": LOCAL_ADDRESS, + "ServerName": CONF.brand.clone(), + "Version": VERSION, + "ProductName": "Jellything", + "OperatingSystem": "", + "Id": SERVER_ID, + "StartupWizardCompleted": true, + })) +} + +#[get("/Branding/Configuration")] +pub fn r_jellyfin_branding_configuration() -> Json { + Json(json!({ + "LoginDisclaimer": format!("{} - {}", CONF.brand, CONF.slogan), + "CustomCss": "", + "SplashscreenEnabled": false, + })) +} + +#[get("/users/public")] +pub fn r_jellyfin_users_public() -> Json { + Json(json!([])) +} + +#[get("/QuickConnect/Enabled")] +pub fn r_jellyfin_quickconnect_enabled(_session: Session) -> Json { + Json(json!(false)) +} + +#[get("/System/Endpoint")] +pub fn r_jellyfin_system_endpoint(_session: Session) -> Json { + Json(json!({ + "IsLocal": false, + "IsInNetwork": false, + })) +} + +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 { + Json(json!({ + "OperatingSystemDisplayName": "", + "HasPendingRestart": false, + "IsShuttingDown": false, + "SupportsLibraryMonitor": true, + "WebSocketPortNumber": 8096, + "CompletedInstallations": [], + "CanSelfRestart": true, + "CanLaunchWebBrowser": false, + "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": LOCAL_ADDRESS, + "ServerName": CONF.brand, + "Version": VERSION, + "OperatingSystem": "", + "Id": SERVER_ID + })) +} + +#[get("/DisplayPreferences/usersettings")] +pub fn r_jellyfin_displaypreferences_usersettings(_session: Session) -> Json { + Json(json!({ + "Id": "3ce5b65d-e116-d731-65d1-efc4a30ec35c", + "SortBy": "SortName", + "RememberIndexing": false, + "PrimaryImageHeight": 250, + "PrimaryImageWidth": 250, + "CustomPrefs": false, + "ScrollDirection": "Horizontal", + "ShowBackdrop": true, + "RememberSorting": false, + "SortOrder": "Ascending", + "ShowSidebar": false, + "Client": "emby", + })) +} + +#[post("/DisplayPreferences/usersettings")] +pub fn r_jellyfin_displaypreferences_usersettings_post(_session: Session) {} + +#[get("/Users/")] +pub fn r_jellyfin_users_id(session: Session, id: &str) -> Json { + let _ = id; + Json(user_object(session.user.name)) +} + +#[get("/Items//Images/Primary?&")] +#[allow(non_snake_case)] +pub fn r_jellyfin_items_image_primary( + _session: Session, + id: &str, + fillWidth: Option, + tag: Option, +) -> Redirect { + if let Some(tag) = tag { + Redirect::permanent(rocket::uri!(r_asset(tag, fillWidth))) + } else { + Redirect::permanent(rocket::uri!(r_item_poster(id, fillWidth))) + } +} + +#[get("/Items/")] +#[allow(private_interfaces)] +pub fn r_jellyfin_items_item( + session: Session, + database: &State, + id: &str, +) -> MyResult> { + let (n, ud) = database.get_node_with_userdata(NodeID::from_slug(id), &session)?; + Ok(Json(item_object(&n, &ud))) +} +#[get("/Users//Items/")] +#[allow(private_interfaces)] +pub fn r_jellyfin_users_items_item( + session: Session, + database: &State, + uid: &str, + id: &str, +) -> MyResult> { + let _ = uid; + r_jellyfin_items_item(session, database, id) +} + +#[derive(Debug, FromForm)] +struct JellyfinItemQuery { + #[field(name = uncased("searchterm"))] + search_term: Option, + #[field(name = uncased("limit"))] + limit: usize, + #[field(name = uncased("parentid"))] + parent_id: Option, + #[field(name = uncased("startindex"))] + start_index: Option, + #[field(name = uncased("includeitemtypes"))] + include_item_types: Option, + + internal_artists: bool, + internal_persons: bool, +} + +#[get("/Users//Items?")] +#[allow(private_interfaces)] +pub fn r_jellyfin_users_items( + session: Session, + database: &State, + uid: &str, + query: JellyfinItemQuery, +) -> MyResult> { + let _ = uid; + r_jellyfin_items(session, database, query) +} + +#[get("/Artists?")] +#[allow(private_interfaces)] +pub fn r_jellyfin_artists( + session: Session, + database: &State, + mut query: JellyfinItemQuery, +) -> MyResult> { + query.internal_artists = true; + r_jellyfin_items(session, database, query)?; // TODO + Ok(Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + }))) +} + +#[get("/Persons?")] +#[allow(private_interfaces)] +pub fn r_jellyfin_persons( + session: Session, + database: &State, + mut query: JellyfinItemQuery, +) -> MyResult> { + query.internal_persons = true; + r_jellyfin_items(session, database, query)?; // TODO + Ok(Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + }))) +} + +#[get("/Items?")] +#[allow(private_interfaces)] +pub fn r_jellyfin_items( + session: Session, + database: &State, + query: JellyfinItemQuery, +) -> MyResult> { + let mut items = Vec::new(); + + let nodes = if let Some(q) = query.search_term { + database + .search(&q, query.limit, query.start_index.unwrap_or_default())? + .1 + } else if let Some(parent) = query.parent_id { + database + .get_node_children(NodeID::from_slug(&parent))? + .into_iter() + .skip(query.start_index.unwrap_or_default()) + .take(query.limit) + .collect() + } else { + vec![] + }; + + let filter_kind = query + .include_item_types + .map(|n| match n.as_str() { + "Movie" => NodeKind::Movie, + "Audio" => NodeKind::Music, + "Video" => NodeKind::Video, + _ => NodeKind::Unknown, + }) + .or(if query.internal_artists { + Some(NodeKind::Channel) + } else { + None + }) + .or(if query.internal_persons { + Some(NodeKind::Channel) + } else { + None + }); + for nid in nodes { + let (n, ud) = database.get_node_with_userdata(nid, &session)?; + if let Some(fk) = filter_kind { + if n.kind != fk { + continue; + } + } + if n.visibility >= Visibility::Reduced { + items.push(item_object(&n, &ud)) + } + } + + Ok(Json(json!({ + "Items": items, + "TotalRecordCount": items.len(), + "StartIndex": query.start_index.unwrap_or_default() + }))) +} + +#[get("/UserViews?")] +#[allow(non_snake_case)] +pub fn r_jellyfin_users_views( + session: Session, + database: &State, + userId: &str, +) -> MyResult> { + let _ = userId; + + let toplevel = database + .get_node_children(NodeID::from_slug("library")) + .context("root node missing")?; + + let mut items = Vec::new(); + for nid in toplevel { + let (n, ud) = database.get_node_with_userdata(nid, &session)?; + if n.visibility >= Visibility::Reduced { + items.push(item_object(&n, &ud)) + } + } + + Ok(Json(json!({ + "Items": items, + "TotalRecordCount": items.len(), + "StartIndex": 0 + }))) +} + +#[get("/Items//Similar")] +pub fn r_jellyfin_items_similar(_session: Session, id: &str) -> Json { + let _ = id; + Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + })) +} + +#[get("/LiveTv/Programs/Recommended")] +pub fn r_jellyfin_livetv_programs_recommended(_session: Session) -> Json { + Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + })) +} + +#[get("/Users//Items//Intros")] +pub fn r_jellyfin_items_intros(_session: Session, uid: &str, id: &str) -> Json { + let _ = (uid, id); + Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + })) +} + +#[post("/Items//PlaybackInfo")] +pub fn r_jellyfin_items_playbackinfo( + _session: Session, + database: &State, + id: &str, +) -> MyResult> { + 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//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 = "")] +#[allow(private_interfaces)] +pub fn r_jellyfin_sessions_playing_progress( + session: Session, + database: &State, + data: Json, +) -> 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?")] +#[allow(non_snake_case)] +pub fn r_jellyfin_playback_bitratetest(_session: Session, Size: usize) -> Vec { + vec![0; Size.min(1_000_000)] +} + +#[get("/Branding/Css")] +pub fn r_jellyfin_branding_css(_session: Session) -> String { + "".to_string() +} + +#[post("/Sessions/Capabilities/Full")] +pub fn r_jellyfin_sessions_capabilities_full(_session: Session) -> () { + () +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct AuthData { + pw: String, + username: String, +} + +#[post("/Users/authenticatebyname", data = "")] +#[allow(private_interfaces)] +pub fn r_jellyfin_users_authenticatebyname( + client_addr: IpAddr, + database: &State, + data: Json, +) -> MyResult> { + let token = login_logic(database, &data.username, &data.pw, None, None)?; + + Ok(Json(json!({ + "User": user_object(data.username.clone()), + "SessionInfo": { + "PlayState": { + "CanSeek": false, + "IsPaused": false, + "IsMuted": false, + "RepeatMode": "RepeatNone", + "PlaybackOrder": "Default" + }, + "AdditionalUsers": [], + "Capabilities": { + "PlayableMediaTypes": [ + "Audio", + "Video" + ], + "SupportedCommands": [], + // "MoveUp", "MoveDown", "MoveLeft", "MoveRight", "PageUp", "PageDown", "PreviousLetter", "NextLetter", "ToggleOsd", "ToggleContextMenu", "Select", "Back", "SendKey", "SendString", "GoHome", "GoToSettings", "VolumeUp", "VolumeDown", "Mute", "Unmute", "ToggleMute", "SetVolume", "SetAudioStreamIndex", "SetSubtitleStreamIndex", "DisplayContent", "GoToSearch", "DisplayMessage", "SetRepeatMode", "SetShuffleQueue", "ChannelUp", "ChannelDown", "PlayMediaSource", "PlayTrailers" + "SupportsMediaControl": true, + "SupportsPersistentIdentifier": false + }, + "RemoteEndPoint": client_addr, + "PlayableMediaTypes": [ + "Audio", + "Video" + ], + "Id": "6e05fbb4fe33477b991455c97a57e25d", + "UserId": USER_ID, + "UserName": data.username.clone(), + "Client": "Jellyfin Web", + "LastActivityDate": "0001-01-01T00:00:00.0000000Z", + "LastPlaybackCheckIn": "0001-01-01T00:00:00.0000000Z", + "DeviceName": "blub blub blub", + "DeviceId": "wagening", + "ApplicationVersion": VERSION, + "IsActive": true, + "SupportsMediaControl": false, + "SupportsRemoteControl": false, + "NowPlayingQueue": [], + "NowPlayingQueueFullItems": [], + "HasCustomDeviceName": false, + "ServerId": SERVER_ID, + "SupportedCommands": [] + // "MoveUp", "MoveDown", "MoveLeft", "MoveRight", "PageUp", "PageDown", "PreviousLetter", "NextLetter", "ToggleOsd", "ToggleContextMenu", "Select", "Back", "SendKey", "SendString", "GoHome", "GoToSettings", "VolumeUp", "VolumeDown", "Mute", "Unmute", "ToggleMute", "SetVolume", "SetAudioStreamIndex", "SetSubtitleStreamIndex", "DisplayContent", "GoToSearch", "DisplayMessage", "SetRepeatMode", "SetShuffleQueue", "ChannelUp", "ChannelDown", "PlayMediaSource", "PlayTrailers" + }, + "AccessToken": token, + "ServerId": SERVER_ID + }))) +} + +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::>(), + 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 item_object(node: &Node, userdata: &NodeUserData) -> JellyfinItem { + let media_source = node.media.as_ref().map(|m| media_source_object(node, m)); + + JellyfinItem { + name: node.title.clone().unwrap_or_default(), + server_id: SERVER_ID.to_owned(), + id: node.slug.clone(), + e_tag: "blob".to_owned(), + date_created: "0001-01-01T00:00:00.0000000Z".to_owned(), + can_delete: false, + can_download: true, + preferred_metadata_language: "".to_owned(), + preferred_metadata_country_code: "".to_owned(), + sort_name: node.slug.clone(), + forced_sort_name: "".to_owned(), + external_urls: vec![], + enable_media_source_display: true, + custom_rating: "".to_owned(), + channel_id: None, + overview: node.description.clone().unwrap_or_default(), + taglines: vec![node.tagline.clone().unwrap_or_default()], + genres: vec![], + remote_trailers: vec![], + provider_ids: BTreeMap::new(), + is_folder: !node.media.is_some(), + parent_id: "todo-parent".to_owned(), // TODO + r#type: match node.kind { + NodeKind::Movie | NodeKind::Video | NodeKind::ShortFormVideo => JellyfinItemType::Movie, + NodeKind::Collection | _ => JellyfinItemType::CollectionFolder, + }, + people: node + .people + .iter() + .flat_map(|(_pg, ps)| { + ps.iter().map(|p| JellyfinPerson { + name: p.person.name.clone(), + id: p.person.ids.tmdb.unwrap_or_default().to_string(), + primary_image_tag: p.person.headshot.clone().map(|a| a.0).unwrap_or_default(), + role: p.characters.join(","), + r#type: JellyfinPersonType::Actor, + }) + }) + .collect(), + studios: vec![], + genre_items: vec![], + local_trailer_count: 0, + special_feature_count: 0, + child_count: 0, + locked_fields: vec![], + lock_data: false, + tags: vec![], + user_data: json!({ + "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" + }), + display_preferences_id: node.slug.clone(), + primary_image_aspect_ratio: match aspect_class(node.kind) { + "aspect-thumb" => 16. / 9., + "aspect-land" => 2f64.sqrt(), + "aspect-port" => 1. / 2f64.sqrt(), + "aspect-square" | _ => 1., + }, + collection_type: "unknown".to_owned(), + image_tags: BTreeMap::from_iter([("Primary".to_string(), "the-image".to_string())]), + backdrop_image_tags: vec![], + media_type: if node.media.is_some() { + "Video".to_owned() + } else { + "Unknown".to_owned() + }, + video_type: node.media.as_ref().map(|_| "VideoFile".to_owned()), + location_type: node.media.as_ref().map(|_| "FileSystem".to_owned()), + play_access: node.media.as_ref().map(|_| "Full".to_owned()), + container: node.media.as_ref().map(|_| "mkv".to_owned()), + run_time_ticks: if let Some(m) = &node.media { + Some((m.duration * 10_000_000.) as i64) + } else { + None + }, + media_sources: media_source.as_ref().map(|s| vec![s.clone()]), + media_streams: 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, + "ServerId": SERVER_ID, + "Id": USER_ID, + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastLoginDate": "0001-01-01T00:00:00.0000000Z", + "LastActivityDate": "0001-01-01T00:00:00.0000000Z", + "Configuration": { + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "", + "DisplayMissingEpisodes": false, + "GroupedFolders": [], + "SubtitleMode": "Default", + "DisplayCollectionsView": false, + "EnableLocalPassword": false, + "OrderedViews": [], + "LatestItemsExcludes": [], + "MyMediaExcludes": [], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true, + "CastReceiverId": "F007D354" + }, + "Policy": { + "IsAdministrator": false, + "IsHidden": true, + "EnableCollectionManagement": false, + "EnableSubtitleManagement": false, + "EnableLyricManagement": false, + "IsDisabled": false, + "BlockedTags": [], + "AllowedTags": [], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [], + "BlockUnratedItems": [], + "EnableRemoteControlOfOtherUsers": false, + "EnableSharedDeviceControl": false, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": false, + "EnableLiveTvAccess": true, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": false, + "EnableContentDeletion": false, + "EnableContentDeletionFromFolders": [], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": [], + "EnableAllDevices": true, + "EnabledChannels": [], + "EnableAllChannels": false, + "EnabledFolders": [], + "EnableAllFolders": true, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": -1, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": [], + "BlockedChannels": [], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", + "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", + "SyncPlayAccess": "CreateAndJoinGroups" + } + }) +} diff --git a/server/src/routes/compat/jellyfin/models.rs b/server/src/routes/compat/jellyfin/models.rs new file mode 100644 index 0000000..eeffa34 --- /dev/null +++ b/server/src/routes/compat/jellyfin/models.rs @@ -0,0 +1,199 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin +*/ + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +#[derive(Debug, Serialize, Deserialize)] +pub(super) enum JellyfinItemType { + AudioBook, + Movie, + BoxSet, + Book, + Photo, + PhotoAlbum, + TvChannel, + LiveTvProgram, + Video, + Audio, + MusicAlbum, + CollectionFolder, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) enum JellyfinMediaStreamType { + Video, + Audio, + Subtitle, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct JellyfinMediaStream { + pub codec: String, + pub time_base: String, + pub video_range: String, + pub video_range_type: String, + pub audio_spatial_format: String, + pub display_title: String, + pub is_interlaced: bool, + pub is_avc: bool, + pub bit_rate: usize, + pub bit_depth: usize, + pub ref_frames: usize, + pub is_default: bool, + pub is_forced: bool, + pub is_hearing_impaired: bool, + pub height: Option, + pub width: Option, + pub average_frame_rate: Option, + pub real_frame_rate: Option, + pub reference_frame_rate: Option, + pub profile: String, + pub r#type: JellyfinMediaStreamType, + pub aspect_ratio: String, + pub index: usize, + pub is_external: bool, + pub is_text_subtitle_stream: bool, + pub supports_external_stream: bool, + pub pixel_format: String, + pub level: usize, + pub is_anamorphic: bool, + pub channel_layout: Option, + pub channels: Option, + pub sample_rate: Option, + pub localized_default: String, + pub localized_external: String, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) enum JellyfinMediaSourceProtocol { + File, +} +#[derive(Debug, Clone, Serialize)] +pub(super) enum JellyfinMediaSourceType { + Default, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) enum JellyfinVideoType { + VideoFile, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct JellyfinMediaSource { + pub protocol: JellyfinMediaSourceProtocol, + pub id: String, + pub path: String, + pub r#type: JellyfinMediaSourceType, + pub container: String, + pub size: usize, + pub name: String, + pub is_remote: bool, + pub e_tag: String, + pub run_time_ticks: f64, + pub read_at_native_framerate: bool, + pub ignore_dts: bool, + pub ignore_index: bool, + pub gen_pts_input: bool, + pub supports_transcoding: bool, + pub supports_direct_stream: bool, + pub supports_direct_play: bool, + pub is_infinite_stream: bool, + pub use_most_compatible_transcoding_profile: bool, + pub requires_opening: bool, + pub requires_closing: bool, + pub requires_looping: bool, + pub supports_probing: bool, + pub video_type: JellyfinVideoType, + pub media_streams: Vec, + pub media_attachments: Vec<()>, + pub formats: Vec<()>, + pub bitrate: usize, + pub required_http_headers: BTreeMap<(), ()>, + pub transcoding_sub_protocol: String, + pub default_audio_stream_index: usize, + pub default_subtitle_stream_index: usize, + pub has_segments: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct JellyfinItem { + pub name: String, + pub server_id: String, + pub id: String, + pub e_tag: String, + pub date_created: String, + pub can_delete: bool, + pub can_download: bool, + pub preferred_metadata_language: String, + pub preferred_metadata_country_code: String, + pub sort_name: String, + pub forced_sort_name: String, + pub external_urls: Vec<()>, + pub enable_media_source_display: bool, + pub custom_rating: String, + pub channel_id: Option, + pub overview: String, + pub taglines: Vec, + pub genres: Vec<()>, + pub play_access: Option, + pub remote_trailers: Vec<()>, + pub provider_ids: BTreeMap<(), ()>, + pub is_folder: bool, + pub parent_id: String, + pub r#type: JellyfinItemType, + pub people: Vec, + pub studios: Vec, + pub genre_items: Vec<()>, + pub local_trailer_count: usize, + pub special_feature_count: usize, + pub child_count: usize, + pub locked_fields: Vec<()>, + pub lock_data: bool, + pub tags: Vec, + pub user_data: Value, + pub display_preferences_id: String, + pub primary_image_aspect_ratio: f64, + pub collection_type: String, + pub image_tags: BTreeMap, + pub backdrop_image_tags: Vec<()>, + pub location_type: Option, + pub media_type: String, + pub video_type: Option, + pub container: Option, + pub run_time_ticks: Option, + pub media_sources: Option>, + pub media_streams: Option>, + pub path: Option, +} + +#[derive(Debug, Serialize)] +pub(super) enum JellyfinPersonType { + Actor, + // Writer, + // Producer, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct JellyfinPerson { + pub name: String, + pub id: String, + pub role: String, + pub r#type: JellyfinPersonType, + pub primary_image_tag: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct JellyfinStudio { + pub name: String, + pub id: String, +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 373146b..98ea4b0 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -10,8 +10,10 @@ use base64::Engine; use compat::{ jellyfin::{ r_jellyfin_artists, r_jellyfin_branding_configuration, r_jellyfin_branding_css, - r_jellyfin_displaypreferences_usersettings, r_jellyfin_items, - r_jellyfin_items_image_primary, r_jellyfin_items_intros, r_jellyfin_items_playbackinfo, + r_jellyfin_displaypreferences_usersettings, + r_jellyfin_displaypreferences_usersettings_post, r_jellyfin_items, + r_jellyfin_items_image_primary, r_jellyfin_items_intros, r_jellyfin_items_item, + r_jellyfin_items_playbackinfo, r_jellyfin_items_similar, r_jellyfin_livetv_programs_recommended, r_jellyfin_persons, r_jellyfin_playback_bitratetest, r_jellyfin_quickconnect_enabled, r_jellyfin_sessions_capabilities_full, r_jellyfin_sessions_playing, @@ -112,82 +114,88 @@ pub fn build_rocket(database: Database, federation: Federation) -> Rocket .mount( "/", routes![ - r_home, - r_home_unpriv, - r_streamsync, - r_favicon, - r_asset, - r_item_backdrop, - r_item_poster, - r_person_asset, - r_search, - r_all_items_filter, - r_library_node_filter, - r_assets_style, - r_assets_font, - r_assets_js, - r_assets_js_map, - r_stream, - r_node_userdata, - r_node_thumbnail, - r_player, - r_node_userdata_progress, - r_node_userdata_watched, - r_node_userdata_rating, - r_account_login, + // Frontend r_account_login_post, - r_account_register, - r_account_register_post, - r_account_logout, + r_account_login, r_account_logout_post, + r_account_logout, + r_account_register_post, + r_account_register, + r_account_settings_post, + r_account_settings, r_admin_dashboard, + r_admin_delete_cache, + r_admin_import, r_admin_invite, - r_admin_remove_user, - r_admin_user, - r_admin_users, + r_admin_log_stream, + r_admin_log, r_admin_remove_invite, - r_admin_user_permission, - r_admin_delete_cache, + r_admin_remove_user, r_admin_transcode_posters, - r_admin_log, - r_admin_log_stream, - r_admin_import, r_admin_update_search, - r_account_settings, - r_account_settings_post, - r_api_version, + r_admin_user_permission, + r_admin_user, + r_admin_users, + r_all_items_filter, + r_asset, + r_assets_font, + r_assets_js_map, + r_assets_js, + r_assets_style, + r_favicon, + r_home_unpriv, + r_home, + r_item_backdrop, + r_item_poster, + r_library_node_filter, + r_node_thumbnail, + r_node_userdata_progress, + r_node_userdata_rating, + r_node_userdata_watched, + r_node_userdata, + r_person_asset, + r_player, + r_search, + r_stream, + r_streamsync, + // API r_api_account_login, - r_api_root, r_api_asset_token_raw, - r_youtube_watch, - r_youtube_channel, - r_jellyfin_system_info_public, - r_jellyfin_system_info_public_case, - r_jellyfin_quickconnect_enabled, - r_jellyfin_users_public, + r_api_root, + r_api_version, + // Compat + r_jellyfin_artists, r_jellyfin_branding_configuration, - r_jellyfin_users_authenticatebyname, - r_jellyfin_sessions_capabilities_full, - r_jellyfin_system_endpoint, r_jellyfin_branding_css, + r_jellyfin_displaypreferences_usersettings_post, r_jellyfin_displaypreferences_usersettings, - r_jellyfin_system_info, - r_jellyfin_users_id, - r_jellyfin_playback_bitratetest, - r_jellyfin_users_views, r_jellyfin_items_image_primary, - r_jellyfin_livetv_programs_recommended, - r_jellyfin_users_items, - r_jellyfin_users_items_item, - r_jellyfin_items, - r_jellyfin_persons, - r_jellyfin_artists, r_jellyfin_items_intros, + r_jellyfin_items_item, r_jellyfin_items_playbackinfo, - r_jellyfin_video_stream, - r_jellyfin_sessions_playing, + r_jellyfin_items_similar, + r_jellyfin_items, + r_jellyfin_livetv_programs_recommended, + r_jellyfin_persons, + r_jellyfin_playback_bitratetest, + r_jellyfin_quickconnect_enabled, + r_jellyfin_sessions_capabilities_full, r_jellyfin_sessions_playing_progress, + r_jellyfin_sessions_playing, r_jellyfin_socket, + r_jellyfin_system_endpoint, + r_jellyfin_system_info_public_case, + r_jellyfin_system_info_public, + r_jellyfin_system_info, + r_jellyfin_users_authenticatebyname, + r_jellyfin_users_id, + r_jellyfin_users_items_item, + r_jellyfin_users_items, + r_jellyfin_users_public, + r_jellyfin_users_views, + r_jellyfin_video_stream, + r_youtube_channel, + r_youtube_watch, ], ) } -- cgit v1.2.3-70-g09d2