diff options
-rw-r--r-- | server/src/main.rs | 1 | ||||
-rw-r--r-- | server/src/routes/compat/jellyfin.rs | 393 | ||||
-rw-r--r-- | server/src/routes/compat/mod.rs | 7 | ||||
-rw-r--r-- | server/src/routes/compat/youtube.rs (renamed from server/src/routes/external_compat.rs) | 17 | ||||
-rw-r--r-- | server/src/routes/mod.rs | 36 | ||||
-rw-r--r-- | server/src/routes/ui/account/session/guard.rs | 2 | ||||
-rw-r--r-- | server/src/routes/ui/assets.rs | 12 |
7 files changed, 448 insertions, 20 deletions
diff --git a/server/src/main.rs b/server/src/main.rs index 407e127..ed3c56a 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -5,6 +5,7 @@ */ #![feature(int_roundings, let_chains)] #![allow(clippy::needless_borrows_for_generic_args)] +#![recursion_limit = "4096"] use crate::routes::ui::{account::hash_password, admin::log::enable_logging}; use anyhow::Context; diff --git a/server/src/routes/compat/jellyfin.rs b/server/src/routes/compat/jellyfin.rs new file mode 100644 index 0000000..18f0242 --- /dev/null +++ b/server/src/routes/compat/jellyfin.rs @@ -0,0 +1,393 @@ +/* + 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> +*/ +use crate::routes::ui::{ + account::{login_logic, session::Session}, + assets::rocket_uri_macro_r_item_poster, + error::MyResult, + node::{aspect_class, DatabaseNodeUserDataExt}, +}; +use anyhow::Context; +use jellybase::{database::Database, CONF}; +use jellycommon::{user::WatchedState, NodeID, NodeKind}; +use rocket::{get, post, response::Redirect, serde::json::Json, State}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::net::IpAddr; + +const SERVER_ID: &'static str = "1694a95daf70708147f16103ce7b7566"; +const USER_ID: &'static str = "33f772aae6c2495ca89fe00340dbd17c"; +const VERSION: &'static str = "10.10.0"; + +#[get("/System/Info/Public")] +pub fn r_jellyfin_system_info_public_case() -> Json<Value> { + r_jellyfin_system_info_public() +} +#[get("/system/info/public")] +pub fn r_jellyfin_system_info_public() -> Json<Value> { + Json(json!({ + "LocalAddress": "http://127.0.0.1:8000", + "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("/QuickConnect/Enabled")] +pub fn r_jellyfin_quickconnect_enabled(_session: Session) -> 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": "/config", + "WebPath": "/jellyfin/jellyfin-web", + "ItemsByNamePath": "/var/lib/jellyfin/metadata", + "CachePath": "/var/cache/jellyfin", + "LogPath": "/config/log", + "InternalMetadataPath": "/var/lib/jellyfin/metadata", + "TranscodingTempPath": "/var/lib/jellyfin/transcodes", + "CastReceiverApplications": [], + "HasUpdateAvailable": false, + "EncoderLocation": "System", + "SystemArchitecture": "X64", + "LocalAddress": "http://127.0.0.1:8000", + "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", + })) +} + +#[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>")] +#[allow(non_snake_case)] +pub fn r_jellyfin_items_image_primary( + _session: Session, + id: &str, + fillWidth: Option<usize>, +) -> Redirect { + Redirect::permanent(rocket::uri!(r_item_poster(id, fillWidth))) +} + +#[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 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)?; + + items.push(json!({ + "Name": n.title, + "ServerId": SERVER_ID, + "Id": n.slug.clone(), + "Etag": "blob", + "DateCreated": "0001-01-01T00:00:00.0000000Z", + "CanDelete": false, + "CanDownload": false, + "PreferredMetadataLanguage": "", + "PreferredMetadataCountryCode": "", + "SortName": n.slug.clone(), + "ForcedSortName": "", + "ExternalUrls": [], + "Path": "/why/does/it/matter", + "EnableMediaSourceDisplay": true, + "CustomRating": "", + "ChannelId": null, + "Overview": "", + "Taglines": [], + "Genres": [], + "PlayAccess": "Full", + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": true, + "ParentId": "todo-parent", // TODO + "Type": match n.kind { + NodeKind::Collection | _ => "CollectionFolder", + }, + "People": [], + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": if ud.watched == WatchedState::Watched { 1 } else { 0 }, + "IsFavorite": ud.rating > 0, + "Played": ud.watched == WatchedState::Watched, + "Key": "7a2175bc-cb1f-1a94-152c-bd2b2bae8f6d", + "ItemId": "00000000000000000000000000000000" + }, + "ChildCount": 2, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": n.slug.clone(), + "Tags": [], + "PrimaryImageAspectRatio": match aspect_class(n.kind) { + "aspect-thumb" => 16. / 9., + "aspect-land" => 2f32.sqrt(), + "aspect-port" => 1. / 2f32.sqrt(), + "aspect-square" | _ => 1., + }, + "CollectionType": "movies", + "ImageTags": { + "Primary": "the-image" + }, + "BackdropImageTags": [], + "LocationType": "FileSystem", + "MediaType": "Unknown", + "LockedFields": [], + "LockData": false + })) + } + + Ok(Json(json!({ + "Items": items, + "TotalRecordCount": items.len(), + "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("/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)] +} + +#[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 = "<data>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_users_authenticatebyname( + client_addr: IpAddr, + database: &State<Database>, + data: Json<AuthData>, +) -> MyResult<Json<Value>> { + 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 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/mod.rs b/server/src/routes/compat/mod.rs new file mode 100644 index 0000000..a7b8c0d --- /dev/null +++ b/server/src/routes/compat/mod.rs @@ -0,0 +1,7 @@ +/* + 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 jellyfin; +pub mod youtube; diff --git a/server/src/routes/external_compat.rs b/server/src/routes/compat/youtube.rs index eda3537..732431e 100644 --- a/server/src/routes/external_compat.rs +++ b/server/src/routes/compat/youtube.rs @@ -3,15 +3,18 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use super::ui::{account::session::Session, error::MyResult}; -use crate::routes::ui::node::rocket_uri_macro_r_library_node; -use crate::routes::ui::player::{rocket_uri_macro_r_player, PlayerConfig}; +use crate::routes::ui::{ + account::session::Session, + error::MyResult, + node::rocket_uri_macro_r_library_node, + player::{rocket_uri_macro_r_player, PlayerConfig}, +}; use anyhow::anyhow; use jellybase::database::Database; use rocket::{get, response::Redirect, State}; #[get("/watch?<v>")] -pub fn r_ext_youtube_watch(_session: Session, db: &State<Database>, v: &str) -> MyResult<Redirect> { +pub fn r_youtube_watch(_session: Session, db: &State<Database>, v: &str) -> MyResult<Redirect> { if v.len() != 11 { Err(anyhow!("video id length incorrect"))? } @@ -26,11 +29,7 @@ pub fn r_ext_youtube_watch(_session: Session, db: &State<Database>, v: &str) -> } #[get("/channel/<id>")] -pub fn r_ext_youtube_channel( - _session: Session, - db: &State<Database>, - id: &str, -) -> MyResult<Redirect> { +pub fn r_youtube_channel(_session: Session, db: &State<Database>, id: &str) -> MyResult<Redirect> { let Some(id) = (if id.starts_with("UC") { db.get_node_external_id("youtube:channel", id)? } else if id.starts_with("@") { diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index e0b955a..f2cfdfd 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -7,7 +7,18 @@ use self::playersync::{r_streamsync, PlayersyncChannels}; use crate::{database::Database, routes::ui::error::MyResult}; use api::{r_api_account_login, r_api_asset_token_raw, r_api_root, r_api_version}; use base64::Engine; -use external_compat::{r_ext_youtube_channel, r_ext_youtube_watch}; +use compat::{ + jellyfin::{ + r_jellyfin_branding_configuration, r_jellyfin_branding_css, + r_jellyfin_displaypreferences_usersettings, r_jellyfin_items_image_primary, + r_jellyfin_livetv_programs_recommended, r_jellyfin_playback_bitratetest, + r_jellyfin_quickconnect_enabled, r_jellyfin_sessions_capabilities_full, + r_jellyfin_system_endpoint, r_jellyfin_system_info, r_jellyfin_system_info_public, + r_jellyfin_system_info_public_case, r_jellyfin_users_authenticatebyname, + r_jellyfin_users_id, r_jellyfin_users_public, r_jellyfin_users_views, + }, + youtube::{r_youtube_channel, r_youtube_watch}, +}; use jellybase::{federation::Federation, CONF, SECRETS}; use log::warn; use rand::random; @@ -49,7 +60,7 @@ use userdata::{ }; pub mod api; -pub mod external_compat; +pub mod compat; pub mod playersync; pub mod stream; pub mod ui; @@ -144,8 +155,25 @@ pub fn build_rocket(database: Database, federation: Federation) -> Rocket<Build> r_api_account_login, r_api_root, r_api_asset_token_raw, - r_ext_youtube_watch, - r_ext_youtube_channel, + 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_jellyfin_branding_configuration, + r_jellyfin_users_authenticatebyname, + r_jellyfin_sessions_capabilities_full, + r_jellyfin_system_endpoint, + r_jellyfin_branding_css, + 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_socket, ], ) } diff --git a/server/src/routes/ui/account/session/guard.rs b/server/src/routes/ui/account/session/guard.rs index 57540cf..3a3f6d7 100644 --- a/server/src/routes/ui/account/session/guard.rs +++ b/server/src/routes/ui/account/session/guard.rs @@ -23,6 +23,8 @@ impl Session { { let token = req .query_value("session") + .or(req.query_value("api_key")) + .or(req.headers().get_one("X-MediaBrowser-Token").map(Ok)) // for jellyfin compat .map(|e| e.expect("str parse should not fail, right?")) .or(req.cookies().get("session").map(|cookie| cookie.value())) .ok_or(anyhow!("not logged in"))?; diff --git a/server/src/routes/ui/assets.rs b/server/src/routes/ui/assets.rs index a01c8bc..bd48f35 100644 --- a/server/src/routes/ui/assets.rs +++ b/server/src/routes/ui/assets.rs @@ -80,10 +80,9 @@ pub async fn r_item_poster( } }; let asset = asset.unwrap_or_else(|| { - AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()) - .ser() + AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()).ser() }); - Ok(Redirect::temporary(rocket::uri!(r_asset(asset.0, width)))) + Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width)))) } #[get("/n/<id>/backdrop?<width>")] pub async fn r_item_backdrop( @@ -105,10 +104,9 @@ pub async fn r_item_backdrop( } }; let asset = asset.unwrap_or_else(|| { - AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()) - .ser() + AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()).ser() }); - Ok(Redirect::temporary(rocket::uri!(r_asset(asset.0, width)))) + Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width)))) } #[get("/n/<id>/person/<index>/asset?<group>&<width>")] @@ -137,7 +135,7 @@ pub async fn r_person_asset( .headshot .to_owned() .unwrap_or(AssetInner::Assets("fallback-Person.avif".into()).ser()); - Ok(Redirect::temporary(rocket::uri!(r_asset(asset.0, width)))) + Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width)))) } // TODO this can create "federation recursion" because track selection cannot be relied on. |