diff options
-rw-r--r-- | base/src/database.rs | 4 | ||||
-rw-r--r-- | server/src/routes/compat/jellyfin.rs | 332 | ||||
-rw-r--r-- | server/src/routes/mod.rs | 20 | ||||
-rw-r--r-- | server/src/routes/ui/search.rs | 2 |
4 files changed, 264 insertions, 94 deletions
diff --git a/base/src/database.rs b/base/src/database.rs index 3c8bef4..90e42b9 100644 --- a/base/src/database.rs +++ b/base/src/database.rs @@ -286,7 +286,7 @@ impl Database { drop(nodes); Ok(i) } - pub fn search(&self, query: &str, page: usize) -> Result<(usize, Vec<NodeID>)> { + pub fn search(&self, query: &str, limit: usize, offset: usize) -> Result<(usize, Vec<NodeID>)> { let query = QueryParser::for_index( &self.text_search.index, vec![self.text_search.title, self.text_search.description], @@ -295,7 +295,7 @@ impl Database { .context("parsing query")?; let searcher = self.text_search.reader.searcher(); - let sres = searcher.search(&query, &TopDocs::with_limit(32).and_offset(page * 32))?; + let sres = searcher.search(&query, &TopDocs::with_limit(limit).and_offset(offset))?; let scount = searcher.search(&query, &Count)?; let mut results = Vec::new(); diff --git a/server/src/routes/compat/jellyfin.rs b/server/src/routes/compat/jellyfin.rs index 7815c09..17ddcb6 100644 --- a/server/src/routes/compat/jellyfin.rs +++ b/server/src/routes/compat/jellyfin.rs @@ -19,7 +19,7 @@ use jellycommon::{ user::{NodeUserData, WatchedState}, MediaInfo, Node, NodeID, NodeKind, SourceTrack, SourceTrackKind, Visibility, }; -use rocket::{get, post, response::Redirect, serde::json::Json, State}; +use rocket::{get, post, response::Redirect, serde::json::Json, FromForm, State}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::{collections::BTreeMap, net::IpAddr}; @@ -150,46 +150,137 @@ pub fn r_jellyfin_items_image_primary( } #[get("/Users/<uid>/Items/<id>")] -pub fn r_jellyfin_users_items( +#[allow(private_interfaces)] +pub fn r_jellyfin_users_items_item( session: Session, database: &State<Database>, uid: &str, id: &str, -) -> MyResult<Json<Value>> { +) -> MyResult<Json<JellyfinItem>> { let _ = uid; let (n, ud) = database.get_node_with_userdata(NodeID::from_slug(id), &session)?; - Ok(Json(node_object(&n, &ud))) + Ok(Json(item_object(&n, &ud))) } -#[get("/Users/<uid>/Items?<StartIndex>&<ParentId>&<Limit>")] -#[allow(non_snake_case)] -pub fn r_jellyfin_items( +#[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, - StartIndex: Option<usize>, - ParentId: &str, - Limit: usize, + query: JellyfinItemQuery, ) -> MyResult<Json<Value>> { let _ = uid; - let children = database.get_node_children(NodeID::from_slug(ParentId))?; + 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 mut items = Vec::new(); - for nid in children - .into_iter() - .skip(StartIndex.unwrap_or_default()) - .take(Limit) - { + + let nodes = if let Some(q) = query.search_term { + database + .search(&q, query.limit, query.start_index.unwrap_or_default())? + .1 + } else if let Some(parent) = query.parent_id { + database + .get_node_children(NodeID::from_slug(&parent))? + .into_iter() + .skip(query.start_index.unwrap_or_default()) + .take(query.limit) + .collect() + } else { + vec![] + }; + + let filter_kind = query + .include_item_types + .map(|n| match n.as_str() { + "Movie" => NodeKind::Movie, + "Audio" => NodeKind::Music, + "Video" => NodeKind::Video, + _ => NodeKind::Unknown, + }) + .or(if query.internal_artists { + Some(NodeKind::Channel) + } else { + None + }) + .or(if query.internal_persons { + Some(NodeKind::Channel) + } else { + None + }); + for nid in nodes { let (n, ud) = database.get_node_with_userdata(nid, &session)?; + if let Some(fk) = filter_kind { + if n.kind != fk { + continue; + } + } if n.visibility >= Visibility::Reduced { - items.push(node_object(&n, &ud)) + items.push(item_object(&n, &ud)) } } Ok(Json(json!({ "Items": items, "TotalRecordCount": items.len(), - "StartIndex": 0 + "StartIndex": query.start_index.unwrap_or_default() }))) } @@ -210,7 +301,7 @@ pub fn r_jellyfin_users_views( for nid in toplevel { let (n, ud) = database.get_node_with_userdata(nid, &session)?; if n.visibility >= Visibility::Reduced { - items.push(node_object(&n, &ud)) + items.push(item_object(&n, &ud)) } } @@ -386,14 +477,30 @@ pub fn r_jellyfin_users_authenticatebyname( }))) } -#[derive(Clone, Serialize)] +#[derive(Debug, Serialize, Deserialize)] +enum JellyfinItemType { + AudioBook, + Movie, + BoxSet, + Book, + Photo, + PhotoAlbum, + TvChannel, + LiveTvProgram, + Video, + Audio, + MusicAlbum, + CollectionFolder, +} + +#[derive(Debug, Clone, Serialize)] enum JellyfinMediaStreamType { Video, Audio, Subtitle, } -#[derive(Clone, Serialize)] +#[derive(Debug, Clone, Serialize)] #[serde(rename_all = "PascalCase")] struct JellyfinMediaStream { codec: String, @@ -432,21 +539,21 @@ struct JellyfinMediaStream { localized_external: String, } -#[derive(Clone, Serialize)] +#[derive(Debug, Clone, Serialize)] enum JellyfinMediaSourceProtocol { File, } -#[derive(Clone, Serialize)] +#[derive(Debug, Clone, Serialize)] enum JellyfinMediaSourceType { Default, } -#[derive(Clone, Serialize)] +#[derive(Debug, Clone, Serialize)] enum JellyfinVideoType { VideoFile, } -#[derive(Clone, Serialize)] +#[derive(Debug, Clone, Serialize)] #[serde(rename_all = "PascalCase")] struct JellyfinMediaSource { protocol: JellyfinMediaSourceProtocol, @@ -614,77 +721,136 @@ fn media_source_object(node: &Node, m: &MediaInfo) -> JellyfinMediaSource { } } -fn node_object(node: &Node, userdata: &NodeUserData) -> Value { +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +struct JellyfinItem { + name: String, + server_id: String, + id: String, + e_tag: String, + date_created: String, + can_delete: bool, + can_download: bool, + preferred_metadata_language: String, + preferred_metadata_country_code: String, + sort_name: String, + forced_sort_name: String, + external_urls: Vec<()>, + enable_media_source_display: bool, + custom_rating: String, + channel_id: Option<String>, + overview: String, + taglines: Vec<String>, + genres: Vec<()>, + play_access: Option<String>, + remote_trailers: Vec<()>, + provider_ids: BTreeMap<(), ()>, + is_folder: bool, + parent_id: String, + r#type: JellyfinItemType, + people: Vec<()>, + studios: Vec<()>, + genre_items: Vec<()>, + local_trailer_count: usize, + special_feature_count: usize, + child_count: usize, + locked_fields: Vec<()>, + lock_data: bool, + tags: Vec<String>, + user_data: Value, + display_preferences_id: String, + primary_image_aspect_ratio: f64, + collection_type: String, + image_tags: BTreeMap<String, String>, + backdrop_image_tags: Vec<()>, + location_type: Option<String>, + media_type: String, + video_type: Option<String>, + container: Option<String>, + run_time_ticks: Option<i64>, + media_sources: Option<Vec<JellyfinMediaSource>>, + media_streams: Option<Vec<JellyfinMediaStream>>, + path: Option<String>, +} + +fn item_object(node: &Node, userdata: &NodeUserData) -> JellyfinItem { let media_source = node.media.as_ref().map(|m| media_source_object(node, m)); - json!({ - "Name": node.title, - "ServerId": SERVER_ID, - "Id": node.slug.clone(), - "Etag": "blob", - "DateCreated": "0001-01-01T00:00:00.0000000Z", - "CanDelete": false, - "CanDownload": true, - "PreferredMetadataLanguage": "", - "PreferredMetadataCountryCode": "", - "SortName": node.slug.clone(), - "ForcedSortName": "", - "ExternalUrls": [], - "EnableMediaSourceDisplay": true, - "CustomRating": "", - "ChannelId": null, - "Overview": "", - "Taglines": [], - "Genres": [], - "PlayAccess": "Full", - "RemoteTrailers": [], - "ProviderIds": {}, - "IsFolder": !node.media.is_some(), - "ParentId": "todo-parent", // TODO - "Type": match node.kind { - NodeKind::Movie | NodeKind::Video | NodeKind::ShortFormVideo => "Movie", - NodeKind::Collection | _ => "CollectionFolder", + 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_some(), + parent_id: "todo-parent".to_owned(), // TODO + r#type: match node.kind { + NodeKind::Movie | NodeKind::Video | NodeKind::ShortFormVideo => JellyfinItemType::Movie, + NodeKind::Collection | _ => JellyfinItemType::CollectionFolder, }, - "People": [], - "Studios": [], - "GenreItems": [], - "LocalTrailerCount": 0, - "SpecialFeatureCount": 0, - "ChildCount": 0, - "LockedFields": [], - "LockData": false, - "Tags": [], - "UserData": { + people: vec![], + 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" - }, - "DisplayPreferencesId": node.slug.clone(), - "PrimaryImageAspectRatio": match aspect_class(node.kind) { + }), + display_preferences_id: node.slug.clone(), + primary_image_aspect_ratio: match aspect_class(node.kind) { "aspect-thumb" => 16. / 9., - "aspect-land" => 2f32.sqrt(), - "aspect-port" => 1. / 2f32.sqrt(), + "aspect-land" => 2f64.sqrt(), + "aspect-port" => 1. / 2f64.sqrt(), "aspect-square" | _ => 1., }, - "CollectionType": "unknown", - "ImageTags": { - "Primary": "the-image" + collection_type: "unknown".to_owned(), + image_tags: BTreeMap::from_iter([("Primary".to_string(), "the-image".to_string())]), + backdrop_image_tags: vec![], + media_type: if node.media.is_some() { + "Video".to_owned() + } else { + "Unknown".to_owned() }, - "BackdropImageTags": [], - "LocationType": "FileSystem", - "MediaType": if node.media.is_some() { "Video" } else { "Unknown" }, - "VideoType": node.media.as_ref().map(|_| "VideoFile"), - "LocationType": node.media.as_ref().map(|_| "FileSystem"), - "PlayAccess": node.media.as_ref().map(|_| "Full"), - "Container": node.media.as_ref().map(|_| "mkv"), - "RunTimeTicks": if let Some(m) = &node.media { Some((m.duration * 10_000_000.) as i64) } else { None }, - "MediaSources": media_source.as_ref().map(|s|vec![s.clone()]), - "MediaStreams": media_source.as_ref().map(|s|s.media_streams.clone()), - "Path": node.media.as_ref().map(|_|format!("/path/to/{}.mkv", node.slug)), - }) + 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(|_| "mkv".to_owned()), + run_time_ticks: if let Some(m) = &node.media { + Some((m.duration * 10_000_000.) as i64) + } else { + None + }, + 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/{}.mkv", node.slug)), + } } fn user_object(username: String) -> Value { diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 93a832f..373146b 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -9,16 +9,17 @@ use api::{r_api_account_login, r_api_asset_token_raw, r_api_root, r_api_version} use base64::Engine; use compat::{ jellyfin::{ - r_jellyfin_branding_configuration, r_jellyfin_branding_css, + r_jellyfin_artists, r_jellyfin_branding_configuration, r_jellyfin_branding_css, r_jellyfin_displaypreferences_usersettings, r_jellyfin_items, r_jellyfin_items_image_primary, r_jellyfin_items_intros, r_jellyfin_items_playbackinfo, - r_jellyfin_livetv_programs_recommended, r_jellyfin_playback_bitratetest, - r_jellyfin_quickconnect_enabled, r_jellyfin_sessions_capabilities_full, - r_jellyfin_sessions_playing, r_jellyfin_sessions_playing_progress, r_jellyfin_socket, - 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_items, r_jellyfin_users_public, - r_jellyfin_users_views, r_jellyfin_video_stream, + r_jellyfin_livetv_programs_recommended, r_jellyfin_persons, + r_jellyfin_playback_bitratetest, r_jellyfin_quickconnect_enabled, + r_jellyfin_sessions_capabilities_full, r_jellyfin_sessions_playing, + r_jellyfin_sessions_playing_progress, r_jellyfin_socket, 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_items, + r_jellyfin_users_items_item, r_jellyfin_users_public, r_jellyfin_users_views, + r_jellyfin_video_stream, }, youtube::{r_youtube_channel, r_youtube_watch}, }; @@ -177,7 +178,10 @@ pub fn build_rocket(database: Database, federation: Federation) -> Rocket<Build> r_jellyfin_items_image_primary, r_jellyfin_livetv_programs_recommended, r_jellyfin_users_items, + r_jellyfin_users_items_item, r_jellyfin_items, + r_jellyfin_persons, + r_jellyfin_artists, r_jellyfin_items_intros, r_jellyfin_items_playbackinfo, r_jellyfin_video_stream, diff --git a/server/src/routes/ui/search.rs b/server/src/routes/ui/search.rs index 6b1504f..d020e2e 100644 --- a/server/src/routes/ui/search.rs +++ b/server/src/routes/ui/search.rs @@ -18,7 +18,7 @@ pub async fn r_search<'a>( ) -> MyResult<DynLayoutPage<'a>> { let timing = Instant::now(); let results = if let Some(query) = query { - let (count, ids) = db.search(query, page.unwrap_or_default())?; + let (count, ids) = db.search(query, 32, page.unwrap_or_default() * 32)?; let mut nodes = ids .into_iter() .map(|id| db.get_node_with_userdata(id, &session)) |