A tiny Subsonic/Jellyfin/S3 server in Rust
navidrome subsonic s3 emby jellyfin
0

Configure Feed

Select the types of activity you want to include in your feed.

smolsonic / src / jellyfin / handlers.rs
169 kB 4518 lines
1use crate::models::{Album, Artist, Playlist, Song, Video}; 2use crate::server::repo; 3use actix_web::{web, HttpRequest, HttpResponse}; 4use chrono::Utc; 5use serde::Deserialize; 6use serde_json::{json, Value}; 7use std::path::PathBuf; 8 9use super::auth::{self, AuthedUser, EmbyAuth}; 10use super::dto::{ 11 AuthenticationResult, BaseItemDto, ImageBlurHashes, ImageTags, ItemsResult, MediaSource, 12 MediaStream, NameGuidPair, PlaybackInfoResponse, PlaylistCreationResult, PublicSystemInfo, 13 SessionInfoDto, SystemInfo, UserConfiguration, UserDto, UserItemDataDto, UserPolicy, 14 ViewsResult, JELLYFIN_API_VERSION, 15}; 16use super::mapping; 17use super::JellyfinState; 18 19const TICKS_PER_MS: i64 = 10_000; 20 21fn now_iso() -> String { 22 // Emit the *naive* 7-digit format real Jellyfin uses 23 // (`2026-06-29T12:17:19.6620000`) — no timezone marker. 24 // jellyfin-sdk-kotlin deserializes via LocalDateTime, which rejects 25 // a trailing `Z` or `+00:00` and silently drops the whole object. 26 let now = Utc::now(); 27 let ticks = now.timestamp_subsec_nanos() / 100; // 100-ns "ticks", 7 digits 28 format!("{}.{ticks:07}", now.format("%Y-%m-%dT%H:%M:%S")) 29} 30 31fn parse_auth(req: &HttpRequest) -> EmbyAuth { 32 let headers = req.headers(); 33 for name in ["x-emby-authorization", "authorization"] { 34 if let Some(v) = headers.get(name) { 35 if let Ok(s) = v.to_str() { 36 return auth::parse_emby_auth_header(s); 37 } 38 } 39 } 40 EmbyAuth::default() 41} 42 43fn server_base(state: &JellyfinState, req: &HttpRequest) -> String { 44 if let Some(h) = req.headers().get("host").and_then(|v| v.to_str().ok()) { 45 let scheme = req.connection_info().scheme().to_string(); 46 return format!("{scheme}://{h}"); 47 } 48 format!("http://{}:{}", state.host, state.port) 49} 50 51// ── Root index ──────────────────────────────────────────────────────────────── 52 53pub async fn index(state: web::Data<JellyfinState>) -> HttpResponse { 54 HttpResponse::Ok() 55 .content_type("text/plain; charset=utf-8") 56 .body(format!( 57 "smolsonic — Jellyfin-compatible sidecar\nserver: {} ({})\n", 58 state.server_name, state.server_id 59 )) 60} 61 62// ── System ─────────────────────────────────────────────────────────────────── 63 64/// Map Rust's `std::env::consts::OS` to the capitalised names Jellyfin uses 65/// in `OperatingSystem` (e.g. `"Darwin"`, `"Linux"`, `"Windows"`). Some 66/// clients pattern-match on this. 67fn jellyfin_os_name() -> &'static str { 68 match std::env::consts::OS { 69 "macos" => "Darwin", 70 "linux" => "Linux", 71 "windows" => "Windows", 72 "freebsd" => "FreeBSD", 73 "openbsd" => "OpenBSD", 74 "netbsd" => "NetBSD", 75 "android" => "Android", 76 "ios" => "iOS", 77 other => match other.chars().next() { 78 Some(c) if c.is_ascii_lowercase() => { 79 Box::leak(format!("{}{}", c.to_ascii_uppercase(), &other[1..]).into_boxed_str()) 80 } 81 _ => other, 82 }, 83 } 84} 85 86fn public_info(state: &JellyfinState, req: &HttpRequest) -> PublicSystemInfo { 87 PublicSystemInfo { 88 local_address: Some(server_base(state, req)), 89 server_name: Some(state.server_name.clone()), 90 version: Some(JELLYFIN_API_VERSION.to_string()), 91 product_name: Some("Jellyfin Server".to_string()), 92 operating_system: Some(jellyfin_os_name().to_string()), 93 id: Some(state.server_id.clone()), 94 startup_wizard_completed: Some(true), 95 } 96} 97 98pub async fn system_info_public(state: web::Data<JellyfinState>, req: HttpRequest) -> HttpResponse { 99 HttpResponse::Ok().json(public_info(&state, &req)) 100} 101 102pub async fn system_info( 103 _user: AuthedUser, 104 state: web::Data<JellyfinState>, 105 req: HttpRequest, 106) -> HttpResponse { 107 let pub_info = public_info(&state, &req); 108 let info = SystemInfo { 109 local_address: pub_info.local_address, 110 server_name: pub_info.server_name, 111 version: pub_info.version, 112 product_name: pub_info.product_name, 113 operating_system: pub_info.operating_system, 114 id: pub_info.id, 115 startup_wizard_completed: pub_info.startup_wizard_completed, 116 operating_system_display_name: Some(jellyfin_os_name().to_string()), 117 package_name: Some("smolsonic".to_string()), 118 has_pending_restart: false, 119 is_shutting_down: false, 120 supports_library_monitor: true, 121 web_socket_port_number: state.port as i32, 122 completed_installations: Some(vec![]), 123 can_self_restart: false, 124 can_launch_web_browser: false, 125 program_data_path: Some(String::new()), 126 web_path: Some(String::new()), 127 items_by_name_path: Some(String::new()), 128 cache_path: Some(String::new()), 129 log_path: Some(String::new()), 130 internal_metadata_path: Some(String::new()), 131 transcoding_temp_path: Some(String::new()), 132 cast_receiver_applications: Some(vec![]), 133 has_update_available: false, 134 encoder_location: Some("Default".to_string()), 135 system_architecture: Some(std::env::consts::ARCH.to_string()), 136 }; 137 HttpResponse::Ok().json(info) 138} 139 140pub async fn system_endpoint(_user: AuthedUser, req: HttpRequest) -> HttpResponse { 141 let info = req.connection_info(); 142 let addr = info.realip_remote_addr().unwrap_or(""); 143 HttpResponse::Ok().json(json!({ 144 "IsLocal": true, 145 "IsInNetwork": true, 146 "RemoteAddress": addr, 147 })) 148} 149 150// ── Users / Auth ───────────────────────────────────────────────────────────── 151 152#[derive(Debug, Deserialize)] 153#[serde(rename_all = "PascalCase")] 154pub struct AuthenticateBody { 155 pub username: Option<String>, 156 pub pw: Option<String>, 157 pub password: Option<String>, 158} 159 160fn build_user(state: &JellyfinState) -> UserDto { 161 UserDto { 162 name: Some(state.username.as_str().to_string()), 163 server_id: Some(state.server_id.clone()), 164 server_name: Some(state.server_name.clone()), 165 id: state.user_id.as_str().to_string(), 166 primary_image_tag: None, 167 primary_image_aspect_ratio: None, 168 has_password: Some(true), 169 has_configured_password: Some(true), 170 has_configured_easy_password: Some(false), 171 enable_auto_login: Some(false), 172 last_login_date: Some(now_iso()), 173 last_activity_date: Some(now_iso()), 174 configuration: Some(UserConfiguration::default()), 175 policy: Some(UserPolicy::admin()), 176 } 177} 178 179pub async fn authenticate_by_name( 180 state: web::Data<JellyfinState>, 181 req: HttpRequest, 182 body: web::Json<AuthenticateBody>, 183) -> HttpResponse { 184 let body = body.into_inner(); 185 let username = body.username.unwrap_or_default(); 186 let password = body.pw.or(body.password).unwrap_or_default(); 187 188 if username != *state.username || password != *state.password { 189 return HttpResponse::Unauthorized().json(json!({ 190 "Message": "Invalid username or password" 191 })); 192 } 193 194 let parsed = parse_auth(&req); 195 let token = auth::random_hex(16); 196 let now = now_iso(); 197 if let Err(e) = 198 auth::store_token(&state.pool, &token, state.user_id.as_str(), &parsed, &now).await 199 { 200 tracing::error!("jellyfin: store_token: {e}"); 201 return HttpResponse::InternalServerError().finish(); 202 } 203 204 let user = build_user(&state); 205 let session = SessionInfoDto { 206 play_state: None, 207 additional_users: Some(vec![]), 208 capabilities: None, 209 remote_end_point: Some( 210 req.connection_info() 211 .realip_remote_addr() 212 .unwrap_or("") 213 .to_string(), 214 ), 215 playable_media_types: vec!["Audio".into(), "Video".into()], 216 id: Some(auth::random_hex(16)), 217 user_id: user.id.clone(), 218 user_name: user.name.clone(), 219 client: parsed.client.clone(), 220 last_activity_date: now.clone(), 221 last_playback_check_in: now.clone(), 222 last_paused_date: None, 223 device_name: parsed.device.clone(), 224 device_type: None, 225 now_playing_item: None, 226 now_viewing_item: None, 227 device_id: parsed.device_id.clone(), 228 application_version: parsed.version.clone(), 229 transcoding_info: None, 230 is_active: true, 231 supports_media_control: false, 232 supports_remote_control: false, 233 now_playing_queue: Some(vec![]), 234 has_custom_device_name: false, 235 playlist_item_id: None, 236 server_id: Some(state.server_id.clone()), 237 user_primary_image_tag: None, 238 supported_commands: vec![], 239 }; 240 241 HttpResponse::Ok().json(AuthenticationResult { 242 user: Some(user), 243 session_info: Some(session), 244 access_token: Some(token), 245 server_id: Some(state.server_id.clone()), 246 }) 247} 248 249pub async fn users_public(state: web::Data<JellyfinState>) -> HttpResponse { 250 // Most clients (Findroid, Streamyfin, official) show a user picker from 251 // this list and then prompt for the password. Returning an empty array 252 // makes them display "no users found" with no manual-login fallback. 253 HttpResponse::Ok().json(vec![build_user(&state)]) 254} 255 256pub async fn users_list(_user: AuthedUser, state: web::Data<JellyfinState>) -> HttpResponse { 257 HttpResponse::Ok().json(vec![build_user(&state)]) 258} 259 260pub async fn users_me(_user: AuthedUser, state: web::Data<JellyfinState>) -> HttpResponse { 261 HttpResponse::Ok().json(build_user(&state)) 262} 263 264pub async fn user_by_id( 265 _user: AuthedUser, 266 state: web::Data<JellyfinState>, 267 _path: web::Path<String>, 268) -> HttpResponse { 269 HttpResponse::Ok().json(build_user(&state)) 270} 271 272// ── Views (top-level library list) ─────────────────────────────────────────── 273 274fn music_library_view(state: &JellyfinState) -> BaseItemDto { 275 BaseItemDto { 276 id: mapping::library_guid(), 277 server_id: Some(state.server_id.clone()), 278 name: Some("Music".to_string()), 279 item_type: "CollectionFolder", 280 media_type: "Unknown", 281 is_folder: Some(true), 282 collection_type: Some("music"), 283 location_type: Some("FileSystem"), 284 ..Default::default() 285 } 286} 287 288fn movies_library_view(state: &JellyfinState) -> Option<BaseItemDto> { 289 let name = state.video_library_name.as_ref()?.clone(); 290 Some(BaseItemDto { 291 id: mapping::movies_library_guid(), 292 server_id: Some(state.server_id.clone()), 293 name: Some(name), 294 item_type: "CollectionFolder", 295 media_type: "Unknown", 296 is_folder: Some(true), 297 collection_type: Some("movies"), 298 location_type: Some("FileSystem"), 299 ..Default::default() 300 }) 301} 302 303/// Virtual "Playlists" library. Jellyfin's reference server auto-creates 304/// this view so clients (Moonfin, Findroid, official web) render playlists 305/// as a top-level tile alongside Music and Movies. `CollectionType` is the 306/// spec enum value `"playlists"` (plural). 307fn playlists_library_view(state: &JellyfinState) -> BaseItemDto { 308 BaseItemDto { 309 id: mapping::playlists_library_guid(), 310 server_id: Some(state.server_id.clone()), 311 name: Some("Playlists".to_string()), 312 item_type: "CollectionFolder", 313 media_type: "Unknown", 314 is_folder: Some(true), 315 collection_type: Some("playlists"), 316 location_type: Some("FileSystem"), 317 ..Default::default() 318 } 319} 320 321fn all_library_views(state: &JellyfinState) -> Vec<BaseItemDto> { 322 let mut v = vec![music_library_view(state)]; 323 if let Some(view) = movies_library_view(state) { 324 v.push(view); 325 } 326 v.push(playlists_library_view(state)); 327 v 328} 329 330pub async fn user_views( 331 _user: AuthedUser, 332 state: web::Data<JellyfinState>, 333 _path: web::Path<String>, 334) -> HttpResponse { 335 let views = all_library_views(&state); 336 let total = views.len() as i32; 337 HttpResponse::Ok().json(ViewsResult { 338 items: views, 339 total_record_count: total, 340 start_index: 0, 341 }) 342} 343 344pub async fn media_folders(_user: AuthedUser, state: web::Data<JellyfinState>) -> HttpResponse { 345 let views = all_library_views(&state); 346 let total = views.len() as i32; 347 HttpResponse::Ok().json(ViewsResult { 348 items: views, 349 total_record_count: total, 350 start_index: 0, 351 }) 352} 353 354pub async fn library_virtual_folders( 355 _user: AuthedUser, 356 state: web::Data<JellyfinState>, 357) -> HttpResponse { 358 let mut folders = vec![json!({ 359 "Name": "Music", 360 "Locations": [state.music_dir.to_string_lossy()], 361 "CollectionType": "music", 362 "ItemId": mapping::library_guid(), 363 })]; 364 if let Some(name) = state.video_library_name.as_ref() { 365 folders.push(json!({ 366 "Name": name, 367 "Locations": [], 368 "CollectionType": "movies", 369 "ItemId": mapping::movies_library_guid(), 370 })); 371 } 372 folders.push(json!({ 373 "Name": "Playlists", 374 "Locations": [], 375 "CollectionType": "playlists", 376 "ItemId": mapping::playlists_library_guid(), 377 })); 378 HttpResponse::Ok().json(folders) 379} 380 381// ── Items ──────────────────────────────────────────────────────────────────── 382 383/// Jellyfin query parameters use **camelCase** (`parentId`, `userId`, 384/// `searchTerm`, …) — JSON bodies use PascalCase, queries don't. 385/// 386/// We parse this by hand from `HttpRequest::query_string()` instead of using 387/// `web::Query<ItemsQuery>` because the Jellyfin spec allows array params to 388/// be sent as repeated keys (`?includeItemTypes=Folder&includeItemTypes=Movie`) 389/// — actix's default `serde_urlencoded` rejects duplicates with 400. We 390/// concatenate repeated values with commas, which our downstream `includes` 391/// helper already understands. 392#[derive(Debug, Default)] 393#[allow(dead_code)] // Fields kept for spec completeness even if not consulted yet. 394pub struct ItemsQuery { 395 pub parent_id: Option<String>, 396 pub include_item_types: Option<String>, 397 pub media_types: Option<String>, 398 pub name_starts_with: Option<String>, 399 pub name_starts_with_or_greater: Option<String>, 400 pub name_less_than: Option<String>, 401 pub recursive: Option<bool>, 402 pub search_term: Option<String>, 403 pub ids: Option<String>, 404 pub album_artist_ids: Option<String>, 405 pub artist_ids: Option<String>, 406 pub album_ids: Option<String>, 407 pub start_index: Option<i64>, 408 pub limit: Option<i64>, 409 pub sort_by: Option<String>, 410 pub sort_order: Option<String>, 411 pub user_id: Option<String>, 412 pub enable_user_data: Option<bool>, 413 pub enable_total_record_count: Option<bool>, 414 pub enable_images: Option<bool>, 415 pub fields: Option<String>, 416 pub is_favorite: Option<bool>, 417 pub filters: Option<String>, 418} 419 420fn collect_query(req: &HttpRequest) -> std::collections::HashMap<String, Vec<String>> { 421 let mut out: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new(); 422 for pair in req.query_string().split('&').filter(|s| !s.is_empty()) { 423 let mut it = pair.splitn(2, '='); 424 let key = it.next().unwrap_or(""); 425 let val = it.next().unwrap_or(""); 426 let decoded = urlencoding::decode(val) 427 .map(|s| s.into_owned()) 428 .unwrap_or_else(|_| val.to_string()); 429 out.entry(key.to_string()).or_default().push(decoded); 430 } 431 out 432} 433 434fn parse_items_query(req: &HttpRequest) -> ItemsQuery { 435 let q = collect_query(req); 436 let one = |k: &str| q.get(k).and_then(|v| v.first()).cloned(); 437 let csv = |k: &str| q.get(k).map(|v| v.join(",")); 438 let parse_bool = |k: &str| one(k).and_then(|s| s.parse::<bool>().ok()); 439 let parse_i64 = |k: &str| one(k).and_then(|s| s.parse::<i64>().ok()); 440 ItemsQuery { 441 parent_id: one("parentId").or_else(|| one("ParentId")), 442 include_item_types: csv("includeItemTypes").or_else(|| csv("IncludeItemTypes")), 443 media_types: csv("mediaTypes").or_else(|| csv("MediaTypes")), 444 name_starts_with: one("nameStartsWith").or_else(|| one("NameStartsWith")), 445 name_starts_with_or_greater: one("nameStartsWithOrGreater") 446 .or_else(|| one("NameStartsWithOrGreater")), 447 name_less_than: one("nameLessThan").or_else(|| one("NameLessThan")), 448 recursive: parse_bool("recursive"), 449 search_term: one("searchTerm").or_else(|| one("SearchTerm")), 450 ids: csv("ids").or_else(|| csv("Ids")), 451 album_artist_ids: csv("albumArtistIds").or_else(|| csv("AlbumArtistIds")), 452 artist_ids: csv("artistIds").or_else(|| csv("ArtistIds")), 453 album_ids: csv("albumIds").or_else(|| csv("AlbumIds")), 454 start_index: parse_i64("startIndex").or_else(|| parse_i64("StartIndex")), 455 limit: parse_i64("limit").or_else(|| parse_i64("Limit")), 456 sort_by: one("sortBy").or_else(|| one("SortBy")), 457 sort_order: one("sortOrder").or_else(|| one("SortOrder")), 458 user_id: one("userId").or_else(|| one("UserId")), 459 enable_user_data: parse_bool("enableUserData"), 460 enable_total_record_count: parse_bool("enableTotalRecordCount"), 461 enable_images: parse_bool("enableImages"), 462 fields: csv("fields").or_else(|| csv("Fields")), 463 is_favorite: parse_bool("isFavorite").or_else(|| parse_bool("IsFavorite")), 464 filters: csv("filters").or_else(|| csv("Filters")), 465 } 466} 467 468/// True when the client asked for favorites-only via either 469/// `?IsFavorite=true` (per the OpenAPI `isFavorite` param) or 470/// `?Filters=IsFavorite` (per the OpenAPI `filters` CSV enum, which also 471/// includes `IsPlayed`, `IsResumable`, etc.). We only honour the affirmative 472/// direction — `IsFavorite=false` is a "don't filter" no-op, matching how 473/// most clients construct the URL. 474fn wants_favorites_only(q: &ItemsQuery) -> bool { 475 if q.is_favorite == Some(true) { 476 return true; 477 } 478 includes(&q.filters, "IsFavorite") 479} 480 481/// Build a `UserItemDataDto` by folding together the `starred` sidecar and 482/// the `user_item_data` row for `native_id`. `jf_guid` is the Jellyfin GUID 483/// we emit in `Key`/`ItemId` (clients use it to correlate the DTO with the 484/// containing BaseItem). Every `*_to_dto` helper — and the standalone 485/// `/UserItems/{itemId}/UserData` GET — uses this so the fields stay in 486/// sync with disk state. 487async fn build_user_data( 488 state: &JellyfinState, 489 native_id: &str, 490 jf_guid: String, 491) -> UserItemDataDto { 492 let is_favorite = repo::is_starred(&state.pool, native_id) 493 .await 494 .unwrap_or(false); 495 let data = repo::get_user_item_data(&state.pool, native_id) 496 .await 497 .unwrap_or_default(); 498 UserItemDataDto { 499 rating: data.rating, 500 played_percentage: None, 501 unplayed_item_count: None, 502 playback_position_ticks: data.playback_position_ticks, 503 play_count: data.play_count, 504 is_favorite, 505 likes: data.likes, 506 last_played_date: data.last_played_date, 507 played: data.played, 508 key: jf_guid.clone(), 509 item_id: jf_guid, 510 } 511} 512 513async fn artist_to_dto(state: &JellyfinState, a: &Artist) -> BaseItemDto { 514 let id = mapping::remember_artist(&state.pool, a) 515 .await 516 .unwrap_or_else(|_| mapping::guid(mapping::KIND_ARTIST, &a.id)); 517 let user_data = build_user_data(state, &a.id, id.clone()).await; 518 // Fetch bio / tags / Last.fm URL when the plugin is enabled. On error 519 // (network, quota) we silently fall back to a bare DTO so the artist 520 // page still renders. 521 let mut overview: Option<String> = None; 522 let mut tags: Option<Vec<String>> = None; 523 let mut external_urls: Option<Vec<super::dto::ExternalUrl>> = None; 524 if let Some(lf) = &state.similar.lastfm { 525 if let Ok(info) = lf.artist_info_cached(&state.pool, &a.id, &a.name).await { 526 overview = info.bio; 527 if !info.tags.is_empty() { 528 tags = Some(info.tags); 529 } 530 if let Some(url) = info.url { 531 external_urls = Some(vec![super::dto::ExternalUrl { 532 name: Some("Last.fm".to_string()), 533 url: Some(url), 534 }]); 535 } 536 } 537 } 538 BaseItemDto { 539 id: id.clone(), 540 server_id: Some(state.server_id.clone()), 541 name: Some(a.name.clone()), 542 item_type: "MusicArtist", 543 media_type: "Unknown", 544 is_folder: Some(true), 545 sort_name: Some(a.name.clone()), 546 location_type: Some("FileSystem"), 547 overview, 548 tags, 549 external_urls, 550 image_tags: Some(ImageTags { 551 primary: Some(a.id.clone()), 552 }), 553 user_data: Some(user_data), 554 ..Default::default() 555 } 556} 557 558async fn album_to_dto(state: &JellyfinState, al: &Album) -> BaseItemDto { 559 let id = mapping::remember_album(&state.pool, al) 560 .await 561 .unwrap_or_else(|_| mapping::guid(mapping::KIND_ALBUM, &al.id)); 562 let artist_guid = mapping::guid(mapping::KIND_ARTIST, &al.artist_id); 563 let song_count = repo::song_count_for_album(&state.pool, &al.id) 564 .await 565 .unwrap_or(0) as i32; 566 let duration_secs = repo::songs_for_album_duration(&state.pool, &al.id) 567 .await 568 .unwrap_or(0); 569 let user_data = build_user_data(state, &al.id, id.clone()).await; 570 // Last.fm-sourced Overview / Tags / ExternalUrls, same silent-fallback 571 // as `artist_to_dto`. 572 let mut overview: Option<String> = None; 573 let mut tags: Option<Vec<String>> = None; 574 let mut external_urls: Option<Vec<super::dto::ExternalUrl>> = None; 575 if let Some(lf) = &state.similar.lastfm { 576 if let Ok(info) = lf 577 .album_info_cached(&state.pool, &al.id, &al.artist, &al.title) 578 .await 579 { 580 overview = info.bio; 581 if !info.tags.is_empty() { 582 tags = Some(info.tags); 583 } 584 if let Some(url) = info.url { 585 external_urls = Some(vec![super::dto::ExternalUrl { 586 name: Some("Last.fm".to_string()), 587 url: Some(url), 588 }]); 589 } 590 } 591 } 592 BaseItemDto { 593 id: id.clone(), 594 server_id: Some(state.server_id.clone()), 595 name: Some(al.title.clone()), 596 item_type: "MusicAlbum", 597 media_type: "Unknown", 598 is_folder: Some(true), 599 production_year: if al.year > 0 { 600 Some(al.year as i32) 601 } else { 602 None 603 }, 604 premiere_date: if al.year > 0 { 605 Some(format!("{:04}-01-01T00:00:00.0000000", al.year)) 606 } else { 607 None 608 }, 609 album: Some(al.title.clone()), 610 album_id: Some(id.clone()), 611 album_artist: Some(al.artist.clone()), 612 album_artists: Some(vec![NameGuidPair { 613 name: Some(al.artist.clone()), 614 id: artist_guid.clone(), 615 }]), 616 artist_items: Some(vec![NameGuidPair { 617 name: Some(al.artist.clone()), 618 id: artist_guid.clone(), 619 }]), 620 artists: Some(vec![al.artist.clone()]), 621 parent_id: Some(artist_guid), 622 sort_name: Some(al.title.clone()), 623 run_time_ticks: Some(duration_secs * 1000 * TICKS_PER_MS), 624 song_count: Some(song_count), 625 child_count: Some(song_count), 626 location_type: Some("FileSystem"), 627 overview, 628 tags, 629 external_urls, 630 image_tags: Some(ImageTags { 631 primary: al.cover_art.clone().map(|_| al.id.clone()), 632 }), 633 user_data: Some(user_data), 634 ..Default::default() 635 } 636} 637 638async fn song_to_dto(state: &JellyfinState, s: &Song) -> BaseItemDto { 639 let id = mapping::remember_song(&state.pool, s) 640 .await 641 .unwrap_or_else(|_| mapping::guid(mapping::KIND_SONG, &s.id)); 642 let album_guid = mapping::guid(mapping::KIND_ALBUM, &s.album_id); 643 let artist_guid = mapping::guid(mapping::KIND_ARTIST, &s.artist_id); 644 let run_time_ticks = s.duration_ms * TICKS_PER_MS; 645 let user_data = build_user_data(state, &s.id, id.clone()).await; 646 647 let audio_stream = MediaStream { 648 codec: Some(s.suffix.clone()), 649 stream_type: "Audio", 650 index: 0, 651 is_default: true, 652 channels: Some(2), 653 sample_rate: None, 654 bit_rate: Some((s.bitrate * 1000) as i32), 655 video_range: "Unknown", 656 video_range_type: "Unknown", 657 audio_spatial_format: "None", 658 is_interlaced: false, 659 is_forced: false, 660 is_hearing_impaired: false, 661 is_original: false, 662 is_external: false, 663 is_text_subtitle_stream: false, 664 supports_external_stream: false, 665 ..Default::default() 666 }; 667 668 let media_source = MediaSource { 669 protocol: "File", 670 id: Some(id.clone()), 671 path: Some(s.path.clone()), 672 source_type: "Default", 673 container: Some(s.suffix.clone()), 674 size: Some(s.filesize), 675 name: Some(s.title.clone()), 676 is_remote: false, 677 run_time_ticks: Some(run_time_ticks), 678 read_at_native_framerate: false, 679 ignore_dts: false, 680 ignore_index: false, 681 gen_pts_input: false, 682 supports_transcoding: false, 683 supports_direct_stream: true, 684 supports_direct_play: true, 685 is_infinite_stream: false, 686 use_most_compatible_transcoding_profile: false, 687 requires_opening: false, 688 requires_closing: false, 689 requires_looping: false, 690 supports_probing: false, 691 media_streams: Some(vec![audio_stream.clone()]), 692 formats: None, 693 bitrate: Some((s.bitrate * 1000) as i32), 694 transcoding_sub_protocol: "http", 695 default_audio_stream_index: Some(0), 696 default_subtitle_stream_index: None, 697 has_segments: false, 698 }; 699 700 BaseItemDto { 701 id: id.clone(), 702 server_id: Some(state.server_id.clone()), 703 name: Some(s.title.clone()), 704 item_type: "Audio", 705 media_type: "Audio", 706 is_folder: Some(false), 707 production_year: s.year.map(|y| y as i32), 708 index_number: s.track_number.map(|n| n as i32), 709 parent_index_number: s.disc_number.map(|n| n as i32), 710 run_time_ticks: Some(run_time_ticks), 711 container: Some(s.suffix.clone()), 712 path: Some(s.path.clone()), 713 album: Some(s.album.clone()), 714 album_id: Some(album_guid.clone()), 715 album_artist: Some(s.artist.clone()), 716 album_artists: Some(vec![NameGuidPair { 717 name: Some(s.artist.clone()), 718 id: artist_guid.clone(), 719 }]), 720 artist_items: Some(vec![NameGuidPair { 721 name: Some(s.artist.clone()), 722 id: artist_guid.clone(), 723 }]), 724 artists: Some(vec![s.artist.clone()]), 725 parent_id: Some(album_guid), 726 genres: s.genre.as_ref().map(|g| vec![g.clone()]), 727 location_type: Some("FileSystem"), 728 media_sources: Some(vec![media_source.clone()]), 729 media_source_count: Some(1), 730 media_streams: Some(vec![audio_stream]), 731 image_tags: Some(ImageTags { 732 primary: s.cover_art.clone().or_else(|| Some(s.album_id.clone())), 733 }), 734 image_blur_hashes: Some(ImageBlurHashes::default()), 735 user_data: Some(user_data), 736 ..Default::default() 737 } 738} 739 740async fn playlist_to_dto(state: &JellyfinState, p: &Playlist) -> BaseItemDto { 741 let id = mapping::remember_playlist(&state.pool, p) 742 .await 743 .unwrap_or_else(|_| mapping::guid(mapping::KIND_PLAYLIST, &p.id)); 744 let songs = repo::playlist_songs(&state.pool, &p.id) 745 .await 746 .unwrap_or_default(); 747 let child_count = songs.len() as i32; 748 let run_time_ticks: i64 = songs.iter().map(|s| s.duration_ms * TICKS_PER_MS).sum(); 749 let user_data = build_user_data(state, &p.id, id.clone()).await; 750 BaseItemDto { 751 id: id.clone(), 752 server_id: Some(state.server_id.clone()), 753 name: Some(p.name.clone()), 754 item_type: "Playlist", 755 media_type: "Audio", 756 is_folder: Some(true), 757 collection_type: Some("playlist"), 758 location_type: Some("FileSystem"), 759 sort_name: Some(p.name.clone()), 760 date_created: Some(p.created_at.clone()), 761 overview: p.comment.clone(), 762 child_count: Some(child_count), 763 song_count: Some(child_count), 764 run_time_ticks: Some(run_time_ticks), 765 user_data: Some(user_data), 766 ..Default::default() 767 } 768} 769 770async fn video_to_dto(state: &JellyfinState, v: &Video) -> BaseItemDto { 771 let id = mapping::remember_video(&state.pool, v) 772 .await 773 .unwrap_or_else(|_| mapping::guid(mapping::KIND_VIDEO, &v.id)); 774 let run_time_ticks = v.duration_ms * TICKS_PER_MS; 775 let user_data = build_user_data(state, &v.id, id.clone()).await; 776 777 let video_stream = MediaStream { 778 codec: Some(v.container.clone()), 779 stream_type: "Video", 780 index: 0, 781 is_default: true, 782 channels: None, 783 sample_rate: None, 784 bit_rate: Some(v.bitrate as i32), 785 height: if v.height > 0 { 786 Some(v.height as i32) 787 } else { 788 None 789 }, 790 width: if v.width > 0 { 791 Some(v.width as i32) 792 } else { 793 None 794 }, 795 video_range: "SDR", 796 video_range_type: "SDR", 797 audio_spatial_format: "None", 798 is_interlaced: false, 799 is_forced: false, 800 is_hearing_impaired: false, 801 is_original: false, 802 is_external: false, 803 is_text_subtitle_stream: false, 804 supports_external_stream: false, 805 ..Default::default() 806 }; 807 808 let media_source = MediaSource { 809 protocol: "File", 810 id: Some(id.clone()), 811 path: Some(v.path.clone()), 812 source_type: "Default", 813 container: Some(v.container.clone()), 814 size: Some(v.filesize), 815 name: Some(v.title.clone()), 816 is_remote: false, 817 run_time_ticks: Some(run_time_ticks), 818 read_at_native_framerate: false, 819 ignore_dts: false, 820 ignore_index: false, 821 gen_pts_input: false, 822 supports_transcoding: false, 823 supports_direct_stream: true, 824 supports_direct_play: true, 825 is_infinite_stream: false, 826 use_most_compatible_transcoding_profile: false, 827 requires_opening: false, 828 requires_closing: false, 829 requires_looping: false, 830 supports_probing: true, 831 media_streams: Some(vec![video_stream.clone()]), 832 formats: None, 833 bitrate: Some(v.bitrate as i32), 834 transcoding_sub_protocol: "http", 835 default_audio_stream_index: None, 836 default_subtitle_stream_index: None, 837 has_segments: false, 838 }; 839 840 BaseItemDto { 841 id: id.clone(), 842 server_id: Some(state.server_id.clone()), 843 name: Some(v.title.clone()), 844 item_type: "Movie", 845 media_type: "Video", 846 is_folder: Some(false), 847 run_time_ticks: Some(run_time_ticks), 848 container: Some(v.container.clone()), 849 path: Some(v.path.clone()), 850 parent_id: Some(mapping::movies_library_guid()), 851 sort_name: Some(v.title.clone()), 852 height: if v.height > 0 { 853 Some(v.height as i32) 854 } else { 855 None 856 }, 857 width: if v.width > 0 { 858 Some(v.width as i32) 859 } else { 860 None 861 }, 862 location_type: Some("FileSystem"), 863 media_sources: Some(vec![media_source.clone()]), 864 media_source_count: Some(1), 865 media_streams: Some(vec![video_stream]), 866 image_tags: Some(ImageTags { 867 primary: v.poster_path.as_ref().map(|_| id.clone()), 868 }), 869 image_blur_hashes: Some(ImageBlurHashes::default()), 870 user_data: Some(user_data), 871 ..Default::default() 872 } 873} 874 875fn includes(types_csv: &Option<String>, want: &str) -> bool { 876 match types_csv { 877 None => false, 878 Some(s) => s.split(',').any(|p| p.trim().eq_ignore_ascii_case(want)), 879 } 880} 881 882/// Clients ask for the global video list in three different ways: 883/// `IncludeItemTypes=Movie` (Jellyfin web/Findroid), `IncludeItemTypes=Video` 884/// (some SDK consumers using the BaseItemKind enum), and `MediaTypes=Video` 885/// (clients filtering purely by media type). Treat all three as the same 886/// request so smolsonic actually returns videos instead of an artist list. 887fn wants_videos(q: &ItemsQuery) -> bool { 888 includes(&q.include_item_types, "Movie") 889 || includes(&q.include_item_types, "Video") 890 || includes(&q.media_types, "Video") 891} 892 893async fn resolve_native(state: &JellyfinState, guid: &str) -> Option<(String, String)> { 894 mapping::lookup(&state.pool, guid).await.ok().flatten() 895} 896 897pub async fn items( 898 _user: AuthedUser, 899 state: web::Data<JellyfinState>, 900 req: HttpRequest, 901) -> HttpResponse { 902 items_impl(state, parse_items_query(&req)).await 903} 904 905pub async fn user_items( 906 _user: AuthedUser, 907 state: web::Data<JellyfinState>, 908 _path: web::Path<String>, 909 req: HttpRequest, 910) -> HttpResponse { 911 items_impl(state, parse_items_query(&req)).await 912} 913 914async fn items_impl(state: web::Data<JellyfinState>, q: ItemsQuery) -> HttpResponse { 915 let library_id = mapping::library_guid(); 916 let movies_id = mapping::movies_library_guid(); 917 let playlists_id = mapping::playlists_library_guid(); 918 919 // Favorites-only listing — `?Filters=IsFavorite` or `?IsFavorite=true`, 920 // matching the Jellyfin OpenAPI spec's two ways of asking. This runs 921 // before *any* other dispatch (including the library-views fallback) 922 // because the reference client's "Favorites" home rail sends 923 // `?Filters=IsFavorite&Recursive=true` with no ParentId — without this 924 // short-circuit we'd return CollectionFolder tiles instead. 925 if wants_favorites_only(&q) { 926 return list_favorites(&state, &q).await; 927 } 928 929 // `/Items?userId=X` with no other filters is the "show me my libraries" 930 // call (real Jellyfin returns CollectionFolders here). Findroid's 931 // MediaViewModel uses this — without this branch we'd return the artist 932 // list, which Findroid then renders as a wall of empty tiles. 933 if q.parent_id.is_none() 934 && q.ids.is_none() 935 && q.search_term.is_none() 936 && q.include_item_types.is_none() 937 && q.media_types.is_none() 938 && q.album_artist_ids.is_none() 939 && q.artist_ids.is_none() 940 && q.album_ids.is_none() 941 { 942 let views = all_library_views(&state); 943 let total = views.len() as i32; 944 return HttpResponse::Ok().json(ItemsResult { 945 items: views, 946 total_record_count: total, 947 start_index: 0, 948 }); 949 } 950 951 // Free-text search (`/Items?searchTerm=...`). Findroid restricts to 952 // `Movie`/`Series` types and expects a video result; Finamp restricts to 953 // `MusicArtist`/`MusicAlbum`/`Audio`. We honour whichever the client 954 // requested; an unfiltered call searches everything. 955 if let Some(term) = q.search_term.as_deref().filter(|s| !s.is_empty()) { 956 let limit = q.limit.unwrap_or(50).max(1); 957 let no_filter = q.include_item_types.is_none(); 958 let mut dtos: Vec<BaseItemDto> = Vec::new(); 959 let ts = state.typesense.as_deref(); 960 if no_filter || includes(&q.include_item_types, "MusicArtist") { 961 if let Ok(rows) = repo::search_artists(&state.pool, term, limit, 0, ts).await { 962 for a in rows { 963 dtos.push(artist_to_dto(&state, &a).await); 964 } 965 } 966 } 967 if no_filter || includes(&q.include_item_types, "MusicAlbum") { 968 if let Ok(rows) = repo::search_albums(&state.pool, term, limit, 0, ts).await { 969 for a in rows { 970 dtos.push(album_to_dto(&state, &a).await); 971 } 972 } 973 } 974 if no_filter || includes(&q.include_item_types, "Audio") { 975 if let Ok(rows) = repo::search_songs(&state.pool, term, limit, 0, ts).await { 976 for s in rows { 977 dtos.push(song_to_dto(&state, &s).await); 978 } 979 } 980 } 981 if no_filter || wants_videos(&q) { 982 if let Ok(rows) = repo::search_videos(&state.pool, term, limit, 0).await { 983 for v in rows { 984 dtos.push(video_to_dto(&state, &v).await); 985 } 986 } 987 } 988 // "Series"/"Episode" requested → nothing to return; smolsonic has no 989 // series concept, so we just don't add anything. Returning an empty 990 // result for an unsupported type is correct per spec. 991 let total = dtos.len() as i32; 992 return HttpResponse::Ok().json(ItemsResult { 993 items: dtos, 994 total_record_count: total, 995 start_index: 0, 996 }); 997 } 998 999 // Explicit Ids= lookup wins (single or comma-separated). 1000 if let Some(ids) = q.ids.as_ref() { 1001 let mut out: Vec<BaseItemDto> = Vec::new(); 1002 for raw in ids.split(',') { 1003 let g = mapping::normalize_guid(raw.trim()); 1004 if let Some((kind, native)) = resolve_native(&state, &g).await { 1005 match kind.as_str() { 1006 "song" => { 1007 if let Ok(Some(s)) = repo::find_song(&state.pool, &native).await { 1008 out.push(song_to_dto(&state, &s).await); 1009 } 1010 } 1011 "album" => { 1012 if let Ok(Some(a)) = repo::find_album(&state.pool, &native).await { 1013 out.push(album_to_dto(&state, &a).await); 1014 } 1015 } 1016 "artist" => { 1017 if let Ok(Some(a)) = repo::find_artist(&state.pool, &native).await { 1018 out.push(artist_to_dto(&state, &a).await); 1019 } 1020 } 1021 "video" => { 1022 if let Ok(Some(v)) = repo::find_video(&state.pool, &native).await { 1023 out.push(video_to_dto(&state, &v).await); 1024 } 1025 } 1026 "playlist" => { 1027 if let Ok(Some(p)) = repo::find_playlist(&state.pool, &native).await { 1028 out.push(playlist_to_dto(&state, &p).await); 1029 } 1030 } 1031 _ => {} 1032 } 1033 } 1034 } 1035 let total = out.len() as i32; 1036 return HttpResponse::Ok().json(ItemsResult { 1037 items: out, 1038 total_record_count: total, 1039 start_index: 0, 1040 }); 1041 } 1042 1043 // Filter by parent FIRST — even when include_item_types includes "Movie", 1044 // a parent restricts the scope. Without this, a Findroid click on an 1045 // artist (with `&includeItemTypes=Folder&includeItemTypes=Movie&includeItemTypes=Series`) 1046 // would return every movie in the library instead of "no Movie children 1047 // of this artist". 1048 if let Some(parent) = q.parent_id.as_ref() { 1049 let g = mapping::normalize_guid(parent); 1050 if g == library_id { 1051 // Top-level inside the music library — list artists by default, albums if requested. 1052 return list_artists_or_albums(&state, &q).await; 1053 } 1054 if g == playlists_id { 1055 return list_playlists(&state, &q).await; 1056 } 1057 if g == movies_id { 1058 return list_movies(&state, &q).await; 1059 } 1060 if let Some((kind, native)) = resolve_native(&state, &g).await { 1061 match kind.as_str() { 1062 "artist" => { 1063 let albums = repo::albums_by_artist(&state.pool, &native) 1064 .await 1065 .unwrap_or_default(); 1066 let mut dtos = Vec::with_capacity(albums.len()); 1067 for a in &albums { 1068 dtos.push(album_to_dto(&state, a).await); 1069 } 1070 let total = dtos.len() as i32; 1071 return HttpResponse::Ok().json(ItemsResult { 1072 items: dtos, 1073 total_record_count: total, 1074 start_index: 0, 1075 }); 1076 } 1077 "album" => { 1078 let songs = repo::songs_by_album(&state.pool, &native) 1079 .await 1080 .unwrap_or_default(); 1081 let mut dtos = Vec::with_capacity(songs.len()); 1082 for s in &songs { 1083 dtos.push(song_to_dto(&state, s).await); 1084 } 1085 let total = dtos.len() as i32; 1086 return HttpResponse::Ok().json(ItemsResult { 1087 items: dtos, 1088 total_record_count: total, 1089 start_index: 0, 1090 }); 1091 } 1092 "genre" => { 1093 // Drill-down from `/Genres` tap. `native` is the exact- 1094 // cased genre name (we stored it in `jf_guids.native_id`). 1095 let limit = q.limit.unwrap_or(500).max(1); 1096 let offset = q.start_index.unwrap_or(0).max(0); 1097 let songs = repo::songs_by_genre(&state.pool, &native, limit, offset) 1098 .await 1099 .unwrap_or_default(); 1100 // If the client asked specifically for albums, dedupe 1101 // song rows down to their albums. 1102 if includes(&q.include_item_types, "MusicAlbum") { 1103 let mut seen: std::collections::HashSet<String> = 1104 std::collections::HashSet::new(); 1105 let mut dtos: Vec<BaseItemDto> = Vec::new(); 1106 for s in &songs { 1107 if seen.insert(s.album_id.clone()) { 1108 if let Ok(Some(al)) = 1109 repo::find_album(&state.pool, &s.album_id).await 1110 { 1111 dtos.push(album_to_dto(&state, &al).await); 1112 } 1113 } 1114 } 1115 let total = dtos.len() as i32; 1116 return HttpResponse::Ok().json(ItemsResult { 1117 items: dtos, 1118 total_record_count: total, 1119 start_index: offset as i32, 1120 }); 1121 } 1122 let mut dtos = Vec::with_capacity(songs.len()); 1123 for s in &songs { 1124 dtos.push(song_to_dto(&state, s).await); 1125 } 1126 let total = dtos.len() as i32; 1127 return HttpResponse::Ok().json(ItemsResult { 1128 items: dtos, 1129 total_record_count: total, 1130 start_index: offset as i32, 1131 }); 1132 } 1133 "year" => { 1134 // Drill-down from `/Years` tap. `native` is the year 1135 // string (we stored it in `jf_guids.native_id`). 1136 let Ok(year) = native.parse::<i32>() else { 1137 return HttpResponse::NotFound().finish(); 1138 }; 1139 // Albums variant — Findroid's "albums by year" tile. 1140 if includes(&q.include_item_types, "MusicAlbum") { 1141 let albums = repo::albums_by_year(&state.pool, year) 1142 .await 1143 .unwrap_or_default(); 1144 let mut dtos = Vec::with_capacity(albums.len()); 1145 for a in &albums { 1146 dtos.push(album_to_dto(&state, a).await); 1147 } 1148 let total = dtos.len() as i32; 1149 return HttpResponse::Ok().json(ItemsResult { 1150 items: dtos, 1151 total_record_count: total, 1152 start_index: 0, 1153 }); 1154 } 1155 let limit = q.limit.unwrap_or(500).max(1); 1156 let offset = q.start_index.unwrap_or(0).max(0); 1157 let songs = repo::songs_by_year(&state.pool, year, limit, offset) 1158 .await 1159 .unwrap_or_default(); 1160 let mut dtos = Vec::with_capacity(songs.len()); 1161 for s in &songs { 1162 dtos.push(song_to_dto(&state, s).await); 1163 } 1164 let total = dtos.len() as i32; 1165 return HttpResponse::Ok().json(ItemsResult { 1166 items: dtos, 1167 total_record_count: total, 1168 start_index: offset as i32, 1169 }); 1170 } 1171 _ => {} 1172 } 1173 } 1174 // parent_id was set but didn't resolve to anything meaningful 1175 // (e.g. a Findroid query for /Items?parentId=<artist>&includeItemTypes=Folder|Movie|Series 1176 // — none of those types exist inside a MusicArtist). Returning empty 1177 // is correct; falling through would dump the whole artist list. 1178 return HttpResponse::Ok().json(ItemsResult { 1179 items: vec![], 1180 total_record_count: 0, 1181 start_index: 0, 1182 }); 1183 } 1184 1185 // No parent: filter by item type. Global movie list for any of 1186 // `?IncludeItemTypes=Movie`, `?IncludeItemTypes=Video`, or `?MediaTypes=Video`. 1187 if wants_videos(&q) { 1188 return list_movies(&state, &q).await; 1189 } 1190 1191 // Playlists as items — `?includeItemTypes=Playlist` (with or without 1192 // `Recursive=true`). Real Jellyfin surfaces user-owned playlists through 1193 // the general Items query as well as via `/Playlists`. 1194 if includes(&q.include_item_types, "Playlist") { 1195 return list_playlists(&state, &q).await; 1196 } 1197 1198 // No parent: filter by AlbumArtistIds / ArtistIds (album or song 1199 // browsing for an artist — Amcfy's "artist detail" page does this). 1200 let artist_filter = q.album_artist_ids.clone().or_else(|| q.artist_ids.clone()); 1201 1202 // Songs by artist — Amcfy/Symfonium: `?artistIds=<X>&includeItemTypes=Audio`. 1203 if includes(&q.include_item_types, "Audio") { 1204 if let Some(filter) = artist_filter.clone() { 1205 if let Some(first) = filter.split(',').next() { 1206 let g = mapping::normalize_guid(first); 1207 if let Some((kind, native)) = resolve_native(&state, &g).await { 1208 if kind == "artist" { 1209 let limit = q.limit.unwrap_or(500).max(1); 1210 let offset = q.start_index.unwrap_or(0).max(0); 1211 let songs = repo::songs_by_artist(&state.pool, &native, limit, offset) 1212 .await 1213 .unwrap_or_default(); 1214 let mut dtos = Vec::with_capacity(songs.len()); 1215 for s in &songs { 1216 dtos.push(song_to_dto(&state, s).await); 1217 } 1218 let total = dtos.len() as i32; 1219 return HttpResponse::Ok().json(ItemsResult { 1220 items: dtos, 1221 total_record_count: total, 1222 start_index: offset as i32, 1223 }); 1224 } 1225 } 1226 } 1227 } 1228 } 1229 1230 // Plain "all songs" — `?includeItemTypes=Audio` with no artist/parent. 1231 // Honours `NameStartsWith` etc. for Finamp's alpha-jump rail. 1232 if includes(&q.include_item_types, "Audio") { 1233 let limit = q.limit.unwrap_or(100).max(1); 1234 let offset = q.start_index.unwrap_or(0).max(0); 1235 let starts = q.name_starts_with.as_deref(); 1236 let geq = q.name_starts_with_or_greater.as_deref(); 1237 let lt = q.name_less_than.as_deref(); 1238 let songs = repo::songs_filtered(&state.pool, starts, geq, lt, limit, offset) 1239 .await 1240 .unwrap_or_default(); 1241 let total = repo::count_songs_filtered(&state.pool, starts, geq, lt) 1242 .await 1243 .unwrap_or(0) as i32; 1244 let mut dtos = Vec::with_capacity(songs.len()); 1245 for s in &songs { 1246 dtos.push(song_to_dto(&state, s).await); 1247 } 1248 return HttpResponse::Ok().json(ItemsResult { 1249 items: dtos, 1250 total_record_count: total, 1251 start_index: offset as i32, 1252 }); 1253 } 1254 1255 if includes(&q.include_item_types, "MusicAlbum") { 1256 if let Some(filter) = artist_filter { 1257 if let Some(first) = filter.split(',').next() { 1258 let g = mapping::normalize_guid(first); 1259 if let Some((kind, native)) = resolve_native(&state, &g).await { 1260 if kind == "artist" { 1261 let albums = repo::albums_by_artist(&state.pool, &native) 1262 .await 1263 .unwrap_or_default(); 1264 let mut dtos = Vec::with_capacity(albums.len()); 1265 for a in &albums { 1266 dtos.push(album_to_dto(&state, a).await); 1267 } 1268 let total = dtos.len() as i32; 1269 return HttpResponse::Ok().json(ItemsResult { 1270 items: dtos, 1271 total_record_count: total, 1272 start_index: 0, 1273 }); 1274 } 1275 } 1276 } 1277 } 1278 // No artist filter → paginated all-albums, honouring NameStartsWith. 1279 let limit = q.limit.unwrap_or(100).max(1); 1280 let offset = q.start_index.unwrap_or(0).max(0); 1281 let starts = q.name_starts_with.as_deref(); 1282 let geq = q.name_starts_with_or_greater.as_deref(); 1283 let lt = q.name_less_than.as_deref(); 1284 let albums = repo::albums_filtered(&state.pool, starts, geq, lt, limit, offset) 1285 .await 1286 .unwrap_or_default(); 1287 let total = repo::count_albums_filtered(&state.pool, starts, geq, lt) 1288 .await 1289 .unwrap_or(0) as i32; 1290 let mut dtos = Vec::with_capacity(albums.len()); 1291 for a in &albums { 1292 dtos.push(album_to_dto(&state, a).await); 1293 } 1294 return HttpResponse::Ok().json(ItemsResult { 1295 items: dtos, 1296 total_record_count: total, 1297 start_index: offset as i32, 1298 }); 1299 } 1300 1301 list_artists_or_albums(&state, &q).await 1302} 1303 1304async fn list_movies(state: &JellyfinState, q: &ItemsQuery) -> HttpResponse { 1305 let limit = q.limit.unwrap_or(100).max(1); 1306 let offset = q.start_index.unwrap_or(0).max(0); 1307 let starts = q.name_starts_with.as_deref(); 1308 let geq = q.name_starts_with_or_greater.as_deref(); 1309 let lt = q.name_less_than.as_deref(); 1310 let videos = repo::videos_filtered(&state.pool, starts, geq, lt, limit, offset) 1311 .await 1312 .unwrap_or_default(); 1313 let total = repo::count_videos_filtered(&state.pool, starts, geq, lt) 1314 .await 1315 .unwrap_or_default(); 1316 let mut dtos = Vec::with_capacity(videos.len()); 1317 for v in &videos { 1318 dtos.push(video_to_dto(state, v).await); 1319 } 1320 HttpResponse::Ok().json(ItemsResult { 1321 items: dtos, 1322 total_record_count: total as i32, 1323 start_index: offset as i32, 1324 }) 1325} 1326 1327/// `/Items?Filters=IsFavorite&IncludeItemTypes=…` — favourites-only listing. 1328/// 1329/// If no type filter is set the response mixes artists, albums, songs, videos 1330/// and playlists (that's what the reference Jellyfin server does for the 1331/// home-screen Favorites rail). When a client narrows via `IncludeItemTypes` 1332/// we return just that slice. Ordering follows `starred.starred_at DESC` — 1333/// most-recently-favorited first — which matches how the reference client 1334/// renders the rail. 1335async fn list_favorites(state: &JellyfinState, q: &ItemsQuery) -> HttpResponse { 1336 let type_filter = q.include_item_types.as_deref(); 1337 let all_types = type_filter.is_none(); 1338 1339 let want_artist = all_types 1340 || includes(&q.include_item_types, "MusicArtist") 1341 || includes(&q.include_item_types, "AlbumArtist"); 1342 let want_album = all_types || includes(&q.include_item_types, "MusicAlbum"); 1343 let want_song = all_types || includes(&q.include_item_types, "Audio"); 1344 let want_video = all_types || wants_videos(q); 1345 let want_playlist = all_types || includes(&q.include_item_types, "Playlist"); 1346 1347 let mut dtos: Vec<BaseItemDto> = Vec::new(); 1348 1349 if want_artist { 1350 if let Ok(rows) = repo::starred_artists(&state.pool).await { 1351 for (a, _when) in rows { 1352 dtos.push(artist_to_dto(state, &a).await); 1353 } 1354 } 1355 } 1356 if want_album { 1357 if let Ok(rows) = repo::starred_albums(&state.pool).await { 1358 for (a, _when) in rows { 1359 dtos.push(album_to_dto(state, &a).await); 1360 } 1361 } 1362 } 1363 if want_song { 1364 if let Ok(rows) = repo::starred_songs(&state.pool).await { 1365 for (s, _when) in rows { 1366 dtos.push(song_to_dto(state, &s).await); 1367 } 1368 } 1369 } 1370 if want_video { 1371 if let Ok(rows) = repo::starred_videos(&state.pool).await { 1372 for (v, _when) in rows { 1373 dtos.push(video_to_dto(state, &v).await); 1374 } 1375 } 1376 } 1377 if want_playlist { 1378 if let Ok(rows) = repo::starred_playlists(&state.pool).await { 1379 for (p, _when) in rows { 1380 dtos.push(playlist_to_dto(state, &p).await); 1381 } 1382 } 1383 } 1384 1385 let total = dtos.len() as i32; 1386 // Honour `StartIndex` / `Limit` after the union — the caller sees a single 1387 // paginated list, not per-type slices. 1388 let start = q.start_index.unwrap_or(0).max(0) as usize; 1389 let limit = q.limit.unwrap_or(500).max(1) as usize; 1390 let items: Vec<BaseItemDto> = dtos.into_iter().skip(start).take(limit).collect(); 1391 HttpResponse::Ok().json(ItemsResult { 1392 items, 1393 total_record_count: total, 1394 start_index: start as i32, 1395 }) 1396} 1397 1398// ── Playlists ──────────────────────────────────────────────────────────────── 1399 1400/// Filter `playlists` in-memory by `NameStartsWith` / `NameStartsWithOrGreater` 1401/// / `NameLessThan`. Playlists live in a small table so the extra pass is 1402/// cheap and keeps the repo API flat. 1403fn filter_playlists_by_query(playlists: Vec<Playlist>, q: &ItemsQuery) -> Vec<Playlist> { 1404 playlists 1405 .into_iter() 1406 .filter(|p| { 1407 let name = p.name.to_lowercase(); 1408 if let Some(s) = q.name_starts_with.as_deref() { 1409 if !name.starts_with(&s.to_lowercase()) { 1410 return false; 1411 } 1412 } 1413 if let Some(s) = q.name_starts_with_or_greater.as_deref() { 1414 if name.as_str() < s.to_lowercase().as_str() { 1415 return false; 1416 } 1417 } 1418 if let Some(s) = q.name_less_than.as_deref() { 1419 if name.as_str() >= s.to_lowercase().as_str() { 1420 return false; 1421 } 1422 } 1423 true 1424 }) 1425 .collect() 1426} 1427 1428async fn list_playlists(state: &JellyfinState, q: &ItemsQuery) -> HttpResponse { 1429 let playlists = repo::all_playlists(&state.pool).await.unwrap_or_default(); 1430 let playlists = filter_playlists_by_query(playlists, q); 1431 let total = playlists.len() as i32; 1432 let start = q.start_index.unwrap_or(0).max(0) as usize; 1433 let limit = q.limit.unwrap_or(500).max(1) as usize; 1434 let slice: Vec<&Playlist> = playlists.iter().skip(start).take(limit).collect(); 1435 let mut dtos = Vec::with_capacity(slice.len()); 1436 for p in slice { 1437 dtos.push(playlist_to_dto(state, p).await); 1438 } 1439 HttpResponse::Ok().json(ItemsResult { 1440 items: dtos, 1441 total_record_count: total, 1442 start_index: start as i32, 1443 }) 1444} 1445 1446/// Convert a `PlaylistItemId` (our synthesized entry GUID) back to a 1447/// 0-based position inside the playlist. Falls back to accepting a raw 1448/// integer position or a plain song GUID. 1449async fn entry_id_to_position( 1450 state: &JellyfinState, 1451 playlist_native: &str, 1452 entry_id: &str, 1453) -> Option<i64> { 1454 let normalized = mapping::normalize_guid(entry_id); 1455 1456 let song_ids = repo::playlist_song_ids(&state.pool, playlist_native) 1457 .await 1458 .ok()?; 1459 for (pos, sid) in song_ids.iter().enumerate() { 1460 let expected = mapping::playlist_entry_guid(playlist_native, pos as i64); 1461 if mapping::normalize_guid(&expected) == normalized { 1462 return Some(pos as i64); 1463 } 1464 // Some clients pass the song's own GUID as the entry id. 1465 let song_guid = mapping::guid(mapping::KIND_SONG, sid); 1466 if mapping::normalize_guid(&song_guid) == normalized { 1467 return Some(pos as i64); 1468 } 1469 } 1470 entry_id.parse::<i64>().ok() 1471} 1472 1473/// Resolve a comma-separated list of song GUIDs to native song ids. Skips 1474/// GUIDs that don't map to a song so callers can silently ignore garbage. 1475async fn resolve_song_native_ids(state: &JellyfinState, ids_csv: &str) -> Vec<String> { 1476 let mut out = Vec::new(); 1477 for raw in ids_csv.split(',') { 1478 let trimmed = raw.trim(); 1479 if trimmed.is_empty() { 1480 continue; 1481 } 1482 let g = mapping::normalize_guid(trimmed); 1483 if let Some((kind, native)) = resolve_native(state, &g).await { 1484 if kind == "song" { 1485 out.push(native); 1486 } 1487 } 1488 } 1489 out 1490} 1491 1492fn new_playlist_native_id() -> String { 1493 format!("pl-{}", auth::random_hex(8)) 1494} 1495 1496/// `GET /Playlists` — some clients probe this to enumerate playlists in the 1497/// same shape as `/Items`. Real Jellyfin has no such endpoint (playlists are 1498/// surfaced through `/Items?IncludeItemTypes=Playlist`), but returning the 1499/// list here keeps 3rd-party clients happy. 1500pub async fn playlists_list( 1501 _user: AuthedUser, 1502 state: web::Data<JellyfinState>, 1503 req: HttpRequest, 1504) -> HttpResponse { 1505 let q = parse_items_query(&req); 1506 list_playlists(&state, &q).await 1507} 1508 1509/// `POST /Playlists` — `CreatePlaylistDto` body OR query params. Response is 1510/// `PlaylistCreationResult { Id }`. 1511pub async fn create_playlist_endpoint( 1512 _user: AuthedUser, 1513 state: web::Data<JellyfinState>, 1514 req: HttpRequest, 1515 body: Option<web::Json<Value>>, 1516) -> HttpResponse { 1517 let query = collect_query(&req); 1518 let q_one = |k: &str| { 1519 query 1520 .get(k) 1521 .and_then(|v| v.first()) 1522 .cloned() 1523 .filter(|s| !s.is_empty()) 1524 }; 1525 let q_ids = || -> Vec<String> { 1526 query 1527 .get("ids") 1528 .or_else(|| query.get("Ids")) 1529 .cloned() 1530 .unwrap_or_default() 1531 .into_iter() 1532 .flat_map(|s| { 1533 s.split(',') 1534 .map(|p| p.trim().to_string()) 1535 .collect::<Vec<_>>() 1536 }) 1537 .filter(|s| !s.is_empty()) 1538 .collect() 1539 }; 1540 1541 let body_val = body.map(|b| b.into_inner()); 1542 1543 let name = body_val 1544 .as_ref() 1545 .and_then(|v| v.get("Name").and_then(|n| n.as_str()).map(String::from)) 1546 .or_else(|| q_one("name")) 1547 .or_else(|| q_one("Name")) 1548 .unwrap_or_else(|| "New Playlist".to_string()); 1549 1550 let body_ids: Vec<String> = body_val 1551 .as_ref() 1552 .and_then(|v| v.get("Ids").and_then(|a| a.as_array())) 1553 .map(|arr| { 1554 arr.iter() 1555 .filter_map(|v| v.as_str().map(String::from)) 1556 .collect() 1557 }) 1558 .unwrap_or_default(); 1559 let all_ids: Vec<String> = if body_ids.is_empty() { 1560 q_ids() 1561 } else { 1562 body_ids 1563 }; 1564 let song_ids = resolve_song_native_ids(&state, &all_ids.join(",")).await; 1565 1566 let native = new_playlist_native_id(); 1567 let now = now_iso(); 1568 if let Err(e) = repo::create_playlist(&state.pool, &native, &name, &now).await { 1569 tracing::error!("jellyfin: create_playlist: {e}"); 1570 return HttpResponse::InternalServerError().finish(); 1571 } 1572 if !song_ids.is_empty() { 1573 if let Err(e) = repo::append_playlist_songs(&state.pool, &native, &song_ids).await { 1574 tracing::error!("jellyfin: append_playlist_songs: {e}"); 1575 } 1576 } 1577 let guid = mapping::guid(mapping::KIND_PLAYLIST, &native); 1578 // Best-effort — the id is deterministic so a failed remember() just costs 1579 // us one lookup on the next request. 1580 let _ = mapping::remember(&state.pool, mapping::KIND_PLAYLIST, &native).await; 1581 HttpResponse::Ok().json(PlaylistCreationResult { id: guid }) 1582} 1583 1584/// `GET /Playlists/{id}` — playlist metadata as a `BaseItemDto`. 1585pub async fn get_playlist_endpoint( 1586 _user: AuthedUser, 1587 state: web::Data<JellyfinState>, 1588 path: web::Path<String>, 1589) -> HttpResponse { 1590 let g = mapping::normalize_guid(&path.into_inner()); 1591 let Some((kind, native)) = resolve_native(&state, &g).await else { 1592 return HttpResponse::NotFound().finish(); 1593 }; 1594 if kind != "playlist" { 1595 return HttpResponse::NotFound().finish(); 1596 } 1597 match repo::find_playlist(&state.pool, &native).await { 1598 Ok(Some(p)) => HttpResponse::Ok().json(playlist_to_dto(&state, &p).await), 1599 _ => HttpResponse::NotFound().finish(), 1600 } 1601} 1602 1603/// `POST /Playlists/{id}` — `UpdatePlaylistDto` body. Supports rename and 1604/// full-replace of the item list; other fields (Users, IsPublic) are 1605/// accepted and ignored because smolsonic has a single-user model. 1606pub async fn update_playlist_endpoint( 1607 _user: AuthedUser, 1608 state: web::Data<JellyfinState>, 1609 path: web::Path<String>, 1610 body: Option<web::Json<Value>>, 1611) -> HttpResponse { 1612 let g = mapping::normalize_guid(&path.into_inner()); 1613 let Some((kind, native)) = resolve_native(&state, &g).await else { 1614 return HttpResponse::NotFound().finish(); 1615 }; 1616 if kind != "playlist" { 1617 return HttpResponse::NotFound().finish(); 1618 } 1619 let body = match body { 1620 Some(b) => b.into_inner(), 1621 None => return HttpResponse::NoContent().finish(), 1622 }; 1623 let now = now_iso(); 1624 if let Some(name) = body.get("Name").and_then(|n| n.as_str()) { 1625 if !name.is_empty() { 1626 let _ = repo::rename_playlist(&state.pool, &native, name, &now).await; 1627 } 1628 } 1629 if let Some(ids) = body.get("Ids").and_then(|a| a.as_array()) { 1630 let csv: String = ids 1631 .iter() 1632 .filter_map(|v| v.as_str()) 1633 .collect::<Vec<_>>() 1634 .join(","); 1635 let song_ids = resolve_song_native_ids(&state, &csv).await; 1636 let _ = repo::replace_playlist_songs(&state.pool, &native, &song_ids).await; 1637 } 1638 HttpResponse::NoContent().finish() 1639} 1640 1641/// `GET /Playlists/{id}/Items` — `BaseItemDtoQueryResult` of the playlist's 1642/// songs, in order. Each song DTO carries a `PlaylistItemId` so clients can 1643/// reference specific entries for remove/move. 1644pub async fn playlist_items( 1645 _user: AuthedUser, 1646 state: web::Data<JellyfinState>, 1647 path: web::Path<String>, 1648 req: HttpRequest, 1649) -> HttpResponse { 1650 let g = mapping::normalize_guid(&path.into_inner()); 1651 let Some((kind, native)) = resolve_native(&state, &g).await else { 1652 return HttpResponse::NotFound().finish(); 1653 }; 1654 if kind != "playlist" { 1655 return HttpResponse::NotFound().finish(); 1656 } 1657 let songs = repo::playlist_songs(&state.pool, &native) 1658 .await 1659 .unwrap_or_default(); 1660 let total = songs.len() as i32; 1661 let q = parse_items_query(&req); 1662 let start = q.start_index.unwrap_or(0).max(0) as usize; 1663 let limit = q.limit.unwrap_or(500).max(1) as usize; 1664 1665 let mut dtos: Vec<BaseItemDto> = Vec::new(); 1666 for (pos, s) in songs.iter().enumerate().skip(start).take(limit) { 1667 let mut dto = song_to_dto(&state, s).await; 1668 dto.playlist_item_id = Some(mapping::playlist_entry_guid(&native, pos as i64)); 1669 dtos.push(dto); 1670 } 1671 HttpResponse::Ok().json(ItemsResult { 1672 items: dtos, 1673 total_record_count: total, 1674 start_index: start as i32, 1675 }) 1676} 1677 1678/// `POST /Playlists/{id}/Items?ids=...` — append songs at the end. 1679pub async fn add_playlist_items( 1680 _user: AuthedUser, 1681 state: web::Data<JellyfinState>, 1682 path: web::Path<String>, 1683 req: HttpRequest, 1684) -> HttpResponse { 1685 let g = mapping::normalize_guid(&path.into_inner()); 1686 let Some((kind, native)) = resolve_native(&state, &g).await else { 1687 return HttpResponse::NotFound().finish(); 1688 }; 1689 if kind != "playlist" { 1690 return HttpResponse::NotFound().finish(); 1691 } 1692 let query = collect_query(&req); 1693 let ids_csv = query 1694 .get("ids") 1695 .or_else(|| query.get("Ids")) 1696 .cloned() 1697 .unwrap_or_default() 1698 .join(","); 1699 let song_ids = resolve_song_native_ids(&state, &ids_csv).await; 1700 if !song_ids.is_empty() { 1701 let _ = repo::append_playlist_songs(&state.pool, &native, &song_ids).await; 1702 if let Ok(Some(p)) = repo::find_playlist(&state.pool, &native).await { 1703 let _ = repo::rename_playlist(&state.pool, &native, &p.name, &now_iso()).await; 1704 } 1705 } 1706 HttpResponse::NoContent().finish() 1707} 1708 1709/// `DELETE /Playlists/{id}/Items?entryIds=...` — remove one or more 1710/// entries, identified by `PlaylistItemId` (or as a fallback, the raw song 1711/// GUID or 0-based position). 1712pub async fn remove_playlist_items( 1713 _user: AuthedUser, 1714 state: web::Data<JellyfinState>, 1715 path: web::Path<String>, 1716 req: HttpRequest, 1717) -> HttpResponse { 1718 let g = mapping::normalize_guid(&path.into_inner()); 1719 let Some((kind, native)) = resolve_native(&state, &g).await else { 1720 return HttpResponse::NotFound().finish(); 1721 }; 1722 if kind != "playlist" { 1723 return HttpResponse::NotFound().finish(); 1724 } 1725 let query = collect_query(&req); 1726 let entry_ids: Vec<String> = query 1727 .get("entryIds") 1728 .or_else(|| query.get("EntryIds")) 1729 .cloned() 1730 .unwrap_or_default() 1731 .into_iter() 1732 .flat_map(|s| { 1733 s.split(',') 1734 .map(|p| p.trim().to_string()) 1735 .collect::<Vec<_>>() 1736 }) 1737 .filter(|s| !s.is_empty()) 1738 .collect(); 1739 1740 if entry_ids.is_empty() { 1741 return HttpResponse::NoContent().finish(); 1742 } 1743 1744 let current = repo::playlist_song_ids(&state.pool, &native) 1745 .await 1746 .unwrap_or_default(); 1747 let mut positions_to_remove: std::collections::BTreeSet<i64> = 1748 std::collections::BTreeSet::new(); 1749 for eid in &entry_ids { 1750 if let Some(pos) = entry_id_to_position(&state, &native, eid).await { 1751 positions_to_remove.insert(pos); 1752 } 1753 } 1754 let kept: Vec<String> = current 1755 .into_iter() 1756 .enumerate() 1757 .filter(|(i, _)| !positions_to_remove.contains(&(*i as i64))) 1758 .map(|(_, sid)| sid) 1759 .collect(); 1760 if let Err(e) = repo::replace_playlist_songs(&state.pool, &native, &kept).await { 1761 tracing::error!("jellyfin: remove_playlist_items: {e}"); 1762 return HttpResponse::InternalServerError().finish(); 1763 } 1764 let now = now_iso(); 1765 if let Ok(Some(p)) = repo::find_playlist(&state.pool, &native).await { 1766 let _ = repo::rename_playlist(&state.pool, &native, &p.name, &now).await; 1767 } 1768 HttpResponse::NoContent().finish() 1769} 1770 1771/// `POST /Playlists/{id}/Items/{itemId}/Move/{newIndex}` — move `itemId` 1772/// (a `PlaylistItemId`) to `newIndex`, shifting the neighbours. 1773pub async fn move_playlist_item( 1774 _user: AuthedUser, 1775 state: web::Data<JellyfinState>, 1776 path: web::Path<(String, String, i64)>, 1777) -> HttpResponse { 1778 let (playlist_id, item_id, new_index) = path.into_inner(); 1779 let g = mapping::normalize_guid(&playlist_id); 1780 let Some((kind, native)) = resolve_native(&state, &g).await else { 1781 return HttpResponse::NotFound().finish(); 1782 }; 1783 if kind != "playlist" { 1784 return HttpResponse::NotFound().finish(); 1785 } 1786 let Some(from) = entry_id_to_position(&state, &native, &item_id).await else { 1787 return HttpResponse::NotFound().finish(); 1788 }; 1789 let mut current = repo::playlist_song_ids(&state.pool, &native) 1790 .await 1791 .unwrap_or_default(); 1792 if from < 0 || (from as usize) >= current.len() { 1793 return HttpResponse::BadRequest().finish(); 1794 } 1795 let target = new_index.max(0).min(current.len() as i64 - 1) as usize; 1796 let song = current.remove(from as usize); 1797 current.insert(target, song); 1798 if let Err(e) = repo::replace_playlist_songs(&state.pool, &native, &current).await { 1799 tracing::error!("jellyfin: move_playlist_item: {e}"); 1800 return HttpResponse::InternalServerError().finish(); 1801 } 1802 let now = now_iso(); 1803 if let Ok(Some(p)) = repo::find_playlist(&state.pool, &native).await { 1804 let _ = repo::rename_playlist(&state.pool, &native, &p.name, &now).await; 1805 } 1806 HttpResponse::NoContent().finish() 1807} 1808 1809/// `GET /Playlists/{id}/Users` — smolsonic is single-user, so this is always 1810/// an empty list. Clients that check permissions before showing the "add 1811/// user" UI silently hide it when this is empty, which is the desired 1812/// behaviour here. 1813pub async fn playlist_users( 1814 _user: AuthedUser, 1815 state: web::Data<JellyfinState>, 1816 path: web::Path<String>, 1817) -> HttpResponse { 1818 let g = mapping::normalize_guid(&path.into_inner()); 1819 let Some((kind, _native)) = resolve_native(&state, &g).await else { 1820 return HttpResponse::NotFound().finish(); 1821 }; 1822 if kind != "playlist" { 1823 return HttpResponse::NotFound().finish(); 1824 } 1825 HttpResponse::Ok().json(Vec::<Value>::new()) 1826} 1827 1828/// `DELETE /Items/{id}` — Jellyfin uses this to delete playlists (playlists 1829/// ARE items). We only allow it for playlists; deleting songs/albums via the 1830/// API is not supported. 1831pub async fn delete_item( 1832 _user: AuthedUser, 1833 state: web::Data<JellyfinState>, 1834 path: web::Path<String>, 1835) -> HttpResponse { 1836 let g = mapping::normalize_guid(&path.into_inner()); 1837 let Some((kind, native)) = resolve_native(&state, &g).await else { 1838 return HttpResponse::NotFound().finish(); 1839 }; 1840 if kind != "playlist" { 1841 return HttpResponse::Forbidden().finish(); 1842 } 1843 if let Err(e) = repo::delete_playlist(&state.pool, &native).await { 1844 tracing::error!("jellyfin: delete_playlist: {e}"); 1845 return HttpResponse::InternalServerError().finish(); 1846 } 1847 HttpResponse::NoContent().finish() 1848} 1849 1850/// `/Artists/Prefixes` — same shape as `/Items/Prefixes?IncludeItemTypes=MusicArtist`, 1851/// but the URL itself implies the type so we don't need query-string hints. 1852pub async fn artists_prefixes(_user: AuthedUser, state: web::Data<JellyfinState>) -> HttpResponse { 1853 let letters = repo::artist_name_prefixes(&state.pool) 1854 .await 1855 .unwrap_or_default(); 1856 let items: Vec<Value> = letters.into_iter().map(|n| json!({ "Name": n })).collect(); 1857 HttpResponse::Ok().json(items) 1858} 1859 1860/// `/Items/Prefixes?ParentId=<lib>&IncludeItemTypes=...` — populates the 1861/// alpha-jump rail. Returns the distinct uppercase first letters that 1862/// actually exist for the requested item type, with "#" for non-alpha names. 1863/// Response shape mirrors the reference Jellyfin server: `[{"Name":"A"}, …]`. 1864pub async fn items_prefixes( 1865 _user: AuthedUser, 1866 state: web::Data<JellyfinState>, 1867 req: HttpRequest, 1868) -> HttpResponse { 1869 let q = parse_items_query(&req); 1870 let parent_kind = q 1871 .parent_id 1872 .as_deref() 1873 .map(mapping::normalize_guid) 1874 .map(|g| { 1875 if g == mapping::movies_library_guid() { 1876 "movies" 1877 } else if g == mapping::library_guid() { 1878 "music" 1879 } else if g == mapping::playlists_library_guid() { 1880 "playlists" 1881 } else { 1882 "" 1883 } 1884 }) 1885 .unwrap_or(""); 1886 1887 if parent_kind == "playlists" || includes(&q.include_item_types, "Playlist") { 1888 let names = repo::all_playlists(&state.pool).await.unwrap_or_default(); 1889 let mut letters: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); 1890 for p in &names { 1891 let first = p 1892 .name 1893 .chars() 1894 .next() 1895 .map(|c| c.to_ascii_uppercase().to_string()) 1896 .unwrap_or_default(); 1897 if let Some(c) = first.chars().next() { 1898 if c.is_ascii_alphabetic() { 1899 letters.insert(first); 1900 } else { 1901 letters.insert("#".to_string()); 1902 } 1903 } 1904 } 1905 let items: Vec<Value> = letters.into_iter().map(|n| json!({ "Name": n })).collect(); 1906 return HttpResponse::Ok().json(items); 1907 } 1908 1909 let letters = if wants_videos(&q) || parent_kind == "movies" { 1910 repo::video_name_prefixes(&state.pool).await 1911 } else if includes(&q.include_item_types, "MusicAlbum") { 1912 repo::album_name_prefixes(&state.pool).await 1913 } else if includes(&q.include_item_types, "Audio") { 1914 repo::song_name_prefixes(&state.pool).await 1915 } else if includes(&q.include_item_types, "MusicArtist") 1916 || includes(&q.include_item_types, "AlbumArtist") 1917 || parent_kind == "music" 1918 { 1919 repo::artist_name_prefixes(&state.pool).await 1920 } else { 1921 Ok(Vec::new()) 1922 }; 1923 1924 let items: Vec<Value> = letters 1925 .unwrap_or_default() 1926 .into_iter() 1927 .map(|n| json!({ "Name": n })) 1928 .collect(); 1929 HttpResponse::Ok().json(items) 1930} 1931 1932async fn list_artists_or_albums(state: &JellyfinState, q: &ItemsQuery) -> HttpResponse { 1933 let limit = q.limit.unwrap_or(100).max(1); 1934 let offset = q.start_index.unwrap_or(0).max(0); 1935 let starts = q.name_starts_with.as_deref(); 1936 let geq = q.name_starts_with_or_greater.as_deref(); 1937 let lt = q.name_less_than.as_deref(); 1938 1939 // Songs in the music library — `parentId=<music_lib>&includeItemTypes=Audio`. 1940 if includes(&q.include_item_types, "Audio") { 1941 let songs = repo::songs_filtered(&state.pool, starts, geq, lt, limit, offset) 1942 .await 1943 .unwrap_or_default(); 1944 let total = repo::count_songs_filtered(&state.pool, starts, geq, lt) 1945 .await 1946 .unwrap_or(0) as i32; 1947 let mut dtos = Vec::with_capacity(songs.len()); 1948 for s in &songs { 1949 dtos.push(song_to_dto(state, s).await); 1950 } 1951 return HttpResponse::Ok().json(ItemsResult { 1952 items: dtos, 1953 total_record_count: total, 1954 start_index: offset as i32, 1955 }); 1956 } 1957 1958 if includes(&q.include_item_types, "MusicAlbum") { 1959 let albums = repo::albums_filtered(&state.pool, starts, geq, lt, limit, offset) 1960 .await 1961 .unwrap_or_default(); 1962 let total = repo::count_albums_filtered(&state.pool, starts, geq, lt) 1963 .await 1964 .unwrap_or(0) as i32; 1965 let mut dtos = Vec::with_capacity(albums.len()); 1966 for a in &albums { 1967 dtos.push(album_to_dto(state, a).await); 1968 } 1969 return HttpResponse::Ok().json(ItemsResult { 1970 items: dtos, 1971 total_record_count: total, 1972 start_index: offset as i32, 1973 }); 1974 } 1975 let artists = repo::artists_filtered(&state.pool, starts, geq, lt, limit, offset) 1976 .await 1977 .unwrap_or_default(); 1978 let total = repo::count_artists_filtered(&state.pool, starts, geq, lt) 1979 .await 1980 .unwrap_or(0) as i32; 1981 let mut dtos = Vec::with_capacity(artists.len()); 1982 for a in &artists { 1983 dtos.push(artist_to_dto(state, a).await); 1984 } 1985 HttpResponse::Ok().json(ItemsResult { 1986 items: dtos, 1987 total_record_count: total, 1988 start_index: offset as i32, 1989 }) 1990} 1991 1992/// If `guid` is one of the virtual library GUIDs, return its CollectionFolder 1993/// DTO. Moonfin tapping a library tile first calls 1994/// `GET /Users/{uid}/Items/{library_guid}` for the header; without this we 1995/// returned 404 and the whole library page failed to render. 1996fn library_view_for(state: &JellyfinState, guid: &str) -> Option<BaseItemDto> { 1997 if guid == mapping::library_guid() { 1998 Some(music_library_view(state)) 1999 } else if guid == mapping::movies_library_guid() { 2000 movies_library_view(state) 2001 } else if guid == mapping::playlists_library_guid() { 2002 Some(playlists_library_view(state)) 2003 } else { 2004 None 2005 } 2006} 2007 2008pub async fn item_by_id( 2009 _user: AuthedUser, 2010 state: web::Data<JellyfinState>, 2011 path: web::Path<String>, 2012) -> HttpResponse { 2013 let g = mapping::normalize_guid(&path.into_inner()); 2014 if let Some(view) = library_view_for(&state, &g) { 2015 return HttpResponse::Ok().json(view); 2016 } 2017 let Some((kind, native)) = resolve_native(&state, &g).await else { 2018 return HttpResponse::NotFound().finish(); 2019 }; 2020 match kind.as_str() { 2021 "song" => match repo::find_song(&state.pool, &native).await { 2022 Ok(Some(s)) => HttpResponse::Ok().json(song_to_dto(&state, &s).await), 2023 _ => HttpResponse::NotFound().finish(), 2024 }, 2025 "album" => match repo::find_album(&state.pool, &native).await { 2026 Ok(Some(a)) => HttpResponse::Ok().json(album_to_dto(&state, &a).await), 2027 _ => HttpResponse::NotFound().finish(), 2028 }, 2029 "artist" => match repo::find_artist(&state.pool, &native).await { 2030 Ok(Some(a)) => HttpResponse::Ok().json(artist_to_dto(&state, &a).await), 2031 _ => HttpResponse::NotFound().finish(), 2032 }, 2033 "video" => match repo::find_video(&state.pool, &native).await { 2034 Ok(Some(v)) => HttpResponse::Ok().json(video_to_dto(&state, &v).await), 2035 _ => HttpResponse::NotFound().finish(), 2036 }, 2037 "playlist" => match repo::find_playlist(&state.pool, &native).await { 2038 Ok(Some(p)) => HttpResponse::Ok().json(playlist_to_dto(&state, &p).await), 2039 _ => HttpResponse::NotFound().finish(), 2040 }, 2041 _ => HttpResponse::NotFound().finish(), 2042 } 2043} 2044 2045pub async fn user_item_by_id( 2046 _user: AuthedUser, 2047 state: web::Data<JellyfinState>, 2048 path: web::Path<(String, String)>, 2049) -> HttpResponse { 2050 let (_user_id, item_id) = path.into_inner(); 2051 let g = mapping::normalize_guid(&item_id); 2052 if let Some(view) = library_view_for(&state, &g) { 2053 return HttpResponse::Ok().json(view); 2054 } 2055 let Some((kind, native)) = resolve_native(&state, &g).await else { 2056 return HttpResponse::NotFound().finish(); 2057 }; 2058 match kind.as_str() { 2059 "song" => match repo::find_song(&state.pool, &native).await { 2060 Ok(Some(s)) => HttpResponse::Ok().json(song_to_dto(&state, &s).await), 2061 _ => HttpResponse::NotFound().finish(), 2062 }, 2063 "album" => match repo::find_album(&state.pool, &native).await { 2064 Ok(Some(a)) => HttpResponse::Ok().json(album_to_dto(&state, &a).await), 2065 _ => HttpResponse::NotFound().finish(), 2066 }, 2067 "artist" => match repo::find_artist(&state.pool, &native).await { 2068 Ok(Some(a)) => HttpResponse::Ok().json(artist_to_dto(&state, &a).await), 2069 _ => HttpResponse::NotFound().finish(), 2070 }, 2071 "video" => match repo::find_video(&state.pool, &native).await { 2072 Ok(Some(v)) => HttpResponse::Ok().json(video_to_dto(&state, &v).await), 2073 _ => HttpResponse::NotFound().finish(), 2074 }, 2075 "playlist" => match repo::find_playlist(&state.pool, &native).await { 2076 Ok(Some(p)) => HttpResponse::Ok().json(playlist_to_dto(&state, &p).await), 2077 _ => HttpResponse::NotFound().finish(), 2078 }, 2079 _ => HttpResponse::NotFound().finish(), 2080 } 2081} 2082 2083// ── Artists endpoints (some clients prefer these over /Items) ─────────────── 2084 2085pub async fn artists( 2086 _user: AuthedUser, 2087 state: web::Data<JellyfinState>, 2088 req: HttpRequest, 2089) -> HttpResponse { 2090 let q = parse_items_query(&req); 2091 2092 // `?isFavorite=true` / `?Filters=IsFavorite` on `/Artists` — return only 2093 // starred artists, honouring the same start/limit paging as the plain call. 2094 if wants_favorites_only(&q) { 2095 let starts = q.name_starts_with.as_deref(); 2096 let rows = repo::starred_artists(&state.pool).await.unwrap_or_default(); 2097 let filtered: Vec<Artist> = rows 2098 .into_iter() 2099 .map(|(a, _)| a) 2100 .filter(|a| match starts { 2101 Some(p) if !p.is_empty() => a 2102 .name 2103 .to_ascii_lowercase() 2104 .starts_with(&p.to_ascii_lowercase()), 2105 _ => true, 2106 }) 2107 .collect(); 2108 let total = filtered.len() as i32; 2109 let start = q.start_index.unwrap_or(0).max(0) as usize; 2110 let limit = q.limit.unwrap_or(500).max(1) as usize; 2111 let slice: Vec<&Artist> = filtered.iter().skip(start).take(limit).collect(); 2112 let mut dtos = Vec::with_capacity(slice.len()); 2113 for a in slice { 2114 dtos.push(artist_to_dto(&state, a).await); 2115 } 2116 return HttpResponse::Ok().json(ItemsResult { 2117 items: dtos, 2118 total_record_count: total, 2119 start_index: start as i32, 2120 }); 2121 } 2122 2123 let limit = q.limit.unwrap_or(500).max(1); 2124 let offset = q.start_index.unwrap_or(0).max(0); 2125 let starts = q.name_starts_with.as_deref(); 2126 let geq = q.name_starts_with_or_greater.as_deref(); 2127 let lt = q.name_less_than.as_deref(); 2128 let artists = repo::artists_filtered(&state.pool, starts, geq, lt, limit, offset) 2129 .await 2130 .unwrap_or_default(); 2131 let total = repo::count_artists_filtered(&state.pool, starts, geq, lt) 2132 .await 2133 .unwrap_or(0) as i32; 2134 let mut dtos = Vec::with_capacity(artists.len()); 2135 for a in &artists { 2136 dtos.push(artist_to_dto(&state, a).await); 2137 } 2138 HttpResponse::Ok().json(ItemsResult { 2139 items: dtos, 2140 total_record_count: total, 2141 start_index: offset as i32, 2142 }) 2143} 2144 2145pub async fn artist_by_name( 2146 _user: AuthedUser, 2147 state: web::Data<JellyfinState>, 2148 path: web::Path<String>, 2149) -> HttpResponse { 2150 let name = path.into_inner(); 2151 let artists = repo::all_artists(&state.pool).await.unwrap_or_default(); 2152 if let Some(a) = artists.iter().find(|a| a.name.eq_ignore_ascii_case(&name)) { 2153 HttpResponse::Ok().json(artist_to_dto(&state, a).await) 2154 } else { 2155 HttpResponse::NotFound().finish() 2156 } 2157} 2158 2159// ── InstantMix ────────────────────────────────────────────────────────────── 2160// 2161// Spec (Jellyfin OpenAPI, InstantMix tag): seven endpoints — one per seed 2162// kind — each returning `BaseItemDtoQueryResult` (an `ItemsResult` of 2163// `Audio` items). Real Jellyfin uses a music-similarity backend; smolsonic 2164// approximates with a three-tier fallback: 2165// 1. songs by the seed's artist (or the seed song itself first) 2166// 2. songs in the seed's genre 2167// 3. random library-wide filler 2168// The result is deduped by native id and capped at `limit` (default 50 in 2169// the spec — Symfonium/Streamyfin both use 100). 2170 2171#[derive(Debug, Deserialize)] 2172#[allow(dead_code)] // `id` is accepted for spec-compliance but not consulted. 2173pub struct InstantMixQuery { 2174 #[serde(default, alias = "Limit")] 2175 pub limit: Option<i64>, 2176 /// Only present on `/MusicGenres/InstantMix` — the seed genre's id. 2177 /// smolsonic doesn't emit Genre BaseItems so we accept-and-ignore. 2178 #[serde(default, alias = "Id")] 2179 pub id: Option<String>, 2180} 2181 2182/// Assemble the InstantMix result. `artist_id` narrows tier 1, `genre` narrows 2183/// tier 2, `seeds` are inserted at the front (used by the song- and 2184/// playlist-seeded mixes). Any of the tiers can be absent — the next one 2185/// takes over. 2186async fn build_instant_mix( 2187 state: &JellyfinState, 2188 limit: i64, 2189 seeds: Vec<Song>, 2190 artist_id: Option<String>, 2191 genre: Option<String>, 2192) -> Vec<Song> { 2193 let target = limit.max(1) as usize; 2194 let mut out: Vec<Song> = Vec::with_capacity(target); 2195 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new(); 2196 2197 for s in seeds { 2198 if out.len() >= target { 2199 break; 2200 } 2201 if seen.insert(s.id.clone()) { 2202 out.push(s); 2203 } 2204 } 2205 2206 // Tier 1: same artist. 2207 if let Some(aid) = artist_id.as_deref() { 2208 if out.len() < target { 2209 let more = repo::random_songs_by_artist(&state.pool, aid, target as i64 * 2) 2210 .await 2211 .unwrap_or_default(); 2212 for s in more { 2213 if out.len() >= target { 2214 break; 2215 } 2216 if seen.insert(s.id.clone()) { 2217 out.push(s); 2218 } 2219 } 2220 } 2221 } 2222 2223 // Tier 2: same genre. 2224 if let Some(g) = genre.as_deref() { 2225 if out.len() < target { 2226 let more = repo::random_songs(&state.pool, target as i64 * 2, None, None, Some(g)) 2227 .await 2228 .unwrap_or_default(); 2229 for s in more { 2230 if out.len() >= target { 2231 break; 2232 } 2233 if seen.insert(s.id.clone()) { 2234 out.push(s); 2235 } 2236 } 2237 } 2238 } 2239 2240 // Tier 3: random library-wide filler. 2241 if out.len() < target { 2242 let more = repo::random_songs(&state.pool, target as i64 * 2, None, None, None) 2243 .await 2244 .unwrap_or_default(); 2245 for s in more { 2246 if out.len() >= target { 2247 break; 2248 } 2249 if seen.insert(s.id.clone()) { 2250 out.push(s); 2251 } 2252 } 2253 } 2254 2255 out 2256} 2257 2258async fn songs_to_items_result(state: &JellyfinState, songs: Vec<Song>) -> HttpResponse { 2259 let mut dtos = Vec::with_capacity(songs.len()); 2260 for s in &songs { 2261 dtos.push(song_to_dto(state, s).await); 2262 } 2263 let total = dtos.len() as i32; 2264 HttpResponse::Ok().json(ItemsResult { 2265 items: dtos, 2266 total_record_count: total, 2267 start_index: 0, 2268 }) 2269} 2270 2271fn instant_mix_limit(q: &InstantMixQuery) -> i64 { 2272 q.limit.unwrap_or(50).max(1) 2273} 2274 2275/// `GET /Albums/{itemId}/InstantMix` 2276pub async fn album_instant_mix( 2277 _user: AuthedUser, 2278 state: web::Data<JellyfinState>, 2279 path: web::Path<String>, 2280 query: web::Query<InstantMixQuery>, 2281) -> HttpResponse { 2282 let g = mapping::normalize_guid(&path.into_inner()); 2283 let Some((kind, native)) = resolve_native(&state, &g).await else { 2284 return HttpResponse::NotFound().finish(); 2285 }; 2286 if kind != "album" { 2287 return HttpResponse::NotFound().finish(); 2288 } 2289 let Ok(Some(al)) = repo::find_album(&state.pool, &native).await else { 2290 return HttpResponse::NotFound().finish(); 2291 }; 2292 // Genre for an album isn't stored on the row — sample one song from the 2293 // album to get a representative genre. Cheap: it's already in `songs`. 2294 let genre = repo::songs_by_album(&state.pool, &native) 2295 .await 2296 .unwrap_or_default() 2297 .into_iter() 2298 .find_map(|s| s.genre); 2299 let songs = build_instant_mix( 2300 &state, 2301 instant_mix_limit(&query), 2302 Vec::new(), 2303 Some(al.artist_id.clone()), 2304 genre, 2305 ) 2306 .await; 2307 songs_to_items_result(&state, songs).await 2308} 2309 2310/// `GET /Artists/{itemId}/InstantMix` 2311pub async fn artist_instant_mix( 2312 _user: AuthedUser, 2313 state: web::Data<JellyfinState>, 2314 path: web::Path<String>, 2315 query: web::Query<InstantMixQuery>, 2316) -> HttpResponse { 2317 let g = mapping::normalize_guid(&path.into_inner()); 2318 let Some((kind, native)) = resolve_native(&state, &g).await else { 2319 return HttpResponse::NotFound().finish(); 2320 }; 2321 if kind != "artist" { 2322 return HttpResponse::NotFound().finish(); 2323 } 2324 let songs = build_instant_mix( 2325 &state, 2326 instant_mix_limit(&query), 2327 Vec::new(), 2328 Some(native), 2329 None, 2330 ) 2331 .await; 2332 songs_to_items_result(&state, songs).await 2333} 2334 2335/// `GET /Songs/{itemId}/InstantMix` 2336pub async fn song_instant_mix( 2337 _user: AuthedUser, 2338 state: web::Data<JellyfinState>, 2339 path: web::Path<String>, 2340 query: web::Query<InstantMixQuery>, 2341) -> HttpResponse { 2342 let g = mapping::normalize_guid(&path.into_inner()); 2343 let Some((kind, native)) = resolve_native(&state, &g).await else { 2344 return HttpResponse::NotFound().finish(); 2345 }; 2346 if kind != "song" { 2347 return HttpResponse::NotFound().finish(); 2348 } 2349 let Ok(Some(seed)) = repo::find_song(&state.pool, &native).await else { 2350 return HttpResponse::NotFound().finish(); 2351 }; 2352 let artist_id = seed.artist_id.clone(); 2353 let genre = seed.genre.clone(); 2354 let songs = build_instant_mix( 2355 &state, 2356 instant_mix_limit(&query), 2357 vec![seed], 2358 Some(artist_id), 2359 genre, 2360 ) 2361 .await; 2362 songs_to_items_result(&state, songs).await 2363} 2364 2365/// `GET /Playlists/{itemId}/InstantMix` — take the playlist's songs in 2366/// order, then top up with same-genre + random-library filler. 2367pub async fn playlist_instant_mix( 2368 _user: AuthedUser, 2369 state: web::Data<JellyfinState>, 2370 path: web::Path<String>, 2371 query: web::Query<InstantMixQuery>, 2372) -> HttpResponse { 2373 let g = mapping::normalize_guid(&path.into_inner()); 2374 let Some((kind, native)) = resolve_native(&state, &g).await else { 2375 return HttpResponse::NotFound().finish(); 2376 }; 2377 if kind != "playlist" { 2378 return HttpResponse::NotFound().finish(); 2379 } 2380 let limit = instant_mix_limit(&query); 2381 let playlist_songs = repo::playlist_songs(&state.pool, &native) 2382 .await 2383 .unwrap_or_default(); 2384 let genre = playlist_songs.iter().find_map(|s| s.genre.clone()); 2385 let songs = build_instant_mix(&state, limit, playlist_songs, None, genre).await; 2386 songs_to_items_result(&state, songs).await 2387} 2388 2389/// `GET /MusicGenres/{name}/InstantMix` 2390pub async fn genre_instant_mix( 2391 _user: AuthedUser, 2392 state: web::Data<JellyfinState>, 2393 path: web::Path<String>, 2394 query: web::Query<InstantMixQuery>, 2395) -> HttpResponse { 2396 let name = path.into_inner(); 2397 let songs = build_instant_mix( 2398 &state, 2399 instant_mix_limit(&query), 2400 Vec::new(), 2401 None, 2402 Some(name), 2403 ) 2404 .await; 2405 songs_to_items_result(&state, songs).await 2406} 2407 2408/// `GET /MusicGenres/InstantMix?id=…` — same as the path-parameterised 2409/// variant but the seed genre is identified by GUID. smolsonic doesn't emit 2410/// Genre BaseItems, so any provided `id` won't resolve — return an empty 2411/// result rather than 404 to match real-server behaviour (the client 2412/// gracefully hides the empty rail). 2413pub async fn genre_instant_mix_by_id( 2414 _user: AuthedUser, 2415 _state: web::Data<JellyfinState>, 2416 _query: web::Query<InstantMixQuery>, 2417) -> HttpResponse { 2418 HttpResponse::Ok().json(ItemsResult { 2419 items: vec![], 2420 total_record_count: 0, 2421 start_index: 0, 2422 }) 2423} 2424 2425/// `GET /Items/{itemId}/InstantMix` — dispatch by resolved kind. 2426pub async fn item_instant_mix( 2427 user: AuthedUser, 2428 state: web::Data<JellyfinState>, 2429 path: web::Path<String>, 2430 query: web::Query<InstantMixQuery>, 2431) -> HttpResponse { 2432 let raw = path.into_inner(); 2433 let g = mapping::normalize_guid(&raw); 2434 let Some((kind, _native)) = resolve_native(&state, &g).await else { 2435 return HttpResponse::NotFound().finish(); 2436 }; 2437 // Re-dispatch — each per-kind handler already resolves and validates the 2438 // GUID. Wrapping in a Path::from keeps the signatures uniform. 2439 let path = web::Path::from(raw); 2440 match kind.as_str() { 2441 "song" => song_instant_mix(user, state, path, query).await, 2442 "album" => album_instant_mix(user, state, path, query).await, 2443 "artist" => artist_instant_mix(user, state, path, query).await, 2444 "playlist" => playlist_instant_mix(user, state, path, query).await, 2445 _ => HttpResponse::NotFound().finish(), 2446 } 2447} 2448 2449// ── Similar ───────────────────────────────────────────────────────────────── 2450// 2451// Spec (Jellyfin OpenAPI, Library tag): six endpoints — /Albums, /Artists, 2452// /Movies, /Trailers, /Shows, /Items — each returning a 2453// `BaseItemDtoQueryResult` of items similar to the seed. 2454// 2455// Similarity is delegated to the Last.fm + MusicBrainz plugins in 2456// `similar::SimilarProviders`. Neither is required — when both are absent 2457// (no `[lastfm]` or `[musicbrainz]` block in the config), every Similar 2458// endpoint short-circuits to an empty result. Movie/Trailer/Shows never 2459// consult a provider (Last.fm and MB are music-only) and always return 2460// empty regardless. 2461 2462#[derive(Debug, Deserialize)] 2463#[allow(dead_code)] // `user_id` and `fields` are accepted for spec-compliance but not consulted. 2464pub struct SimilarQuery { 2465 #[serde(default, alias = "Limit")] 2466 pub limit: Option<i64>, 2467 #[serde(default, alias = "ExcludeArtistIds")] 2468 pub exclude_artist_ids: Option<String>, 2469 #[serde(default, alias = "UserId", rename = "userId")] 2470 pub user_id: Option<String>, 2471 #[serde(default, alias = "Fields")] 2472 pub fields: Option<String>, 2473} 2474 2475fn similar_limit(q: &SimilarQuery) -> usize { 2476 q.limit.unwrap_or(12).max(1) as usize 2477} 2478 2479/// Parse `excludeArtistIds=<guid>,<guid>` into the set of *native* artist 2480/// ids we should drop from the result. Unknown GUIDs are silently ignored. 2481async fn resolve_exclude_artists(state: &JellyfinState, csv: Option<&str>) -> Vec<String> { 2482 let Some(csv) = csv else { return Vec::new() }; 2483 let mut out: Vec<String> = Vec::new(); 2484 for raw in csv.split(',') { 2485 let g = mapping::normalize_guid(raw.trim()); 2486 if let Some((kind, native)) = resolve_native(state, &g).await { 2487 if kind == "artist" { 2488 out.push(native); 2489 } 2490 } 2491 } 2492 out 2493} 2494 2495/// Resolve provider-returned artist names back to library `Artist` rows via 2496/// case-insensitive name match. Drops names we don't have. 2497async fn library_artists_by_name( 2498 state: &JellyfinState, 2499 names: &[String], 2500 exclude_artist_ids: &[String], 2501) -> Vec<crate::models::Artist> { 2502 if names.is_empty() { 2503 return Vec::new(); 2504 } 2505 let all = repo::all_artists(&state.pool).await.unwrap_or_default(); 2506 let lower_index: std::collections::HashMap<String, crate::models::Artist> = all 2507 .into_iter() 2508 .map(|a| (a.name.to_ascii_lowercase(), a)) 2509 .collect(); 2510 let mut out = Vec::new(); 2511 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new(); 2512 for name in names { 2513 if let Some(a) = lower_index.get(&name.to_ascii_lowercase()) { 2514 if exclude_artist_ids.contains(&a.id) { 2515 continue; 2516 } 2517 if seen.insert(a.id.clone()) { 2518 out.push(a.clone()); 2519 } 2520 } 2521 } 2522 out 2523} 2524 2525async fn empty_items_result() -> HttpResponse { 2526 HttpResponse::Ok().json(ItemsResult { 2527 items: vec![], 2528 total_record_count: 0, 2529 start_index: 0, 2530 }) 2531} 2532 2533pub async fn album_similar( 2534 _user: AuthedUser, 2535 state: web::Data<JellyfinState>, 2536 path: web::Path<String>, 2537 query: web::Query<SimilarQuery>, 2538) -> HttpResponse { 2539 let g = mapping::normalize_guid(&path.into_inner()); 2540 let Some((kind, native)) = resolve_native(&state, &g).await else { 2541 return HttpResponse::NotFound().finish(); 2542 }; 2543 if kind != "album" { 2544 return HttpResponse::NotFound().finish(); 2545 } 2546 let Ok(Some(seed_album)) = repo::find_album(&state.pool, &native).await else { 2547 return HttpResponse::NotFound().finish(); 2548 }; 2549 if !state.similar.any_enabled() { 2550 return empty_items_result().await; 2551 } 2552 2553 let q = query.into_inner(); 2554 let limit = similar_limit(&q); 2555 let exclude = resolve_exclude_artists(&state, q.exclude_artist_ids.as_deref()).await; 2556 let names = state 2557 .similar 2558 .similar_artist_names(&state.pool, &seed_album.artist_id, &seed_album.artist) 2559 .await; 2560 let similar_artists = library_artists_by_name(&state, &names, &exclude).await; 2561 2562 // For each similar artist, collect their albums. Skip the seed album 2563 // itself even though it shouldn't appear (safety belt). 2564 let mut dtos: Vec<BaseItemDto> = Vec::new(); 2565 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new(); 2566 seen.insert(seed_album.id.clone()); 2567 for artist in similar_artists { 2568 if dtos.len() >= limit { 2569 break; 2570 } 2571 let albums = repo::albums_by_artist(&state.pool, &artist.id) 2572 .await 2573 .unwrap_or_default(); 2574 for al in albums { 2575 if dtos.len() >= limit { 2576 break; 2577 } 2578 if seen.insert(al.id.clone()) { 2579 dtos.push(album_to_dto(&state, &al).await); 2580 } 2581 } 2582 } 2583 let total = dtos.len() as i32; 2584 HttpResponse::Ok().json(ItemsResult { 2585 items: dtos, 2586 total_record_count: total, 2587 start_index: 0, 2588 }) 2589} 2590 2591pub async fn artist_similar( 2592 _user: AuthedUser, 2593 state: web::Data<JellyfinState>, 2594 path: web::Path<String>, 2595 query: web::Query<SimilarQuery>, 2596) -> HttpResponse { 2597 let g = mapping::normalize_guid(&path.into_inner()); 2598 let Some((kind, native)) = resolve_native(&state, &g).await else { 2599 return HttpResponse::NotFound().finish(); 2600 }; 2601 if kind != "artist" { 2602 return HttpResponse::NotFound().finish(); 2603 } 2604 let Ok(Some(seed)) = repo::find_artist(&state.pool, &native).await else { 2605 return HttpResponse::NotFound().finish(); 2606 }; 2607 if !state.similar.any_enabled() { 2608 return empty_items_result().await; 2609 } 2610 2611 let q = query.into_inner(); 2612 let limit = similar_limit(&q); 2613 let mut exclude = resolve_exclude_artists(&state, q.exclude_artist_ids.as_deref()).await; 2614 // Always exclude the seed itself. 2615 exclude.push(seed.id.clone()); 2616 2617 let names = state 2618 .similar 2619 .similar_artist_names(&state.pool, &seed.id, &seed.name) 2620 .await; 2621 let mut library = library_artists_by_name(&state, &names, &exclude).await; 2622 library.truncate(limit); 2623 2624 let mut dtos = Vec::with_capacity(library.len()); 2625 for a in &library { 2626 dtos.push(artist_to_dto(&state, a).await); 2627 } 2628 let total = dtos.len() as i32; 2629 HttpResponse::Ok().json(ItemsResult { 2630 items: dtos, 2631 total_record_count: total, 2632 start_index: 0, 2633 }) 2634} 2635 2636/// `GET /Movies/{id}/Similar`, `/Trailers/{id}/Similar`, `/Shows/{id}/Similar`. 2637/// Last.fm / MusicBrainz are music-only, so the video/TV variants have 2638/// nothing to consult and always return an empty result. We still validate 2639/// the item id — a 404 for unknown GUIDs matches spec. 2640pub async fn video_similar( 2641 _user: AuthedUser, 2642 state: web::Data<JellyfinState>, 2643 path: web::Path<String>, 2644 _query: web::Query<SimilarQuery>, 2645) -> HttpResponse { 2646 let g = mapping::normalize_guid(&path.into_inner()); 2647 match resolve_native(&state, &g).await { 2648 Some(_) => empty_items_result().await, 2649 None => HttpResponse::NotFound().finish(), 2650 } 2651} 2652 2653/// `GET /Items/{id}/Similar` — dispatch by kind. Songs, playlists and other 2654/// kinds not covered above respond with an empty result. 2655pub async fn item_similar( 2656 user: AuthedUser, 2657 state: web::Data<JellyfinState>, 2658 path: web::Path<String>, 2659 query: web::Query<SimilarQuery>, 2660) -> HttpResponse { 2661 let raw = path.into_inner(); 2662 let g = mapping::normalize_guid(&raw); 2663 let Some((kind, _native)) = resolve_native(&state, &g).await else { 2664 return HttpResponse::NotFound().finish(); 2665 }; 2666 let path = web::Path::from(raw); 2667 match kind.as_str() { 2668 "album" => album_similar(user, state, path, query).await, 2669 "artist" => artist_similar(user, state, path, query).await, 2670 "video" => video_similar(user, state, path, query).await, 2671 _ => empty_items_result().await, 2672 } 2673} 2674 2675// ── RemoteImage ───────────────────────────────────────────────────────────── 2676// 2677// Spec (Jellyfin OpenAPI, RemoteImage tag): 2678// GET /Items/{id}/RemoteImages?type=&providerName=&startIndex=&limit= 2679// &includeAllLanguages= → RemoteImageResult 2680// POST /Items/{id}/RemoteImages/Download?type=&imageUrl= → 204 2681// GET /Items/{id}/RemoteImages/Providers → [ImageProviderInfo] 2682// 2683// Sourced from the Last.fm + MusicBrainz plugins we already wired up for 2684// Similar. Only `Primary` (cover art) is supported — smolsonic doesn't 2685// model Art/Backdrop/Banner/etc. Requests for other types return an empty 2686// image list on the search endpoint and 400 on Download. 2687 2688#[derive(Debug, Deserialize)] 2689#[allow(dead_code)] // `include_all_languages` is accepted per spec but not filtered on. 2690pub struct RemoteImagesQuery { 2691 #[serde(default, alias = "Type", rename = "type")] 2692 pub image_type: Option<String>, 2693 #[serde(default, alias = "StartIndex", rename = "startIndex")] 2694 pub start_index: Option<i64>, 2695 #[serde(default, alias = "Limit")] 2696 pub limit: Option<i64>, 2697 #[serde(default, alias = "ProviderName", rename = "providerName")] 2698 pub provider_name: Option<String>, 2699 #[serde(default, alias = "IncludeAllLanguages", rename = "includeAllLanguages")] 2700 pub include_all_languages: Option<bool>, 2701} 2702 2703#[derive(Debug, Deserialize)] 2704pub struct DownloadImageQuery { 2705 #[serde(default, alias = "Type", rename = "type")] 2706 pub image_type: Option<String>, 2707 #[serde(default, alias = "ImageUrl", rename = "imageUrl")] 2708 pub image_url: Option<String>, 2709} 2710 2711fn is_primary(kind: Option<&str>) -> bool { 2712 // Absent `type` → default to Primary (Findroid omits it on the initial 2713 // rail render). Any other explicit value → not Primary. 2714 match kind { 2715 None => true, 2716 Some(s) => s.eq_ignore_ascii_case("Primary"), 2717 } 2718} 2719 2720/// Resolve `item_guid` to (kind, native_id). For songs we hop up to the 2721/// containing album because that's where the artwork actually lives. 2722async fn remote_image_target( 2723 state: &JellyfinState, 2724 item_guid: &str, 2725) -> Result<(String, crate::models::Album), HttpResponse> { 2726 let g = mapping::normalize_guid(item_guid); 2727 let Some((kind, native)) = resolve_native(state, &g).await else { 2728 return Err(HttpResponse::NotFound().finish()); 2729 }; 2730 match kind.as_str() { 2731 "album" => match repo::find_album(&state.pool, &native).await { 2732 Ok(Some(a)) => Ok(("album".to_string(), a)), 2733 _ => Err(HttpResponse::NotFound().finish()), 2734 }, 2735 "song" => { 2736 let Ok(Some(s)) = repo::find_song(&state.pool, &native).await else { 2737 return Err(HttpResponse::NotFound().finish()); 2738 }; 2739 match repo::find_album(&state.pool, &s.album_id).await { 2740 Ok(Some(a)) => Ok(("song".to_string(), a)), 2741 _ => Err(HttpResponse::NotFound().finish()), 2742 } 2743 } 2744 "artist" => Err(HttpResponse::NotFound().finish()), 2745 _ => Err(HttpResponse::NotFound().finish()), 2746 } 2747} 2748 2749fn enabled_provider_names(state: &JellyfinState) -> Vec<String> { 2750 let mut out = Vec::new(); 2751 if state.similar.lastfm.is_some() { 2752 out.push("Last.fm".to_string()); 2753 } 2754 if state.similar.musicbrainz.is_some() { 2755 out.push("MusicBrainz".to_string()); 2756 } 2757 out 2758} 2759 2760pub async fn remote_images( 2761 _user: AuthedUser, 2762 state: web::Data<JellyfinState>, 2763 path: web::Path<String>, 2764 query: web::Query<RemoteImagesQuery>, 2765) -> HttpResponse { 2766 let (_kind, album) = match remote_image_target(&state, &path.into_inner()).await { 2767 Ok(t) => t, 2768 Err(resp) => return resp, 2769 }; 2770 let q = query.into_inner(); 2771 let providers_available = enabled_provider_names(&state); 2772 2773 // Non-Primary type → the query is well-formed but we have nothing. 2774 if !is_primary(q.image_type.as_deref()) { 2775 return HttpResponse::Ok().json(super::dto::RemoteImageResult { 2776 images: vec![], 2777 total_record_count: 0, 2778 providers: providers_available, 2779 }); 2780 } 2781 2782 let mut images: Vec<super::dto::RemoteImageInfo> = Vec::new(); 2783 let want_lastfm = matches_provider(&q.provider_name, "Last.fm"); 2784 let want_mb = matches_provider(&q.provider_name, "MusicBrainz"); 2785 2786 if want_lastfm { 2787 if let Some(lf) = &state.similar.lastfm { 2788 match lf 2789 .album_image_urls_cached(&state.pool, &album.id, &album.artist, &album.title) 2790 .await 2791 { 2792 Ok(urls) => { 2793 for url in urls { 2794 images.push(super::dto::RemoteImageInfo { 2795 provider_name: Some("Last.fm".to_string()), 2796 url: Some(url), 2797 thumbnail_url: None, 2798 height: None, 2799 width: None, 2800 community_rating: None, 2801 vote_count: None, 2802 language: Some("en".to_string()), 2803 image_type: "Primary", 2804 }); 2805 } 2806 } 2807 Err(e) => tracing::warn!("lastfm album.getInfo({}): {e}", album.title), 2808 } 2809 } 2810 } 2811 if want_mb { 2812 if let Some(mb) = &state.similar.musicbrainz { 2813 match mb 2814 .album_image_urls_cached(&state.pool, &album.id, &album.artist, &album.title) 2815 .await 2816 { 2817 Ok(urls) => { 2818 for url in urls { 2819 images.push(super::dto::RemoteImageInfo { 2820 provider_name: Some("MusicBrainz".to_string()), 2821 url: Some(url), 2822 thumbnail_url: None, 2823 height: None, 2824 width: None, 2825 community_rating: None, 2826 vote_count: None, 2827 language: None, 2828 image_type: "Primary", 2829 }); 2830 } 2831 } 2832 Err(e) => tracing::warn!("mb coverart({}): {e}", album.title), 2833 } 2834 } 2835 } 2836 2837 // Honour StartIndex / Limit. 2838 let start = q.start_index.unwrap_or(0).max(0) as usize; 2839 let take = q.limit.map(|n| n.max(1) as usize).unwrap_or(usize::MAX); 2840 let total = images.len() as i32; 2841 let sliced: Vec<super::dto::RemoteImageInfo> = 2842 images.into_iter().skip(start).take(take).collect(); 2843 HttpResponse::Ok().json(super::dto::RemoteImageResult { 2844 images: sliced, 2845 total_record_count: total, 2846 providers: providers_available, 2847 }) 2848} 2849 2850fn matches_provider(requested: &Option<String>, provider: &str) -> bool { 2851 match requested { 2852 None => true, 2853 Some(s) => s.eq_ignore_ascii_case(provider), 2854 } 2855} 2856 2857pub async fn remote_image_providers( 2858 _user: AuthedUser, 2859 state: web::Data<JellyfinState>, 2860 path: web::Path<String>, 2861) -> HttpResponse { 2862 // Still 404 on unknown items — real Jellyfin behaves the same. 2863 let g = mapping::normalize_guid(&path.into_inner()); 2864 if resolve_native(&state, &g).await.is_none() { 2865 return HttpResponse::NotFound().finish(); 2866 } 2867 let list: Vec<super::dto::ImageProviderInfo> = enabled_provider_names(&state) 2868 .into_iter() 2869 .map(|name| super::dto::ImageProviderInfo { 2870 name, 2871 supported_images: vec!["Primary"], 2872 }) 2873 .collect(); 2874 HttpResponse::Ok().json(list) 2875} 2876 2877pub async fn download_remote_image( 2878 _user: AuthedUser, 2879 state: web::Data<JellyfinState>, 2880 path: web::Path<String>, 2881 query: web::Query<DownloadImageQuery>, 2882) -> HttpResponse { 2883 let (_kind, album) = match remote_image_target(&state, &path.into_inner()).await { 2884 Ok(t) => t, 2885 Err(resp) => return resp, 2886 }; 2887 let q = query.into_inner(); 2888 if !is_primary(q.image_type.as_deref()) { 2889 return HttpResponse::BadRequest().body("only Primary image type is supported"); 2890 } 2891 let Some(image_url) = q.image_url.filter(|u| u.starts_with("http")) else { 2892 return HttpResponse::BadRequest().body("imageUrl query parameter is required"); 2893 }; 2894 if state.similar.lastfm.is_none() && state.similar.musicbrainz.is_none() { 2895 return HttpResponse::BadRequest() 2896 .body("no image plugin enabled; configure [lastfm] or [musicbrainz]"); 2897 } 2898 2899 let http = reqwest::Client::builder() 2900 .timeout(std::time::Duration::from_secs(15)) 2901 .build(); 2902 let http = match http { 2903 Ok(c) => c, 2904 Err(e) => { 2905 tracing::error!("build http client: {e}"); 2906 return HttpResponse::InternalServerError().finish(); 2907 } 2908 }; 2909 let resp = match http.get(&image_url).send().await { 2910 Ok(r) => r, 2911 Err(e) => { 2912 tracing::warn!("download {image_url}: {e}"); 2913 return HttpResponse::BadGateway().finish(); 2914 } 2915 }; 2916 let content_type = resp 2917 .headers() 2918 .get(reqwest::header::CONTENT_TYPE) 2919 .and_then(|v| v.to_str().ok()) 2920 .unwrap_or("image/jpeg") 2921 .to_string(); 2922 let bytes = match resp.bytes().await { 2923 Ok(b) => b, 2924 Err(e) => { 2925 tracing::warn!("read image bytes: {e}"); 2926 return HttpResponse::BadGateway().finish(); 2927 } 2928 }; 2929 let ext = ext_for_content_type(&content_type); 2930 let filename = format!("remote-{}.{ext}", album.id); 2931 let dst = state.covers_dir.join(&filename); 2932 if let Err(e) = std::fs::create_dir_all(&state.covers_dir) { 2933 tracing::error!("create covers_dir {}: {e}", state.covers_dir.display()); 2934 return HttpResponse::InternalServerError().finish(); 2935 } 2936 if let Err(e) = std::fs::write(&dst, &bytes) { 2937 tracing::error!("write cover {}: {e}", dst.display()); 2938 return HttpResponse::InternalServerError().finish(); 2939 } 2940 if let Err(e) = repo::set_album_cover_art(&state.pool, &album.id, &filename).await { 2941 tracing::error!("set_album_cover_art({}): {e}", album.id); 2942 return HttpResponse::InternalServerError().finish(); 2943 } 2944 HttpResponse::NoContent().finish() 2945} 2946 2947/// Naive content-type → extension. Falls back to `jpg` for anything unknown 2948/// because Cover Art Archive returns JPEG for `/front-*` by default. 2949fn ext_for_content_type(ct: &str) -> &'static str { 2950 let ct_lower = ct.to_ascii_lowercase(); 2951 if ct_lower.starts_with("image/png") { 2952 "png" 2953 } else if ct_lower.starts_with("image/webp") { 2954 "webp" 2955 } else if ct_lower.starts_with("image/gif") { 2956 "gif" 2957 } else if ct_lower.starts_with("image/svg") { 2958 "svg" 2959 } else { 2960 "jpg" 2961 } 2962} 2963 2964// ── Filter + Genre ────────────────────────────────────────────────────────── 2965// 2966// Spec (Jellyfin OpenAPI, Filter + Genre + MusicGenre tags): 2967// GET /Items/Filters → QueryFiltersLegacy 2968// GET /Items/Filters2 → QueryFilters 2969// GET /Genres → BaseItemDtoQueryResult 2970// GET /Genres/{genreName} → BaseItemDto 2971// GET /MusicGenres → BaseItemDtoQueryResult (same shape as /Genres) 2972// GET /MusicGenres/{genreName} → BaseItemDto 2973// 2974// smolsonic derives genres from `songs.genre` (no dedicated table). Filters 2975// share that source; Tags / OfficialRatings / AudioLanguages / 2976// SubtitleLanguages have no backing data and always come out empty. 2977 2978pub async fn items_counts(_user: AuthedUser, state: web::Data<JellyfinState>) -> HttpResponse { 2979 let song_count = repo::count_songs(&state.pool).await.unwrap_or(0) as i32; 2980 let album_count = repo::count_albums(&state.pool).await.unwrap_or(0) as i32; 2981 let artist_count = repo::count_artists(&state.pool).await.unwrap_or(0) as i32; 2982 let movie_count = repo::count_videos(&state.pool).await.unwrap_or(0) as i32; 2983 HttpResponse::Ok().json(super::dto::ItemCounts { 2984 song_count, 2985 album_count, 2986 artist_count, 2987 movie_count, 2988 ..Default::default() 2989 }) 2990} 2991 2992pub async fn items_filters(_user: AuthedUser, state: web::Data<JellyfinState>) -> HttpResponse { 2993 let genres: Vec<String> = repo::distinct_genres(&state.pool) 2994 .await 2995 .unwrap_or_default() 2996 .into_iter() 2997 .map(|(name, _, _)| name) 2998 .collect(); 2999 let years = repo::distinct_years(&state.pool).await.unwrap_or_default(); 3000 HttpResponse::Ok().json(super::dto::QueryFiltersLegacy { 3001 genres, 3002 tags: vec![], 3003 official_ratings: vec![], 3004 years, 3005 }) 3006} 3007 3008pub async fn items_filters2(_user: AuthedUser, state: web::Data<JellyfinState>) -> HttpResponse { 3009 let mut genres: Vec<super::dto::NameGuidPair> = Vec::new(); 3010 for (name, _, _) in repo::distinct_genres(&state.pool).await.unwrap_or_default() { 3011 let id = mapping::remember_genre(&state.pool, &name) 3012 .await 3013 .unwrap_or_else(|_| mapping::genre_guid(&name)); 3014 genres.push(super::dto::NameGuidPair { 3015 name: Some(name), 3016 id, 3017 }); 3018 } 3019 HttpResponse::Ok().json(super::dto::QueryFilters { 3020 genres, 3021 tags: vec![], 3022 audio_languages: vec![], 3023 subtitle_languages: vec![], 3024 }) 3025} 3026 3027async fn genre_to_dto(state: &JellyfinState, name: &str) -> Option<BaseItemDto> { 3028 let (real_name, song_count, album_count) = repo::find_genre_stats(&state.pool, name) 3029 .await 3030 .ok() 3031 .flatten()?; 3032 let id = mapping::remember_genre(&state.pool, &real_name) 3033 .await 3034 .unwrap_or_else(|_| mapping::genre_guid(&real_name)); 3035 Some(BaseItemDto { 3036 id, 3037 server_id: Some(state.server_id.clone()), 3038 name: Some(real_name.clone()), 3039 item_type: "MusicGenre", 3040 media_type: "Unknown", 3041 is_folder: Some(true), 3042 sort_name: Some(real_name), 3043 location_type: Some("FileSystem"), 3044 song_count: Some(song_count as i32), 3045 album_count: Some(album_count as i32), 3046 child_count: Some(song_count as i32), 3047 ..Default::default() 3048 }) 3049} 3050 3051pub async fn genres_list( 3052 _user: AuthedUser, 3053 state: web::Data<JellyfinState>, 3054 req: HttpRequest, 3055) -> HttpResponse { 3056 let q = parse_items_query(&req); 3057 let rows = repo::distinct_genres(&state.pool).await.unwrap_or_default(); 3058 // Apply NameStartsWith / SearchTerm from the query. 3059 let filtered: Vec<(String, i64, i64)> = rows 3060 .into_iter() 3061 .filter(|(n, _, _)| { 3062 if let Some(prefix) = q.name_starts_with.as_deref() { 3063 if !n 3064 .to_ascii_lowercase() 3065 .starts_with(&prefix.to_ascii_lowercase()) 3066 { 3067 return false; 3068 } 3069 } 3070 if let Some(term) = q.search_term.as_deref() { 3071 if !n.to_ascii_lowercase().contains(&term.to_ascii_lowercase()) { 3072 return false; 3073 } 3074 } 3075 true 3076 }) 3077 .collect(); 3078 let total = filtered.len() as i32; 3079 let start = q.start_index.unwrap_or(0).max(0) as usize; 3080 let take = q.limit.map(|n| n.max(1) as usize).unwrap_or(usize::MAX); 3081 let mut dtos: Vec<BaseItemDto> = Vec::new(); 3082 for (name, song_count, album_count) in filtered.into_iter().skip(start).take(take) { 3083 let id = mapping::remember_genre(&state.pool, &name) 3084 .await 3085 .unwrap_or_else(|_| mapping::genre_guid(&name)); 3086 dtos.push(BaseItemDto { 3087 id, 3088 server_id: Some(state.server_id.clone()), 3089 name: Some(name.clone()), 3090 item_type: "MusicGenre", 3091 media_type: "Unknown", 3092 is_folder: Some(true), 3093 sort_name: Some(name), 3094 location_type: Some("FileSystem"), 3095 song_count: Some(song_count as i32), 3096 album_count: Some(album_count as i32), 3097 child_count: Some(song_count as i32), 3098 ..Default::default() 3099 }); 3100 } 3101 HttpResponse::Ok().json(ItemsResult { 3102 items: dtos, 3103 total_record_count: total, 3104 start_index: start as i32, 3105 }) 3106} 3107 3108pub async fn genre_by_name( 3109 _user: AuthedUser, 3110 state: web::Data<JellyfinState>, 3111 path: web::Path<String>, 3112) -> HttpResponse { 3113 let name = path.into_inner(); 3114 match genre_to_dto(&state, &name).await { 3115 Some(dto) => HttpResponse::Ok().json(dto), 3116 None => HttpResponse::NotFound().finish(), 3117 } 3118} 3119 3120async fn year_to_dto(state: &JellyfinState, year: i32) -> Option<BaseItemDto> { 3121 let (song_count, album_count) = repo::find_year_stats(&state.pool, year) 3122 .await 3123 .ok() 3124 .flatten()?; 3125 let id = mapping::remember_year(&state.pool, year) 3126 .await 3127 .unwrap_or_else(|_| mapping::year_guid(year)); 3128 let name = year.to_string(); 3129 Some(BaseItemDto { 3130 id, 3131 server_id: Some(state.server_id.clone()), 3132 name: Some(name.clone()), 3133 item_type: "Year", 3134 media_type: "Unknown", 3135 is_folder: Some(true), 3136 sort_name: Some(name), 3137 location_type: Some("FileSystem"), 3138 production_year: Some(year), 3139 song_count: Some(song_count as i32), 3140 album_count: Some(album_count as i32), 3141 child_count: Some((song_count + album_count) as i32), 3142 ..Default::default() 3143 }) 3144} 3145 3146pub async fn years_list( 3147 _user: AuthedUser, 3148 state: web::Data<JellyfinState>, 3149 req: HttpRequest, 3150) -> HttpResponse { 3151 let q = parse_items_query(&req); 3152 let years = repo::distinct_years(&state.pool).await.unwrap_or_default(); 3153 let total = years.len() as i32; 3154 let start = q.start_index.unwrap_or(0).max(0) as usize; 3155 let take = q.limit.map(|n| n.max(1) as usize).unwrap_or(usize::MAX); 3156 // sortOrder=Descending is common on the year rail — flip when asked. 3157 let ordered: Vec<i32> = if q 3158 .sort_order 3159 .as_deref() 3160 .map(|s| s.eq_ignore_ascii_case("Descending")) 3161 .unwrap_or(false) 3162 { 3163 years.into_iter().rev().collect() 3164 } else { 3165 years 3166 }; 3167 let mut dtos: Vec<BaseItemDto> = Vec::new(); 3168 for year in ordered.into_iter().skip(start).take(take) { 3169 if let Some(dto) = year_to_dto(&state, year).await { 3170 dtos.push(dto); 3171 } 3172 } 3173 HttpResponse::Ok().json(ItemsResult { 3174 items: dtos, 3175 total_record_count: total, 3176 start_index: start as i32, 3177 }) 3178} 3179 3180pub async fn year_by_value( 3181 _user: AuthedUser, 3182 state: web::Data<JellyfinState>, 3183 path: web::Path<i32>, 3184) -> HttpResponse { 3185 let year = path.into_inner(); 3186 match year_to_dto(&state, year).await { 3187 Some(dto) => HttpResponse::Ok().json(dto), 3188 None => HttpResponse::NotFound().finish(), 3189 } 3190} 3191 3192/// `/Persons` and `/Studios` — smolsonic doesn't scan Persons (composers, 3193/// performers) or Studios (labels) into the DB. Return an empty result so 3194/// clients render "no persons/studios" cleanly rather than 404-ing. 3195pub async fn persons_list(_user: AuthedUser) -> HttpResponse { 3196 HttpResponse::Ok().json(ItemsResult { 3197 items: vec![], 3198 total_record_count: 0, 3199 start_index: 0, 3200 }) 3201} 3202 3203pub async fn person_by_name(_user: AuthedUser, _path: web::Path<String>) -> HttpResponse { 3204 HttpResponse::NotFound().finish() 3205} 3206 3207pub async fn studios_list(_user: AuthedUser) -> HttpResponse { 3208 HttpResponse::Ok().json(ItemsResult { 3209 items: vec![], 3210 total_record_count: 0, 3211 start_index: 0, 3212 }) 3213} 3214 3215pub async fn studio_by_name(_user: AuthedUser, _path: web::Path<String>) -> HttpResponse { 3216 HttpResponse::NotFound().finish() 3217} 3218 3219// ── Images ─────────────────────────────────────────────────────────────────── 3220 3221fn cover_path_for(state: &JellyfinState, filename: &str) -> PathBuf { 3222 state.covers_dir.join(filename) 3223} 3224 3225/// Resolved image source: either a filename under `covers_dir` or an absolute 3226/// path that points at a sibling poster file (or an ffmpeg-generated thumb). 3227enum ImageSource { 3228 InCoversDir(String), 3229 Absolute(PathBuf), 3230} 3231 3232async fn resolve_image_source(state: &JellyfinState, guid: &str) -> Option<ImageSource> { 3233 let g = mapping::normalize_guid(guid); 3234 let (kind, native) = mapping::lookup(&state.pool, &g).await.ok().flatten()?; 3235 match kind.as_str() { 3236 "album" => repo::find_album(&state.pool, &native) 3237 .await 3238 .ok() 3239 .flatten() 3240 .and_then(|a| a.cover_art) 3241 .map(ImageSource::InCoversDir), 3242 "song" => { 3243 let song = repo::find_song(&state.pool, &native).await.ok().flatten()?; 3244 let filename = if song.cover_art.is_some() { 3245 song.cover_art 3246 } else { 3247 repo::find_album(&state.pool, &song.album_id) 3248 .await 3249 .ok() 3250 .flatten() 3251 .and_then(|a| a.cover_art) 3252 }?; 3253 Some(ImageSource::InCoversDir(filename)) 3254 } 3255 "artist" => repo::albums_by_artist(&state.pool, &native) 3256 .await 3257 .ok() 3258 .unwrap_or_default() 3259 .into_iter() 3260 .find_map(|a| a.cover_art) 3261 .map(ImageSource::InCoversDir), 3262 "video" => repo::find_video(&state.pool, &native) 3263 .await 3264 .ok() 3265 .flatten() 3266 .and_then(|v| v.poster_path) 3267 .map(|p| ImageSource::Absolute(PathBuf::from(p))), 3268 _ => None, 3269 } 3270} 3271 3272pub async fn item_image( 3273 state: web::Data<JellyfinState>, 3274 path: web::Path<(String, String)>, 3275) -> HttpResponse { 3276 let (item_guid, _kind) = path.into_inner(); 3277 serve_cover(&state, &item_guid).await 3278} 3279 3280pub async fn item_image_by_index( 3281 state: web::Data<JellyfinState>, 3282 path: web::Path<(String, String, i32)>, 3283) -> HttpResponse { 3284 let (item_guid, _kind, _idx) = path.into_inner(); 3285 serve_cover(&state, &item_guid).await 3286} 3287 3288async fn serve_cover(state: &JellyfinState, item_guid: &str) -> HttpResponse { 3289 let Some(source) = resolve_image_source(state, item_guid).await else { 3290 return HttpResponse::NotFound().finish(); 3291 }; 3292 let full = match source { 3293 ImageSource::InCoversDir(filename) => cover_path_for(state, &filename), 3294 ImageSource::Absolute(p) => p, 3295 }; 3296 match std::fs::read(&full) { 3297 Ok(data) => { 3298 let mime = mime_guess::from_path(&full) 3299 .first_or_octet_stream() 3300 .to_string(); 3301 HttpResponse::Ok().content_type(mime).body(data) 3302 } 3303 Err(e) => { 3304 tracing::warn!("jellyfin: image read {}: {e}", full.display()); 3305 HttpResponse::NotFound().finish() 3306 } 3307 } 3308} 3309 3310// ── PlaybackInfo ───────────────────────────────────────────────────────────── 3311 3312pub async fn playback_info( 3313 _user: AuthedUser, 3314 state: web::Data<JellyfinState>, 3315 path: web::Path<String>, 3316) -> HttpResponse { 3317 let g = mapping::normalize_guid(&path.into_inner()); 3318 let Some((kind, native)) = resolve_native(&state, &g).await else { 3319 return HttpResponse::NotFound().finish(); 3320 }; 3321 let media_sources = match kind.as_str() { 3322 "song" => { 3323 let Ok(Some(song)) = repo::find_song(&state.pool, &native).await else { 3324 return HttpResponse::NotFound().finish(); 3325 }; 3326 song_to_dto(&state, &song).await.media_sources 3327 } 3328 "video" => { 3329 let Ok(Some(video)) = repo::find_video(&state.pool, &native).await else { 3330 return HttpResponse::NotFound().finish(); 3331 }; 3332 video_to_dto(&state, &video).await.media_sources 3333 } 3334 _ => return HttpResponse::BadRequest().finish(), 3335 }; 3336 HttpResponse::Ok().json(PlaybackInfoResponse { 3337 media_sources: media_sources.unwrap_or_default(), 3338 play_session_id: Some(auth::random_hex(8)), 3339 }) 3340} 3341 3342// ── Audio stream ───────────────────────────────────────────────────────────── 3343 3344async fn stream_by_guid(state: &JellyfinState, guid: &str, req: &HttpRequest) -> HttpResponse { 3345 // Streaming endpoints accept api_key as a query param, so we authorize 3346 // here instead of through the FromRequest extractor. 3347 let token = auth::extract_token(req); 3348 let authorized = match token { 3349 Some(t) => auth::token_valid(&state.pool, &t).await, 3350 None => false, 3351 }; 3352 if !authorized { 3353 return HttpResponse::Unauthorized().finish(); 3354 } 3355 3356 let g = mapping::normalize_guid(guid); 3357 let Some((kind, native)) = resolve_native(state, &g).await else { 3358 return HttpResponse::NotFound().finish(); 3359 }; 3360 if kind != "song" { 3361 return HttpResponse::BadRequest().finish(); 3362 } 3363 let Ok(Some(song)) = repo::find_song(&state.pool, &native).await else { 3364 return HttpResponse::NotFound().finish(); 3365 }; 3366 serve_song(&song, req) 3367} 3368 3369pub async fn audio_stream( 3370 state: web::Data<JellyfinState>, 3371 path: web::Path<String>, 3372 req: HttpRequest, 3373) -> HttpResponse { 3374 stream_by_guid(&state, &path.into_inner(), &req).await 3375} 3376 3377pub async fn audio_stream_ext( 3378 state: web::Data<JellyfinState>, 3379 path: web::Path<(String, String)>, 3380 req: HttpRequest, 3381) -> HttpResponse { 3382 let (id, _ext) = path.into_inner(); 3383 stream_by_guid(&state, &id, &req).await 3384} 3385 3386pub async fn audio_universal( 3387 state: web::Data<JellyfinState>, 3388 path: web::Path<String>, 3389 req: HttpRequest, 3390) -> HttpResponse { 3391 stream_by_guid(&state, &path.into_inner(), &req).await 3392} 3393 3394fn serve_song(song: &Song, req: &HttpRequest) -> HttpResponse { 3395 serve_file(&song.path, &song.content_type, req) 3396} 3397 3398fn video_content_type(container: &str) -> &'static str { 3399 match container { 3400 "mp4" | "m4v" => "video/mp4", 3401 "mkv" => "video/x-matroska", 3402 "webm" => "video/webm", 3403 "mov" => "video/quicktime", 3404 "avi" => "video/x-msvideo", 3405 _ => "application/octet-stream", 3406 } 3407} 3408 3409fn serve_file(path_str: &str, content_type: &str, req: &HttpRequest) -> HttpResponse { 3410 let path = PathBuf::from(path_str); 3411 let file_size = match std::fs::metadata(&path) { 3412 Ok(m) => m.len(), 3413 Err(e) => { 3414 tracing::error!("jellyfin stream stat {path_str}: {e}"); 3415 return HttpResponse::InternalServerError().finish(); 3416 } 3417 }; 3418 3419 if let Some(range_hdr) = req.headers().get(actix_web::http::header::RANGE) { 3420 if let Ok(range_str) = range_hdr.to_str() { 3421 if let Some(range) = range_str.strip_prefix("bytes=") { 3422 let mut parts = range.splitn(2, '-'); 3423 let start: u64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); 3424 let end: u64 = parts 3425 .next() 3426 .filter(|s| !s.is_empty()) 3427 .and_then(|s| s.parse().ok()) 3428 .unwrap_or(file_size.saturating_sub(1)) 3429 .min(file_size.saturating_sub(1)); 3430 if start <= end && file_size > 0 { 3431 use std::io::{Read, Seek, SeekFrom}; 3432 return match std::fs::File::open(&path) { 3433 Ok(mut file) => { 3434 let _ = file.seek(SeekFrom::Start(start)); 3435 let length = (end - start + 1) as usize; 3436 let mut buf = vec![0u8; length]; 3437 let n = file.read(&mut buf).unwrap_or(0); 3438 buf.truncate(n); 3439 let actual_end = start + n as u64 - 1; 3440 HttpResponse::PartialContent() 3441 .content_type(content_type.to_string()) 3442 .insert_header(("Accept-Ranges", "bytes")) 3443 .insert_header(("Content-Length", n.to_string())) 3444 .insert_header(( 3445 "Content-Range", 3446 format!("bytes {}-{}/{}", start, actual_end, file_size), 3447 )) 3448 .body(buf) 3449 } 3450 Err(e) => { 3451 tracing::error!("jellyfin stream open {path_str}: {e}"); 3452 HttpResponse::InternalServerError().finish() 3453 } 3454 }; 3455 } 3456 } 3457 } 3458 } 3459 3460 match std::fs::read(&path) { 3461 Ok(data) => HttpResponse::Ok() 3462 .content_type(content_type.to_string()) 3463 .insert_header(("Accept-Ranges", "bytes")) 3464 .insert_header(("Content-Length", file_size.to_string())) 3465 .body(data), 3466 Err(e) => { 3467 tracing::error!("jellyfin stream read {path_str}: {e}"); 3468 HttpResponse::InternalServerError().finish() 3469 } 3470 } 3471} 3472 3473// ── Lyric ──────────────────────────────────────────────────────────────────── 3474// 3475// Spec (Jellyfin OpenAPI, Lyric tag): 3476// GET /Audio/{itemId}/Lyrics → LyricDto 3477// POST /Audio/{itemId}/Lyrics?fileName=… (body text/…) → LyricDto 3478// DELETE /Audio/{itemId}/Lyrics → 204 3479// GET /Audio/{itemId}/RemoteSearch/Lyrics → [] 3480// POST /Audio/{itemId}/RemoteSearch/Lyrics/{lyricId} → 404 3481// GET /Providers/Lyrics/{lyricId} → 404 3482// 3483// smolsonic reads/writes a `.lrc` sidecar next to the audio file. The three 3484// remote endpoints are accepted for spec-compliance but always return 3485// empty / 404 — we have no remote lyric provider. 3486 3487async fn audio_native_song( 3488 state: &JellyfinState, 3489 guid: &str, 3490) -> Result<crate::models::Song, HttpResponse> { 3491 let g = mapping::normalize_guid(guid); 3492 let Some((kind, native)) = resolve_native(state, &g).await else { 3493 return Err(HttpResponse::NotFound().finish()); 3494 }; 3495 if kind != "song" { 3496 return Err(HttpResponse::NotFound().finish()); 3497 } 3498 match repo::find_song(&state.pool, &native).await { 3499 Ok(Some(s)) => Ok(s), 3500 _ => Err(HttpResponse::NotFound().finish()), 3501 } 3502} 3503 3504pub async fn get_lyrics( 3505 _user: AuthedUser, 3506 state: web::Data<JellyfinState>, 3507 path: web::Path<String>, 3508) -> HttpResponse { 3509 let song = match audio_native_song(&state, &path.into_inner()).await { 3510 Ok(s) => s, 3511 Err(resp) => return resp, 3512 }; 3513 let audio_path = std::path::Path::new(&song.path); 3514 let Some(sidecar) = super::lyrics::find_sidecar(audio_path) else { 3515 return HttpResponse::NotFound().finish(); 3516 }; 3517 let Ok(source) = std::fs::read_to_string(&sidecar) else { 3518 tracing::warn!( 3519 "jellyfin: failed to read lyric sidecar {}", 3520 sidecar.display() 3521 ); 3522 return HttpResponse::NotFound().finish(); 3523 }; 3524 HttpResponse::Ok().json(super::lyrics::parse_lrc(&source)) 3525} 3526 3527#[derive(Debug, Deserialize)] 3528#[allow(dead_code)] // `file_name` is required by the spec but we key on the audio file's stem. 3529pub struct UploadLyricsQuery { 3530 #[serde(default, alias = "FileName", rename = "fileName")] 3531 pub file_name: Option<String>, 3532} 3533 3534pub async fn upload_lyrics( 3535 _user: AuthedUser, 3536 state: web::Data<JellyfinState>, 3537 path: web::Path<String>, 3538 _query: web::Query<UploadLyricsQuery>, 3539 body: web::Bytes, 3540) -> HttpResponse { 3541 let song = match audio_native_song(&state, &path.into_inner()).await { 3542 Ok(s) => s, 3543 Err(resp) => return resp, 3544 }; 3545 let sidecar = super::lyrics::sidecar_path(std::path::Path::new(&song.path)); 3546 if let Err(e) = std::fs::write(&sidecar, &body) { 3547 tracing::error!("jellyfin: write lyric sidecar {}: {e}", sidecar.display()); 3548 return HttpResponse::InternalServerError().finish(); 3549 } 3550 let source = String::from_utf8_lossy(&body).into_owned(); 3551 HttpResponse::Ok().json(super::lyrics::parse_lrc(&source)) 3552} 3553 3554pub async fn delete_lyrics( 3555 _user: AuthedUser, 3556 state: web::Data<JellyfinState>, 3557 path: web::Path<String>, 3558) -> HttpResponse { 3559 let song = match audio_native_song(&state, &path.into_inner()).await { 3560 Ok(s) => s, 3561 Err(resp) => return resp, 3562 }; 3563 let audio_path = std::path::Path::new(&song.path); 3564 let Some(sidecar) = super::lyrics::find_sidecar(audio_path) else { 3565 // No sidecar → treat DELETE as a success no-op. Real Jellyfin 3566 // returns 204 in this case as well. 3567 return HttpResponse::NoContent().finish(); 3568 }; 3569 if let Err(e) = std::fs::remove_file(&sidecar) { 3570 tracing::error!("jellyfin: delete lyric sidecar {}: {e}", sidecar.display()); 3571 return HttpResponse::InternalServerError().finish(); 3572 } 3573 HttpResponse::NoContent().finish() 3574} 3575 3576/// `GET /Audio/{itemId}/RemoteSearch/Lyrics` — smolsonic has no remote 3577/// provider; return an empty array so the client's "search online" button 3578/// stops spinning cleanly instead of erroring. 3579pub async fn remote_search_lyrics( 3580 _user: AuthedUser, 3581 state: web::Data<JellyfinState>, 3582 path: web::Path<String>, 3583) -> HttpResponse { 3584 // Still validate the itemId — a 404 for an unknown GUID matches how 3585 // real Jellyfin behaves before it hits the provider layer. 3586 match audio_native_song(&state, &path.into_inner()).await { 3587 Ok(_) => HttpResponse::Ok().json(Vec::<Value>::new()), 3588 Err(resp) => resp, 3589 } 3590} 3591 3592pub async fn download_remote_lyric( 3593 _user: AuthedUser, 3594 _state: web::Data<JellyfinState>, 3595 _path: web::Path<(String, String)>, 3596) -> HttpResponse { 3597 HttpResponse::NotFound().finish() 3598} 3599 3600pub async fn get_remote_lyric( 3601 _user: AuthedUser, 3602 _state: web::Data<JellyfinState>, 3603 _path: web::Path<String>, 3604) -> HttpResponse { 3605 HttpResponse::NotFound().finish() 3606} 3607 3608// ── Video stream ───────────────────────────────────────────────────────────── 3609 3610async fn video_by_guid(state: &JellyfinState, guid: &str, req: &HttpRequest) -> HttpResponse { 3611 let token = auth::extract_token(req); 3612 let authorized = match token { 3613 Some(t) => auth::token_valid(&state.pool, &t).await, 3614 None => false, 3615 }; 3616 if !authorized { 3617 return HttpResponse::Unauthorized().finish(); 3618 } 3619 3620 let g = mapping::normalize_guid(guid); 3621 let Some((kind, native)) = resolve_native(state, &g).await else { 3622 return HttpResponse::NotFound().finish(); 3623 }; 3624 if kind != "video" { 3625 return HttpResponse::BadRequest().finish(); 3626 } 3627 let Ok(Some(video)) = repo::find_video(&state.pool, &native).await else { 3628 return HttpResponse::NotFound().finish(); 3629 }; 3630 serve_file(&video.path, video_content_type(&video.container), req) 3631} 3632 3633pub async fn video_stream( 3634 state: web::Data<JellyfinState>, 3635 path: web::Path<String>, 3636 req: HttpRequest, 3637) -> HttpResponse { 3638 video_by_guid(&state, &path.into_inner(), &req).await 3639} 3640 3641/// `GET /Items/{id}/File` — Finamp / just_audio stream the original file via 3642/// this endpoint, passing the token as `?ApiKey=`. Dispatches to the song or 3643/// video file streamer based on what kind of item the GUID points to. 3644pub async fn item_file_stream( 3645 state: web::Data<JellyfinState>, 3646 path: web::Path<String>, 3647 req: HttpRequest, 3648) -> HttpResponse { 3649 let token = auth::extract_token(&req); 3650 let authorized = match token { 3651 Some(t) => auth::token_valid(&state.pool, &t).await, 3652 None => false, 3653 }; 3654 if !authorized { 3655 return HttpResponse::Unauthorized().finish(); 3656 } 3657 3658 let g = mapping::normalize_guid(&path.into_inner()); 3659 let Some((kind, native)) = resolve_native(&state, &g).await else { 3660 return HttpResponse::NotFound().finish(); 3661 }; 3662 match kind.as_str() { 3663 "song" => match repo::find_song(&state.pool, &native).await { 3664 Ok(Some(s)) => serve_song(&s, &req), 3665 _ => HttpResponse::NotFound().finish(), 3666 }, 3667 "video" => match repo::find_video(&state.pool, &native).await { 3668 Ok(Some(v)) => serve_file(&v.path, video_content_type(&v.container), &req), 3669 _ => HttpResponse::NotFound().finish(), 3670 }, 3671 _ => HttpResponse::NotFound().finish(), 3672 } 3673} 3674 3675pub async fn video_stream_ext( 3676 state: web::Data<JellyfinState>, 3677 path: web::Path<(String, String)>, 3678 req: HttpRequest, 3679) -> HttpResponse { 3680 let (id, _ext) = path.into_inner(); 3681 video_by_guid(&state, &id, &req).await 3682} 3683 3684// ── Scrobble / sessions ────────────────────────────────────────────────────── 3685 3686pub async fn sessions_capabilities(_user: AuthedUser) -> HttpResponse { 3687 HttpResponse::NoContent().finish() 3688} 3689 3690/// `GET /Sessions?activeWithinSeconds=N` — Streamyfin polls this every few 3691/// seconds. We don't track active sessions; an empty list keeps the client 3692/// polling cleanly instead of 404-ing. 3693pub async fn sessions_list(_user: AuthedUser) -> HttpResponse { 3694 HttpResponse::Ok().json(Vec::<Value>::new()) 3695} 3696 3697pub async fn sessions_playing(_user: AuthedUser, _body: web::Json<Value>) -> HttpResponse { 3698 HttpResponse::NoContent().finish() 3699} 3700 3701pub async fn sessions_playing_progress(_user: AuthedUser, _body: web::Json<Value>) -> HttpResponse { 3702 HttpResponse::NoContent().finish() 3703} 3704 3705pub async fn sessions_playing_stopped(_user: AuthedUser, _body: web::Json<Value>) -> HttpResponse { 3706 HttpResponse::NoContent().finish() 3707} 3708 3709// ── Favorites ─────────────────────────────────────────────────────────────── 3710// 3711// Spec (Jellyfin OpenAPI, UserLibrary tag): 3712// POST /UserFavoriteItems/{itemId}?userId=… → UserItemDataDto 3713// DELETE /UserFavoriteItems/{itemId}?userId=… → UserItemDataDto 3714// 3715// Older clients still hit the legacy per-user variant: 3716// POST /Users/{userId}/FavoriteItems/{itemId} → UserItemDataDto 3717// DELETE /Users/{userId}/FavoriteItems/{itemId} → UserItemDataDto 3718// 3719// Both mutate a single row in the `starred` sidecar. smolsonic is 3720// single-user, so the `userId` param is accepted but not consulted — the 3721// favorite is scoped to the sole account. 3722 3723/// Resolve the Jellyfin GUID at the end of the path back to a native id and 3724/// its kind. Returns 404 if the GUID isn't in `jf_guids`. 3725async fn favorite_native( 3726 state: &JellyfinState, 3727 item_guid: &str, 3728) -> Result<(String, String), HttpResponse> { 3729 let g = mapping::normalize_guid(item_guid); 3730 match resolve_native(state, &g).await { 3731 Some(pair) => Ok(pair), 3732 None => Err(HttpResponse::NotFound().finish()), 3733 } 3734} 3735 3736async fn set_favorite( 3737 state: web::Data<JellyfinState>, 3738 item_guid: String, 3739 starred: bool, 3740) -> HttpResponse { 3741 let (_kind, native) = match favorite_native(&state, &item_guid).await { 3742 Ok(pair) => pair, 3743 Err(resp) => return resp, 3744 }; 3745 let now = now_iso(); 3746 let result = if starred { 3747 repo::star(&state.pool, &native, &now).await 3748 } else { 3749 repo::unstar(&state.pool, &native).await 3750 }; 3751 if let Err(e) = result { 3752 tracing::error!("jellyfin: favorite mutation on {native}: {e}"); 3753 return HttpResponse::InternalServerError().finish(); 3754 } 3755 let dashed = mapping::normalize_guid(&item_guid); 3756 HttpResponse::Ok().json(build_user_data(&state, &native, dashed).await) 3757} 3758 3759pub async fn add_favorite_item( 3760 _user: AuthedUser, 3761 state: web::Data<JellyfinState>, 3762 path: web::Path<String>, 3763) -> HttpResponse { 3764 set_favorite(state, path.into_inner(), true).await 3765} 3766 3767pub async fn remove_favorite_item( 3768 _user: AuthedUser, 3769 state: web::Data<JellyfinState>, 3770 path: web::Path<String>, 3771) -> HttpResponse { 3772 set_favorite(state, path.into_inner(), false).await 3773} 3774 3775pub async fn add_user_favorite_item( 3776 _user: AuthedUser, 3777 state: web::Data<JellyfinState>, 3778 path: web::Path<(String, String)>, 3779) -> HttpResponse { 3780 let (_user_id, item_id) = path.into_inner(); 3781 set_favorite(state, item_id, true).await 3782} 3783 3784pub async fn remove_user_favorite_item( 3785 _user: AuthedUser, 3786 state: web::Data<JellyfinState>, 3787 path: web::Path<(String, String)>, 3788) -> HttpResponse { 3789 let (_user_id, item_id) = path.into_inner(); 3790 set_favorite(state, item_id, false).await 3791} 3792 3793// ── UserData / PlayedItems / Rating ───────────────────────────────────────── 3794// 3795// Spec (Jellyfin OpenAPI, UserLibrary tag): 3796// GET /UserItems/{itemId}/UserData → UserItemDataDto 3797// POST /UserItems/{itemId}/UserData → UserItemDataDto (body: UpdateUserItemDataDto) 3798// POST /UserPlayedItems/{itemId}?datePlayed= → UserItemDataDto (mark played, PlayCount++) 3799// DELETE /UserPlayedItems/{itemId} → UserItemDataDto (mark unplayed) 3800// POST /UserItems/{itemId}/Rating?likes=… → UserItemDataDto (set Likes) 3801// DELETE /UserItems/{itemId}/Rating → UserItemDataDto (clear Likes) 3802// 3803// Legacy per-user forms hit by older clients (Symfonium, Finamp <=1.9): 3804// POST /Users/{userId}/PlayedItems/{itemId} 3805// DELETE /Users/{userId}/PlayedItems/{itemId} 3806// POST /Users/{userId}/Items/{itemId}/UserData 3807// GET /Users/{userId}/Items/{itemId}/UserData 3808// POST /Users/{userId}/Items/{itemId}/Rating 3809// DELETE /Users/{userId}/Items/{itemId}/Rating 3810// 3811// All state lives in the `user_item_data` sidecar keyed by native id; the 3812// `userId` param is accepted but not consulted (smolsonic is single-user). 3813 3814/// Resolve `item_guid` to (native_id, dashed jf guid), 404 if unknown. 3815async fn user_data_target( 3816 state: &JellyfinState, 3817 item_guid: &str, 3818) -> Result<(String, String), HttpResponse> { 3819 let g = mapping::normalize_guid(item_guid); 3820 match resolve_native(state, &g).await { 3821 Some((_kind, native)) => Ok((native, g)), 3822 None => Err(HttpResponse::NotFound().finish()), 3823 } 3824} 3825 3826async fn respond_user_data(state: &JellyfinState, native: &str, jf_guid: String) -> HttpResponse { 3827 HttpResponse::Ok().json(build_user_data(state, native, jf_guid).await) 3828} 3829 3830/// `?likes=true|false` — query param for POST /UserItems/{id}/Rating (and 3831/// its legacy per-user variant). 3832#[derive(Debug, Deserialize)] 3833pub struct RatingQuery { 3834 #[serde(default, alias = "Likes")] 3835 pub likes: Option<bool>, 3836} 3837 3838/// `?datePlayed=…` — optional query param for POST /UserPlayedItems/{id} 3839/// (and the legacy `/Users/{uid}/PlayedItems/{id}` variant). When present 3840/// it overrides `now_iso()` as the stamped `LastPlayedDate`. 3841#[derive(Debug, Deserialize)] 3842pub struct PlayedQuery { 3843 #[serde(default, alias = "DatePlayed", rename = "datePlayed")] 3844 pub date_played: Option<String>, 3845} 3846 3847pub async fn get_user_item_data_endpoint( 3848 _user: AuthedUser, 3849 state: web::Data<JellyfinState>, 3850 path: web::Path<String>, 3851) -> HttpResponse { 3852 let (native, guid) = match user_data_target(&state, &path.into_inner()).await { 3853 Ok(t) => t, 3854 Err(resp) => return resp, 3855 }; 3856 respond_user_data(&state, &native, guid).await 3857} 3858 3859pub async fn update_user_item_data_endpoint( 3860 _user: AuthedUser, 3861 state: web::Data<JellyfinState>, 3862 path: web::Path<String>, 3863 body: web::Json<super::dto::UpdateUserItemDataDto>, 3864) -> HttpResponse { 3865 let (native, guid) = match user_data_target(&state, &path.into_inner()).await { 3866 Ok(t) => t, 3867 Err(resp) => return resp, 3868 }; 3869 if let Err(e) = apply_update(&state, &native, body.into_inner()).await { 3870 tracing::error!("jellyfin: user_item_data update on {native}: {e}"); 3871 return HttpResponse::InternalServerError().finish(); 3872 } 3873 respond_user_data(&state, &native, guid).await 3874} 3875 3876/// Legacy `POST /Users/{userId}/Items/{itemId}/UserData`. Same payload as 3877/// the spec-form endpoint above. 3878pub async fn update_user_item_data_legacy( 3879 _user: AuthedUser, 3880 state: web::Data<JellyfinState>, 3881 path: web::Path<(String, String)>, 3882 body: web::Json<super::dto::UpdateUserItemDataDto>, 3883) -> HttpResponse { 3884 let (_user_id, item_id) = path.into_inner(); 3885 let (native, guid) = match user_data_target(&state, &item_id).await { 3886 Ok(t) => t, 3887 Err(resp) => return resp, 3888 }; 3889 if let Err(e) = apply_update(&state, &native, body.into_inner()).await { 3890 tracing::error!("jellyfin: user_item_data update on {native}: {e}"); 3891 return HttpResponse::InternalServerError().finish(); 3892 } 3893 respond_user_data(&state, &native, guid).await 3894} 3895 3896pub async fn get_user_item_data_legacy( 3897 _user: AuthedUser, 3898 state: web::Data<JellyfinState>, 3899 path: web::Path<(String, String)>, 3900) -> HttpResponse { 3901 let (_user_id, item_id) = path.into_inner(); 3902 let (native, guid) = match user_data_target(&state, &item_id).await { 3903 Ok(t) => t, 3904 Err(resp) => return resp, 3905 }; 3906 respond_user_data(&state, &native, guid).await 3907} 3908 3909async fn apply_update( 3910 state: &JellyfinState, 3911 native: &str, 3912 body: super::dto::UpdateUserItemDataDto, 3913) -> anyhow::Result<()> { 3914 // Convert the nullable-everywhere request DTO into the two-level Option 3915 // shape repo::update_user_item_data expects: outer `Some` means "present 3916 // in the body", inner value = "the value to store" (including `None` for 3917 // nullable-clear on last_played_date/rating/likes). 3918 let update = repo::UserItemDataUpdate { 3919 played: body.played, 3920 play_count: body.play_count, 3921 playback_position_ticks: body.playback_position_ticks, 3922 // Body carries a single `Option<String>` — client can't distinguish 3923 // "leave alone" from "clear" here without a signal channel, so we 3924 // treat any present body as "write this value" (which may be None). 3925 last_played_date: Some(body.last_played_date), 3926 rating: Some(body.rating), 3927 likes: Some(body.likes), 3928 }; 3929 repo::update_user_item_data(&state.pool, native, update).await?; 3930 // Favorite state is stored in `starred` — keep the two tables in sync so 3931 // the response reflects the requested toggle. 3932 if let Some(fav) = body.is_favorite { 3933 let now = now_iso(); 3934 if fav { 3935 repo::star(&state.pool, native, &now).await?; 3936 } else { 3937 repo::unstar(&state.pool, native).await?; 3938 } 3939 } 3940 Ok(()) 3941} 3942 3943pub async fn mark_played_endpoint( 3944 _user: AuthedUser, 3945 state: web::Data<JellyfinState>, 3946 path: web::Path<String>, 3947 query: web::Query<PlayedQuery>, 3948) -> HttpResponse { 3949 let (native, guid) = match user_data_target(&state, &path.into_inner()).await { 3950 Ok(t) => t, 3951 Err(resp) => return resp, 3952 }; 3953 let when = query.into_inner().date_played.unwrap_or_else(now_iso); 3954 if let Err(e) = repo::mark_played(&state.pool, &native, &when).await { 3955 tracing::error!("jellyfin: mark_played on {native}: {e}"); 3956 return HttpResponse::InternalServerError().finish(); 3957 } 3958 respond_user_data(&state, &native, guid).await 3959} 3960 3961pub async fn mark_unplayed_endpoint( 3962 _user: AuthedUser, 3963 state: web::Data<JellyfinState>, 3964 path: web::Path<String>, 3965) -> HttpResponse { 3966 let (native, guid) = match user_data_target(&state, &path.into_inner()).await { 3967 Ok(t) => t, 3968 Err(resp) => return resp, 3969 }; 3970 if let Err(e) = repo::mark_unplayed(&state.pool, &native).await { 3971 tracing::error!("jellyfin: mark_unplayed on {native}: {e}"); 3972 return HttpResponse::InternalServerError().finish(); 3973 } 3974 respond_user_data(&state, &native, guid).await 3975} 3976 3977/// `POST /Users/{userId}/PlayedItems/{itemId}` — legacy per-user variant. 3978pub async fn mark_played_legacy( 3979 _user: AuthedUser, 3980 state: web::Data<JellyfinState>, 3981 path: web::Path<(String, String)>, 3982 query: web::Query<PlayedQuery>, 3983) -> HttpResponse { 3984 let (_user_id, item_id) = path.into_inner(); 3985 let (native, guid) = match user_data_target(&state, &item_id).await { 3986 Ok(t) => t, 3987 Err(resp) => return resp, 3988 }; 3989 let when = query.into_inner().date_played.unwrap_or_else(now_iso); 3990 if let Err(e) = repo::mark_played(&state.pool, &native, &when).await { 3991 tracing::error!("jellyfin: mark_played on {native}: {e}"); 3992 return HttpResponse::InternalServerError().finish(); 3993 } 3994 respond_user_data(&state, &native, guid).await 3995} 3996 3997pub async fn mark_unplayed_legacy( 3998 _user: AuthedUser, 3999 state: web::Data<JellyfinState>, 4000 path: web::Path<(String, String)>, 4001) -> HttpResponse { 4002 let (_user_id, item_id) = path.into_inner(); 4003 let (native, guid) = match user_data_target(&state, &item_id).await { 4004 Ok(t) => t, 4005 Err(resp) => return resp, 4006 }; 4007 if let Err(e) = repo::mark_unplayed(&state.pool, &native).await { 4008 tracing::error!("jellyfin: mark_unplayed on {native}: {e}"); 4009 return HttpResponse::InternalServerError().finish(); 4010 } 4011 respond_user_data(&state, &native, guid).await 4012} 4013 4014pub async fn set_rating_endpoint( 4015 _user: AuthedUser, 4016 state: web::Data<JellyfinState>, 4017 path: web::Path<String>, 4018 query: web::Query<RatingQuery>, 4019) -> HttpResponse { 4020 let (native, guid) = match user_data_target(&state, &path.into_inner()).await { 4021 Ok(t) => t, 4022 Err(resp) => return resp, 4023 }; 4024 if let Err(e) = repo::set_likes(&state.pool, &native, query.into_inner().likes).await { 4025 tracing::error!("jellyfin: set_likes on {native}: {e}"); 4026 return HttpResponse::InternalServerError().finish(); 4027 } 4028 respond_user_data(&state, &native, guid).await 4029} 4030 4031pub async fn clear_rating_endpoint( 4032 _user: AuthedUser, 4033 state: web::Data<JellyfinState>, 4034 path: web::Path<String>, 4035) -> HttpResponse { 4036 let (native, guid) = match user_data_target(&state, &path.into_inner()).await { 4037 Ok(t) => t, 4038 Err(resp) => return resp, 4039 }; 4040 if let Err(e) = repo::set_likes(&state.pool, &native, None).await { 4041 tracing::error!("jellyfin: clear_likes on {native}: {e}"); 4042 return HttpResponse::InternalServerError().finish(); 4043 } 4044 respond_user_data(&state, &native, guid).await 4045} 4046 4047pub async fn set_rating_legacy( 4048 _user: AuthedUser, 4049 state: web::Data<JellyfinState>, 4050 path: web::Path<(String, String)>, 4051 query: web::Query<RatingQuery>, 4052) -> HttpResponse { 4053 let (_user_id, item_id) = path.into_inner(); 4054 let (native, guid) = match user_data_target(&state, &item_id).await { 4055 Ok(t) => t, 4056 Err(resp) => return resp, 4057 }; 4058 if let Err(e) = repo::set_likes(&state.pool, &native, query.into_inner().likes).await { 4059 tracing::error!("jellyfin: set_likes on {native}: {e}"); 4060 return HttpResponse::InternalServerError().finish(); 4061 } 4062 respond_user_data(&state, &native, guid).await 4063} 4064 4065pub async fn clear_rating_legacy( 4066 _user: AuthedUser, 4067 state: web::Data<JellyfinState>, 4068 path: web::Path<(String, String)>, 4069) -> HttpResponse { 4070 let (_user_id, item_id) = path.into_inner(); 4071 let (native, guid) = match user_data_target(&state, &item_id).await { 4072 Ok(t) => t, 4073 Err(resp) => return resp, 4074 }; 4075 if let Err(e) = repo::set_likes(&state.pool, &native, None).await { 4076 tracing::error!("jellyfin: clear_likes on {native}: {e}"); 4077 return HttpResponse::InternalServerError().finish(); 4078 } 4079 respond_user_data(&state, &native, guid).await 4080} 4081 4082// ── Misc stubs that some clients probe ────────────────────────────────────── 4083 4084pub async fn empty_array() -> HttpResponse { 4085 HttpResponse::Ok().json(Vec::<Value>::new()) 4086} 4087 4088/// 204 ack for endpoints we accept but have nothing to return (scheduled 4089/// tasks, scrobble pings, etc.). 4090pub async fn no_content() -> HttpResponse { 4091 HttpResponse::NoContent().finish() 4092} 4093 4094/// Routed 404 — same wire result as the default service, but bypasses the 4095/// `log_unrouted` warning. Use for endpoints that *should* respond 404 4096/// (e.g. a user with no avatar, an unsupported WebSocket upgrade) so the 4097/// log stays focused on genuinely unhandled paths. 4098pub async fn not_found() -> HttpResponse { 4099 HttpResponse::NotFound().finish() 4100} 4101 4102/// `GET/HEAD /System/Ping` — Jellyfin's heartbeat endpoint. Reference 4103/// server returns plain text "Jellyfin Server"; some clients (Moonfin, 4104/// official web) use this to decide if the server is reachable. 4105pub async fn system_ping() -> HttpResponse { 4106 HttpResponse::Ok() 4107 .content_type("text/plain; charset=utf-8") 4108 .body("Jellyfin Server") 4109} 4110 4111/// `POST /ScheduledTasks/Running/{id}` and `POST /Library/Refresh` — kick off 4112/// a library rescan in the background. Returns 204 immediately; the scan 4113/// continues asynchronously. If a scan is already in progress, this is a 4114/// cheap no-op (the scanner's `running` flag prevents overlap). 4115pub async fn trigger_library_scan( 4116 _user: AuthedUser, 4117 state: web::Data<JellyfinState>, 4118) -> HttpResponse { 4119 use std::sync::atomic::Ordering; 4120 4121 // Music 4122 if !state.music_scan_progress.running.load(Ordering::SeqCst) { 4123 let pool = state.pool.clone(); 4124 let music_dir = state.music_dir.clone(); 4125 let covers_dir = state.covers_dir.clone(); 4126 let progress = state.music_scan_progress.clone(); 4127 let ts = state.typesense.clone(); 4128 tokio::spawn(async move { 4129 tracing::info!("jellyfin: triggered music scan of {}", music_dir.display()); 4130 if let Err(e) = crate::scanner::scan(pool, music_dir, covers_dir, progress, ts).await { 4131 tracing::error!("jellyfin-triggered music scan failed: {e}"); 4132 } 4133 }); 4134 } else { 4135 tracing::debug!("jellyfin: music scan trigger ignored (already running)"); 4136 } 4137 4138 // Video (only if a video library is configured) 4139 if let Some(video_dir) = state.video_dir.clone() { 4140 if !state.video_scan_progress.running.load(Ordering::SeqCst) { 4141 let pool = state.pool.clone(); 4142 let covers_dir = state.covers_dir.clone(); 4143 let progress = state.video_scan_progress.clone(); 4144 tokio::spawn(async move { 4145 tracing::info!("jellyfin: triggered video scan of {}", video_dir.display()); 4146 if let Err(e) = 4147 crate::video_scanner::scan(pool, video_dir, covers_dir, progress).await 4148 { 4149 tracing::error!("jellyfin-triggered video scan failed: {e}"); 4150 } 4151 }); 4152 } 4153 } 4154 4155 HttpResponse::NoContent().finish() 4156} 4157 4158/// `/Items/Latest?userId=...&parentId=...&limit=N` — Findroid uses this for 4159/// the "Latest" rail on each library page. Returns up to `limit` items 4160/// inside the given parent (movies for the Movies library, albums for 4161/// Music). Response is a plain JSON array, not an `ItemsResult`. 4162pub async fn items_latest( 4163 _user: AuthedUser, 4164 state: web::Data<JellyfinState>, 4165 req: HttpRequest, 4166) -> HttpResponse { 4167 items_latest_impl(state, parse_items_query(&req)).await 4168} 4169 4170async fn items_latest_impl(state: web::Data<JellyfinState>, q: ItemsQuery) -> HttpResponse { 4171 let limit = q.limit.unwrap_or(16).max(1); 4172 4173 if let Some(parent) = q.parent_id.as_ref() { 4174 let g = mapping::normalize_guid(parent); 4175 if g == mapping::movies_library_guid() { 4176 let videos = repo::all_videos(&state.pool, limit, 0) 4177 .await 4178 .unwrap_or_default(); 4179 let mut dtos = Vec::with_capacity(videos.len()); 4180 for v in &videos { 4181 dtos.push(video_to_dto(&state, v).await); 4182 } 4183 return HttpResponse::Ok().json(dtos); 4184 } 4185 if g == mapping::library_guid() { 4186 let albums = repo::albums_paginated(&state.pool, "newest", limit, 0) 4187 .await 4188 .unwrap_or_default(); 4189 let mut dtos = Vec::with_capacity(albums.len()); 4190 for a in &albums { 4191 dtos.push(album_to_dto(&state, a).await); 4192 } 4193 return HttpResponse::Ok().json(dtos); 4194 } 4195 if g == mapping::playlists_library_guid() { 4196 let playlists = repo::all_playlists(&state.pool).await.unwrap_or_default(); 4197 let take = (limit as usize).min(playlists.len()); 4198 let mut dtos = Vec::with_capacity(take); 4199 for p in playlists.iter().take(take) { 4200 dtos.push(playlist_to_dto(&state, p).await); 4201 } 4202 return HttpResponse::Ok().json(dtos); 4203 } 4204 } 4205 if wants_videos(&q) { 4206 let videos = repo::all_videos(&state.pool, limit, 0) 4207 .await 4208 .unwrap_or_default(); 4209 let mut dtos = Vec::with_capacity(videos.len()); 4210 for v in &videos { 4211 dtos.push(video_to_dto(&state, v).await); 4212 } 4213 return HttpResponse::Ok().json(dtos); 4214 } 4215 HttpResponse::Ok().json(Vec::<Value>::new()) 4216} 4217 4218pub async fn empty_items() -> HttpResponse { 4219 HttpResponse::Ok().json(ItemsResult { 4220 items: vec![], 4221 total_record_count: 0, 4222 start_index: 0, 4223 }) 4224} 4225 4226/// `/Users/{uid}/Views/{view}/Latest` — legacy per-library latest rail. 4227/// The view id lives in the path here (not the query), so we override 4228/// `parent_id` on the parsed query and delegate to `items_latest`'s logic. 4229pub async fn view_latest( 4230 _user: AuthedUser, 4231 state: web::Data<JellyfinState>, 4232 path: web::Path<(String, String)>, 4233 req: HttpRequest, 4234) -> HttpResponse { 4235 let (_uid, view) = path.into_inner(); 4236 let mut q = parse_items_query(&req); 4237 q.parent_id = Some(view); 4238 items_latest_impl(state, q).await 4239} 4240 4241/// `/Items/Resume`, `/UserItems/Resume`, `/Users/{uid}/Items/Resume` — 4242/// items with a non-zero playback position, ordered by most-recently 4243/// played. Returned as a `BaseItemDtoQueryResult` per spec (not a bare 4244/// array like Latest). 4245pub async fn items_resume( 4246 _user: AuthedUser, 4247 state: web::Data<JellyfinState>, 4248 req: HttpRequest, 4249) -> HttpResponse { 4250 let q = parse_items_query(&req); 4251 let limit = q.limit.unwrap_or(12).max(1); 4252 4253 // Movies-library parent → resume videos; music parent (or no parent) → 4254 // resume songs. `?mediaTypes=Video` also asks for videos. 4255 let want_videos = q 4256 .parent_id 4257 .as_deref() 4258 .map(mapping::normalize_guid) 4259 .map(|g| g == mapping::movies_library_guid()) 4260 .unwrap_or(false) 4261 || includes(&q.media_types, "Video"); 4262 4263 if want_videos { 4264 let videos = repo::resume_videos(&state.pool, limit) 4265 .await 4266 .unwrap_or_default(); 4267 let mut dtos = Vec::with_capacity(videos.len()); 4268 for v in &videos { 4269 dtos.push(video_to_dto(&state, v).await); 4270 } 4271 let total = dtos.len() as i32; 4272 return HttpResponse::Ok().json(ItemsResult { 4273 items: dtos, 4274 total_record_count: total, 4275 start_index: 0, 4276 }); 4277 } 4278 4279 let songs = repo::resume_songs(&state.pool, limit) 4280 .await 4281 .unwrap_or_default(); 4282 let mut dtos = Vec::with_capacity(songs.len()); 4283 for s in &songs { 4284 dtos.push(song_to_dto(&state, s).await); 4285 } 4286 let total = dtos.len() as i32; 4287 HttpResponse::Ok().json(ItemsResult { 4288 items: dtos, 4289 total_record_count: total, 4290 start_index: 0, 4291 }) 4292} 4293 4294/// `/Items/Suggestions`, `/Users/{uid}/Items/Suggestions` — spec-defined 4295/// "you might like" rail. Returns random albums by default (or songs when 4296/// `?type=Audio` / `?mediaType=Audio`, or movies when `?type=Movie`). 4297pub async fn items_suggestions( 4298 _user: AuthedUser, 4299 state: web::Data<JellyfinState>, 4300 req: HttpRequest, 4301) -> HttpResponse { 4302 let q = parse_items_query(&req); 4303 let limit = q.limit.unwrap_or(12).max(1); 4304 4305 if includes(&q.include_item_types, "Audio") || includes(&q.media_types, "Audio") { 4306 let songs = repo::random_songs(&state.pool, limit, None, None, None) 4307 .await 4308 .unwrap_or_default(); 4309 let mut dtos = Vec::with_capacity(songs.len()); 4310 for s in &songs { 4311 dtos.push(song_to_dto(&state, s).await); 4312 } 4313 let total = dtos.len() as i32; 4314 return HttpResponse::Ok().json(ItemsResult { 4315 items: dtos, 4316 total_record_count: total, 4317 start_index: 0, 4318 }); 4319 } 4320 if wants_videos(&q) { 4321 // Use the same "random" approximation as MusicAlbum below — all_videos 4322 // is ordered by title but that's better than nothing. 4323 let videos = repo::all_videos(&state.pool, limit, 0) 4324 .await 4325 .unwrap_or_default(); 4326 let mut dtos = Vec::with_capacity(videos.len()); 4327 for v in &videos { 4328 dtos.push(video_to_dto(&state, v).await); 4329 } 4330 let total = dtos.len() as i32; 4331 return HttpResponse::Ok().json(ItemsResult { 4332 items: dtos, 4333 total_record_count: total, 4334 start_index: 0, 4335 }); 4336 } 4337 4338 // Default → random albums. Reuse `albums_paginated("random", …)` which 4339 // ORDER BY RANDOM()s the table. 4340 let albums = repo::albums_paginated(&state.pool, "random", limit, 0) 4341 .await 4342 .unwrap_or_default(); 4343 let mut dtos = Vec::with_capacity(albums.len()); 4344 for a in &albums { 4345 dtos.push(album_to_dto(&state, a).await); 4346 } 4347 let total = dtos.len() as i32; 4348 HttpResponse::Ok().json(ItemsResult { 4349 items: dtos, 4350 total_record_count: total, 4351 start_index: 0, 4352 }) 4353} 4354 4355/// `/UserViews?userId=...&includeHidden=...` — Findroid uses this instead of 4356/// the path-parametric `/Users/{id}/Views`. Same payload. 4357pub async fn user_views_query(_user: AuthedUser, state: web::Data<JellyfinState>) -> HttpResponse { 4358 let views = all_library_views(&state); 4359 let total = views.len() as i32; 4360 HttpResponse::Ok().json(ViewsResult { 4361 items: views, 4362 total_record_count: total, 4363 start_index: 0, 4364 }) 4365} 4366 4367#[derive(Debug, Deserialize)] 4368#[serde(default, rename_all = "camelCase")] 4369pub struct SearchHintsQuery { 4370 pub search_term: Option<String>, 4371 pub limit: Option<i64>, 4372 pub start_index: Option<i64>, 4373 pub include_item_types: Option<String>, 4374 pub user_id: Option<String>, 4375} 4376 4377impl Default for SearchHintsQuery { 4378 fn default() -> Self { 4379 Self { 4380 search_term: None, 4381 limit: None, 4382 start_index: None, 4383 include_item_types: None, 4384 user_id: None, 4385 } 4386 } 4387} 4388 4389/// `/Search/Hints?searchTerm=...` — backed by the existing FTS index. Returns 4390/// up to `limit` artist/album/song matches. 4391pub async fn search_hints( 4392 _user: AuthedUser, 4393 state: web::Data<JellyfinState>, 4394 query: web::Query<SearchHintsQuery>, 4395) -> HttpResponse { 4396 let q = query.into_inner(); 4397 let Some(term) = q.search_term.as_deref().filter(|s| !s.is_empty()) else { 4398 return HttpResponse::Ok().json(json!({ 4399 "SearchHints": [], 4400 "TotalRecordCount": 0, 4401 })); 4402 }; 4403 let want_artist = q 4404 .include_item_types 4405 .as_deref() 4406 .map(|s| { 4407 s.split(',') 4408 .any(|t| t.trim().eq_ignore_ascii_case("MusicArtist")) 4409 }) 4410 .unwrap_or(true); 4411 let want_album = q 4412 .include_item_types 4413 .as_deref() 4414 .map(|s| { 4415 s.split(',') 4416 .any(|t| t.trim().eq_ignore_ascii_case("MusicAlbum")) 4417 }) 4418 .unwrap_or(true); 4419 let want_song = q 4420 .include_item_types 4421 .as_deref() 4422 .map(|s| s.split(',').any(|t| t.trim().eq_ignore_ascii_case("Audio"))) 4423 .unwrap_or(true); 4424 4425 let limit = q.limit.unwrap_or(20).max(1); 4426 let mut hints: Vec<Value> = Vec::new(); 4427 let ts = state.typesense.as_deref(); 4428 4429 if want_artist { 4430 if let Ok(rows) = repo::search_artists(&state.pool, term, limit, 0, ts).await { 4431 for a in rows { 4432 let id = mapping::remember_artist(&state.pool, &a) 4433 .await 4434 .unwrap_or_else(|_| mapping::guid(mapping::KIND_ARTIST, &a.id)); 4435 hints.push(json!({ 4436 "ItemId": id.clone(), 4437 "Id": id, 4438 "Name": a.name, 4439 "Type": "MusicArtist", 4440 "MediaType": "Unknown", 4441 "IsFolder": true, 4442 })); 4443 } 4444 } 4445 } 4446 if want_album { 4447 if let Ok(rows) = repo::search_albums(&state.pool, term, limit, 0, ts).await { 4448 for al in rows { 4449 let id = mapping::remember_album(&state.pool, &al) 4450 .await 4451 .unwrap_or_else(|_| mapping::guid(mapping::KIND_ALBUM, &al.id)); 4452 hints.push(json!({ 4453 "ItemId": id.clone(), 4454 "Id": id, 4455 "Name": al.title, 4456 "Album": al.title, 4457 "AlbumArtist": al.artist, 4458 "Type": "MusicAlbum", 4459 "MediaType": "Unknown", 4460 "IsFolder": true, 4461 })); 4462 } 4463 } 4464 } 4465 if want_song { 4466 if let Ok(rows) = repo::search_songs(&state.pool, term, limit, 0, ts).await { 4467 for s in rows { 4468 let id = mapping::remember_song(&state.pool, &s) 4469 .await 4470 .unwrap_or_else(|_| mapping::guid(mapping::KIND_SONG, &s.id)); 4471 hints.push(json!({ 4472 "ItemId": id.clone(), 4473 "Id": id, 4474 "Name": s.title, 4475 "Album": s.album, 4476 "AlbumArtist": s.artist, 4477 "Type": "Audio", 4478 "MediaType": "Audio", 4479 "IsFolder": false, 4480 "RunTimeTicks": s.duration_ms * TICKS_PER_MS, 4481 })); 4482 } 4483 } 4484 } 4485 4486 let total = hints.len() as i32; 4487 HttpResponse::Ok().json(json!({ 4488 "SearchHints": hints, 4489 "TotalRecordCount": total, 4490 })) 4491} 4492 4493pub async fn displaypreferences(_user: AuthedUser) -> HttpResponse { 4494 HttpResponse::Ok().json(json!({ 4495 "Id": "", 4496 "ViewType": "", 4497 "SortBy": "SortName", 4498 "SortOrder": "Ascending", 4499 "RememberIndexing": false, 4500 "PrimaryImageHeight": 250, 4501 "PrimaryImageWidth": 250, 4502 "CustomPrefs": {}, 4503 "ScrollDirection": "Vertical", 4504 "ShowBackdrop": true, 4505 "RememberSorting": false, 4506 "IndexBy": "", 4507 "ShowSidebar": false, 4508 "Client": "" 4509 })) 4510} 4511 4512pub async fn branding_config() -> HttpResponse { 4513 HttpResponse::Ok().json(json!({ 4514 "LoginDisclaimer": "", 4515 "CustomCss": "", 4516 "SplashscreenEnabled": false, 4517 })) 4518}