diff --git a/README.md b/README.md index 4ded42b..a395d6e 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ Output: `Moonfin.Server-{VERSION}.zip` in the repo root. | `/Moonfin/Assets/{fileName}` | GET | Yes | Serve embedded rating icons | | `/Moonfin/MDBList/Batch` | POST | Yes | Batch fetch ratings for multiple items | | `/Moonfin/MDBList/{imdbId}` | GET | Yes | Get MDBList ratings for a single item | +| `/Moonfin/MediaBar` | GET | Yes | Get resolved media bar content for the current user | | `/Moonfin/TMDB/Episode/{seriesId}/{seasonNumber}/{episodeNumber}` | GET | Yes | Get TMDB episode rating | | `/SyncPlay/List` | GET | Yes | List available SyncPlay groups | | `/SyncPlay/New` | POST | Yes | Create a new SyncPlay group | @@ -280,7 +281,10 @@ Settings stored on the server per-user and shared across all Moonfin clients. Ea | `showLibrariesInToolbar` | bool | Show library buttons in toolbar | | `shuffleContentType` | string | Shuffle content type (`movies`, `tv`, `both`) | | `mediaBarEnabled` | bool | Enable featured media bar | -| `mediaBarContentType` | string | Media bar content type (`movies`, `tv`, `both`) | +| `mediaBarSourceType` | string | Media bar content source (`library`, `collection`) | +| `mediaBarLibraryIds` | list | Library IDs to pull media bar items from (empty = all libraries) | +| `mediaBarCollectionIds` | list | Collection/playlist IDs for media bar (when source is `collection`) | +| `mediaBarShuffleItems` | bool | Shuffle items in media bar | | `mediaBarItemCount` | int | Number of items in media bar | | `mediaBarOpacity` | int | Media bar overlay opacity (0–100) | | `mediaBarOverlayColor` | string | Media bar overlay color key | diff --git a/backend/Api/MoonfinController.cs b/backend/Api/MoonfinController.cs index 0c6c770..3aaad51 100644 --- a/backend/Api/MoonfinController.cs +++ b/backend/Api/MoonfinController.cs @@ -1,5 +1,9 @@ using System.Net.Mime; using System.Text.Json; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -18,6 +22,7 @@ public class MoonfinController : ControllerBase { private readonly MoonfinSettingsService _settingsService; private readonly IHttpClientFactory _httpClientFactory; + private readonly ILibraryManager _libraryManager; // Cache for auto-detected variant private static string? _cachedVariant; @@ -25,10 +30,14 @@ public class MoonfinController : ControllerBase private static DateTime _variantCacheExpiry = DateTime.MinValue; private static readonly SemaphoreSlim _variantLock = new(1, 1); - public MoonfinController(MoonfinSettingsService settingsService, IHttpClientFactory httpClientFactory) + public MoonfinController( + MoonfinSettingsService settingsService, + IHttpClientFactory httpClientFactory, + ILibraryManager libraryManager) { _settingsService = settingsService; _httpClientFactory = httpClientFactory; + _libraryManager = libraryManager; } /// @@ -417,6 +426,201 @@ public ActionResult CheckMySettingsExist() return NotFound(); } + /// + /// Gets resolved media bar content for the current user. + /// Combines user settings resolution with server-side item queries so all clients + /// (web, Android, TV) get identical results from a single call. + /// + /// Device profile name: desktop, mobile, tv, or global. + /// Media bar items as Jellyfin BaseItemDto objects. + [HttpGet("MediaBar")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task GetMediaBarItems( + [FromQuery] string profile = "global") + { + var userId = this.GetUserIdFromClaims(); + if (userId == null) + { + return Unauthorized(new { Error = "User not authenticated" }); + } + + // Resolve settings: device profile → global → admin defaults + var resolved = await _settingsService.GetResolvedProfileAsync(userId.Value, profile); + var settings = resolved ?? MoonfinPlugin.Instance?.Configuration?.DefaultUserSettings ?? new MoonfinSettingsProfile(); + + var sourceType = settings.MediaBarSourceType ?? "library"; + var limit = settings.MediaBarItemCount ?? 10; + + List items; + + if (sourceType == "collection" && settings.MediaBarCollectionIds is { Count: > 0 }) + { + items = GetCollectionItems(settings.MediaBarCollectionIds, limit); + } + else + { + items = GetLibraryItems(settings.MediaBarLibraryIds, limit); + } + + var dtos = items.Select(MapItemToDto).ToList(); + + return Ok(new + { + Items = dtos, + TotalRecordCount = dtos.Count + }); + } + + /// + /// Maps a BaseItem to a lightweight DTO matching Jellyfin's BaseItemDto shape. + /// Uses only stable BaseItem properties to avoid version-specific API issues. + /// + private static object MapItemToDto(BaseItem item) + { + // Build image tags dict + var imageTags = new Dictionary(); + var imageInfo = item.GetImageInfo(ImageType.Primary, 0); + if (imageInfo != null) + { + imageTags["Primary"] = GetTag(imageInfo); + } + var logoInfo = item.GetImageInfo(ImageType.Logo, 0); + if (logoInfo != null) + { + imageTags["Logo"] = GetTag(logoInfo); + } + + // Build backdrop tags array + var backdropTags = new List(); + var backdropImages = item.GetImages(ImageType.Backdrop).ToList(); + foreach (var bd in backdropImages) + { + backdropTags.Add(GetTag(bd)); + } + + return new + { + item.Id, + item.Name, + Type = item.GetBaseItemKind().ToString(), + item.ProductionYear, + item.OfficialRating, + item.RunTimeTicks, + item.Genres, + item.Overview, + item.CommunityRating, + item.CriticRating, + ImageTags = imageTags, + BackdropImageTags = backdropTags + }; + } + + /// + /// Gets a stable tag string from an ItemImageInfo for cache-busting image URLs. + /// + private static string GetTag(ItemImageInfo info) + { + return info.DateModified.Ticks.ToString("X"); + } + + /// + /// Queries random Movie/Series items, optionally filtered to specific libraries. + /// + private List GetLibraryItems(List? libraryIds, int limit) + { + var query = new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Movie, BaseItemKind.Series], + Limit = limit, + Recursive = true + }; + + // Set OrderBy = Random via reflection to avoid compile-time reference to + // SortOrder which moved assemblies between Jellyfin 10.10 and 10.11 + SetRandomOrder(query); + + if (libraryIds is { Count: > 0 }) + { + var parsedIds = libraryIds + .Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty) + .Where(g => g != Guid.Empty) + .ToArray(); + + if (parsedIds.Length > 0) + { + query.TopParentIds = parsedIds; + } + } + + return _libraryManager.GetItemsResult(query).Items.ToList(); + } + + /// + /// Sets OrderBy to Random on the query using reflection, avoiding direct + /// reference to SortOrder which moved between Jellyfin 10.10 and 10.11. + /// + private static void SetRandomOrder(InternalItemsQuery query) + { + try + { + // Find SortOrder enum type at runtime (works regardless of assembly) + var orderByProp = typeof(InternalItemsQuery).GetProperty(nameof(InternalItemsQuery.OrderBy)); + if (orderByProp == null) return; + + // Get the generic type args: (ItemSortBy, SortOrder) + var elementType = orderByProp.PropertyType.GetGenericArguments()[0]; + var sortOrderType = elementType.GetGenericArguments()[1]; + var ascending = Enum.ToObject(sortOrderType, 0); + + // Create the tuple (ItemSortBy.Random, SortOrder.Ascending) + var tuple = Activator.CreateInstance(elementType, ItemSortBy.Random, ascending); + var array = Array.CreateInstance(elementType, 1); + array.SetValue(tuple, 0); + + orderByProp.SetValue(query, array); + } + catch + { + // Reflection failed — query will return items in default order, still functional + } + } + + /// + /// Queries items from specified collections/playlists, filtered to Movie/Series. + /// + private List GetCollectionItems(List collectionIds, int limit) + { + var allItems = new List(); + var seenIds = new HashSet(); + + foreach (var colId in collectionIds) + { + if (!Guid.TryParse(colId, out var parentGuid)) continue; + + // Get the collection/playlist as a Folder to access LinkedChildren + var parent = _libraryManager.GetItemById(parentGuid); + if (parent is not Folder folder) continue; + + // Access LinkedChildren (data property, no method signature issues) + // then resolve each linked item individually via GetItemById (proven stable) + foreach (var linkedChild in folder.LinkedChildren) + { + if (!linkedChild.ItemId.HasValue) continue; + var item = _libraryManager.GetItemById(linkedChild.ItemId.Value); + if (item == null || !seenIds.Add(item.Id)) continue; + + var kind = item.GetBaseItemKind(); + if (kind != BaseItemKind.Movie && kind != BaseItemKind.Series) continue; + + allItems.Add(item); + } + } + + return allItems.Take(limit).ToList(); + } + /// /// Gets the Jellyseerr configuration (admin URL + user enablement). /// diff --git a/backend/Models/MoonfinSettingsProfile.cs b/backend/Models/MoonfinSettingsProfile.cs index 1a5845c..b295427 100644 --- a/backend/Models/MoonfinSettingsProfile.cs +++ b/backend/Models/MoonfinSettingsProfile.cs @@ -87,9 +87,6 @@ public class MoonfinSettingsProfile [JsonPropertyName("mediaBarEnabled")] public bool? MediaBarEnabled { get; set; } - [JsonPropertyName("mediaBarContentType")] - public string? MediaBarContentType { get; set; } - [JsonPropertyName("mediaBarItemCount")] public int? MediaBarItemCount { get; set; } @@ -108,6 +105,18 @@ public class MoonfinSettingsProfile [JsonPropertyName("mediaBarTrailerPreview")] public bool? MediaBarTrailerPreview { get; set; } + [JsonPropertyName("mediaBarSourceType")] + public string? MediaBarSourceType { get; set; } + + [JsonPropertyName("mediaBarCollectionIds")] + public List? MediaBarCollectionIds { get; set; } + + [JsonPropertyName("mediaBarShuffleItems")] + public bool? MediaBarShuffleItems { get; set; } + + [JsonPropertyName("mediaBarLibraryIds")] + public List? MediaBarLibraryIds { get; set; } + [JsonPropertyName("seasonalSurprise")] public string? SeasonalSurprise { get; set; } diff --git a/backend/Models/MoonfinUserSettings.cs b/backend/Models/MoonfinUserSettings.cs index 7cb806f..77ba7a9 100644 --- a/backend/Models/MoonfinUserSettings.cs +++ b/backend/Models/MoonfinUserSettings.cs @@ -86,8 +86,7 @@ public class MoonfinUserSettings public bool? ConfirmExit { get; set; } [JsonPropertyName("mediaBarEnabled")] public bool? MediaBarEnabled { get; set; } - [JsonPropertyName("mediaBarContentType")] - public string? MediaBarContentType { get; set; } + [JsonPropertyName("mediaBarItemCount")] public int? MediaBarItemCount { get; set; } [JsonPropertyName("mediaBarOpacity")] @@ -100,6 +99,14 @@ public class MoonfinUserSettings public int? MediaBarIntervalMs { get; set; } [JsonPropertyName("mediaBarTrailerPreview")] public bool? MediaBarTrailerPreview { get; set; } + [JsonPropertyName("mediaBarSourceType")] + public string? MediaBarSourceType { get; set; } + [JsonPropertyName("mediaBarCollectionIds")] + public List? MediaBarCollectionIds { get; set; } + [JsonPropertyName("mediaBarShuffleItems")] + public bool? MediaBarShuffleItems { get; set; } + [JsonPropertyName("mediaBarLibraryIds")] + public List? MediaBarLibraryIds { get; set; } [JsonPropertyName("seasonalSurprise")] public string? SeasonalSurprise { get; set; } [JsonPropertyName("backdropEnabled")] diff --git a/backend/Pages/configPage.html b/backend/Pages/configPage.html index 5432959..d6a324d 100644 --- a/backend/Pages/configPage.html +++ b/backend/Pages/configPage.html @@ -136,6 +136,36 @@

UI Features<
Enable the featured media carousel on the home page by default.
+
+ + +
Default content source for the media bar. Users can override this.
+
+ + +
+ +
Randomize the order of items from collections/playlists by default.
+