diff options
Diffstat (limited to 'server/src/compat/jellyfin/mod.rs')
-rw-r--r-- | server/src/compat/jellyfin/mod.rs | 881 |
1 files changed, 881 insertions, 0 deletions
diff --git a/server/src/compat/jellyfin/mod.rs b/server/src/compat/jellyfin/mod.rs new file mode 100644 index 0000000..9d5c93e --- /dev/null +++ b/server/src/compat/jellyfin/mod.rs @@ -0,0 +1,881 @@ +/* + 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 <metamuffin.org> +*/ +pub mod models; + +use crate::{ + logic::session::Session, + ui::{ + account::login_logic, + assets::{ + rocket_uri_macro_r_asset, rocket_uri_macro_r_item_backdrop, + rocket_uri_macro_r_item_poster, + }, + error::MyResult, + node::{aspect_class, DatabaseNodeUserDataExt}, + sort::{filter_and_sort_nodes, FilterProperty, NodeFilterSort, SortOrder, SortProperty}, + }, +}; +use anyhow::{anyhow, Context}; +use jellybase::{database::Database, CONF}; +use jellycommon::{ + stream::{StreamContainer, StreamSpec}, + user::{NodeUserData, WatchedState}, + MediaInfo, Node, NodeID, NodeKind, SourceTrack, SourceTrackKind, Visibility, +}; +use models::*; +use rocket::{ + get, + http::{Cookie, CookieJar}, + 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: &str = "1694a95daf70708147f16103ce7b7566"; +const USER_ID: &str = "33f772aae6c2495ca89fe00340dbd17c"; +const VERSION: &str = "10.10.0"; +const LOCAL_ADDRESS: &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": 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<Value> { + Json(json!({ + "LoginDisclaimer": format!("{} - {}", CONF.brand, CONF.slogan), + "CustomCss": "", + "SplashscreenEnabled": false, + })) +} + +#[get("/users/public")] +pub fn r_jellyfin_users_public() -> Json<Value> { + Json(json!([])) +} + +#[get("/Branding/Css")] +pub fn r_jellyfin_branding_css() -> String { + "".to_string() +} + +#[get("/QuickConnect/Enabled")] +pub fn r_jellyfin_quickconnect_enabled() -> Json<Value> { + Json(json!(false)) +} + +#[get("/System/Endpoint")] +pub fn r_jellyfin_system_endpoint(_session: Session) -> Json<Value> { + 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<Value> { + 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<Value> { + 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/<id>")] +pub fn r_jellyfin_users_id(session: Session, id: &str) -> Json<Value> { + let _ = id; + Json(user_object(session.user.name)) +} + +#[get("/Items/<id>/Images/Primary?<fillWidth>&<tag>")] +#[allow(non_snake_case)] +pub fn r_jellyfin_items_image_primary( + _session: Session, + id: &str, + fillWidth: Option<usize>, + tag: String, +) -> Redirect { + if tag == "poster" { + Redirect::permanent(rocket::uri!(r_item_poster(id, fillWidth))) + } else { + Redirect::permanent(rocket::uri!(r_asset(tag, fillWidth))) + } +} + +#[get("/Items/<id>/Images/Backdrop/0?<maxWidth>")] +#[allow(non_snake_case)] +pub fn r_jellyfin_items_images_backdrop( + _session: Session, + id: &str, + maxWidth: Option<usize>, +) -> Redirect { + Redirect::permanent(rocket::uri!(r_item_backdrop(id, maxWidth))) +} + +#[get("/Items/<id>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_items_item( + session: Session, + database: &State<Database>, + id: &str, +) -> MyResult<Json<JellyfinItem>> { + let (n, ud) = database.get_node_with_userdata(NodeID::from_slug(id), &session)?; + Ok(Json(item_object(&n, &ud))) +} +#[get("/Users/<uid>/Items/<id>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_users_items_item( + session: Session, + database: &State<Database>, + uid: &str, + id: &str, +) -> MyResult<Json<JellyfinItem>> { + let _ = uid; + r_jellyfin_items_item(session, database, id) +} + +#[derive(Debug, FromForm)] +struct JellyfinItemQuery { + #[field(name = uncased("searchterm"))] + search_term: Option<String>, + #[field(name = uncased("limit"))] + limit: usize, + #[field(name = uncased("parentid"))] + parent_id: Option<String>, + #[field(name = uncased("startindex"))] + start_index: Option<usize>, + #[field(name = uncased("includeitemtypes"))] + include_item_types: Option<String>, + + internal_artists: bool, + internal_persons: bool, +} + +#[get("/Users/<uid>/Items?<query..>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_users_items( + session: Session, + database: &State<Database>, + uid: &str, + query: JellyfinItemQuery, +) -> MyResult<Json<Value>> { + let _ = uid; + r_jellyfin_items(session, database, query) +} + +#[get("/Artists?<query..>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_artists( + session: Session, + database: &State<Database>, + mut query: JellyfinItemQuery, +) -> MyResult<Json<Value>> { + query.internal_artists = true; + r_jellyfin_items(session, database, query)?; // TODO + Ok(Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + }))) +} + +#[get("/Persons?<query..>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_persons( + session: Session, + database: &State<Database>, + mut query: JellyfinItemQuery, +) -> MyResult<Json<Value>> { + query.internal_persons = true; + r_jellyfin_items(session, database, query)?; // TODO + Ok(Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + }))) +} + +#[get("/Items?<query..>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_items( + session: Session, + database: &State<Database>, + query: JellyfinItemQuery, +) -> MyResult<Json<Value>> { + let (nodes, parent_kind) = if let Some(q) = query.search_term { + ( + database + .search(&q, query.limit, query.start_index.unwrap_or_default())? + .1, + None, + ) + } else if let Some(parent) = query.parent_id { + let parent = NodeID::from_slug(&parent); + ( + database + .get_node_children(parent)? + .into_iter() + .skip(query.start_index.unwrap_or_default()) + .take(query.limit) + .collect(), + database.get_node(parent)?.map(|n| n.kind), + ) + } else { + (vec![], None) + }; + + let filter_kind = query + .include_item_types + .map(|n| match n.as_str() { + "Movie" => vec![FilterProperty::KindMovie], + "Audio" => vec![FilterProperty::KindMusic], + "Video" => vec![FilterProperty::KindVideo], + "TvChannel" => vec![FilterProperty::KindChannel], + _ => vec![], + }) + .or(if query.internal_artists { + Some(vec![]) + } else { + None + }) + .or(if query.internal_persons { + Some(vec![]) + } else { + None + }); + + let mut nodes = nodes + .into_iter() + .map(|nid| database.get_node_with_userdata(nid, &session)) + .collect::<Result<Vec<_>, anyhow::Error>>()?; + + filter_and_sort_nodes( + &NodeFilterSort { + sort_by: None, + filter_kind, + sort_order: None, + }, + match parent_kind { + Some(NodeKind::Channel) => (SortProperty::ReleaseDate, SortOrder::Descending), + _ => (SortProperty::Title, SortOrder::Ascending), + }, + &mut nodes, + ); + + let items = nodes + .into_iter() + .filter(|(n, _)| n.visibility >= Visibility::Reduced) + .map(|(n, ud)| item_object(&n, &ud)) + .collect::<Vec<_>>(); + + Ok(Json(json!({ + "Items": items, + "TotalRecordCount": items.len(), + "StartIndex": query.start_index.unwrap_or_default() + }))) +} + +#[get("/UserViews?<userId>")] +#[allow(non_snake_case)] +pub fn r_jellyfin_users_views( + session: Session, + database: &State<Database>, + userId: &str, +) -> MyResult<Json<Value>> { + let _ = userId; + + let mut toplevel = database + .get_node_children(NodeID::from_slug("library")) + .context("root node missing")? + .into_iter() + .map(|nid| database.get_node_with_userdata(nid, &session)) + .collect::<Result<Vec<_>, anyhow::Error>>()?; + + toplevel.sort_by_key(|(n, _)| n.index.unwrap_or(usize::MAX)); + + let mut items = Vec::new(); + for (n, ud) in toplevel { + if n.visibility >= Visibility::Reduced { + items.push(item_object(&n, &ud)) + } + } + + Ok(Json(json!({ + "Items": items, + "TotalRecordCount": items.len(), + "StartIndex": 0 + }))) +} + +#[get("/Items/<id>/Similar")] +pub fn r_jellyfin_items_similar(_session: Session, id: &str) -> Json<Value> { + let _ = id; + Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + })) +} + +#[get("/LiveTv/Programs/Recommended")] +pub fn r_jellyfin_livetv_programs_recommended(_session: Session) -> Json<Value> { + Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + })) +} + +#[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 + })) +} + +#[get("/Shows/NextUp")] +pub fn r_jellyfin_shows_nextup(_session: Session) -> Json<Value> { + 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.webm")] +pub fn r_jellyfin_video_stream( + _session: Session, + database: &State<Database>, + id: &str, +) -> MyResult<Redirect> { + 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 params = StreamSpec::Remux { + tracks: (0..media.tracks.len()).collect(), + container: StreamContainer::WebM, + } + .to_query(); + Ok(Redirect::temporary(format!("/n/{id}/stream{params}"))) +} + +#[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> { + vec![0; Size.min(1_000_000)] +} + +#[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 = "<data>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_users_authenticatebyname_case( + client_addr: IpAddr, + database: &State<Database>, + data: Json<AuthData>, + jar: &CookieJar, +) -> MyResult<Json<Value>> { + r_jellyfin_users_authenticatebyname(client_addr, database, data, jar) +} + +#[post("/Users/authenticatebyname", data = "<data>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_users_authenticatebyname( + client_addr: IpAddr, + database: &State<Database>, + data: Json<AuthData>, + jar: &CookieJar, +) -> MyResult<Json<Value>> { + let token = login_logic(database, &data.username, &data.pw, None, None)?; + + // setting the session cookie too because image requests carry no auth headers for some reason. + // TODO find alternative, non-web clients might not understand cookies + jar.add( + Cookie::build(("session", token.clone())) + .permanent() + .build(), + ); + + 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/{}.webm", node.slug), + r#type: JellyfinMediaSourceType::Default, + container: "webm".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 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_none(), + parent_id: "todo-parent".to_owned(), // TODO + r#type: match node.kind { + NodeKind::Movie | NodeKind::Video | NodeKind::ShortFormVideo => JellyfinItemType::Movie, + NodeKind::Collection => JellyfinItemType::CollectionFolder, + _ => 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., + _ => 1., + }, + collection_type: "unknown".to_owned(), + image_tags: BTreeMap::from_iter([("Primary".to_string(), "poster".to_string())]), + backdrop_image_tags: vec!["backdrop".to_string()], + 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(|_| "webm".to_owned()), + run_time_ticks: node + .media + .as_ref() + .map(|m| (m.duration * 10_000_000.) as i64), + 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/{}.webm", 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" + } + }) +} |