aboutsummaryrefslogtreecommitdiff
path: root/server/src/compat/jellyfin/mod.rs
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/compat/jellyfin/mod.rs')
-rw-r--r--server/src/compat/jellyfin/mod.rs881
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"
+ }
+ })
+}