aboutsummaryrefslogtreecommitdiff
path: root/server/src/routes/compat
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-02-03 22:42:13 +0100
committermetamuffin <metamuffin@disroot.org>2025-02-03 22:42:13 +0100
commite43dc75e3cfb950ac0d0308900c20fa292de0c46 (patch)
treefce989ad0292328166efede8cfb2b769c370ab24 /server/src/routes/compat
parent11c5be29987912b89fd6d351938d08fe6a561ad2 (diff)
downloadjellything-e43dc75e3cfb950ac0d0308900c20fa292de0c46.tar
jellything-e43dc75e3cfb950ac0d0308900c20fa292de0c46.tar.bz2
jellything-e43dc75e3cfb950ac0d0308900c20fa292de0c46.tar.zst
some jellyfin api endpoints
Diffstat (limited to 'server/src/routes/compat')
-rw-r--r--server/src/routes/compat/jellyfin.rs393
-rw-r--r--server/src/routes/compat/mod.rs7
-rw-r--r--server/src/routes/compat/youtube.rs44
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))))
+}