A tiny Subsonic/Jellyfin/S3 server in Rust
navidrome
subsonic
s3
emby
jellyfin
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, ¤t).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}