diff options
Diffstat (limited to 'server/src/routes/compat')
-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 | 44 |
3 files changed, 444 insertions, 0 deletions
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/compat/youtube.rs b/server/src/routes/compat/youtube.rs new file mode 100644 index 0000000..732431e --- /dev/null +++ b/server/src/routes/compat/youtube.rs @@ -0,0 +1,44 @@ +/* + 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::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_youtube_watch(_session: Session, db: &State<Database>, v: &str) -> MyResult<Redirect> { + if v.len() != 11 { + Err(anyhow!("video id length incorrect"))? + } + let Some(id) = db.get_node_external_id("youtube:video", v)? else { + Err(anyhow!("element not found"))? + }; + let node = db.get_node(id)?.ok_or(anyhow!("node missing"))?; + Ok(Redirect::to(rocket::uri!(r_player( + &node.slug, + PlayerConfig::default() + )))) +} + +#[get("/channel/<id>")] +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("@") { + db.get_node_external_id("youtube:channel-name", id)? + } else { + Err(anyhow!("unknown channel id format"))? + }) else { + Err(anyhow!("channel not found"))? + }; + let node = db.get_node(id)?.ok_or(anyhow!("node missing"))?; + Ok(Redirect::to(rocket::uri!(r_library_node(&node.slug)))) +} |