Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
206 changes: 205 additions & 1 deletion backend/Api/MoonfinController.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -18,17 +22,22 @@ 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;
private static string? _cachedVariantUrl;
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;
}

/// <summary>
Expand Down Expand Up @@ -417,6 +426,201 @@ public ActionResult CheckMySettingsExist()
return NotFound();
}

/// <summary>
/// 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.
/// </summary>
/// <param name="profile">Device profile name: desktop, mobile, tv, or global.</param>
/// <returns>Media bar items as Jellyfin BaseItemDto objects.</returns>
[HttpGet("MediaBar")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult> 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<BaseItem> 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
});
}

/// <summary>
/// Maps a BaseItem to a lightweight DTO matching Jellyfin's BaseItemDto shape.
/// Uses only stable BaseItem properties to avoid version-specific API issues.
/// </summary>
private static object MapItemToDto(BaseItem item)
{
// Build image tags dict
var imageTags = new Dictionary<string, string>();
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<string>();
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
};
}

/// <summary>
/// Gets a stable tag string from an ItemImageInfo for cache-busting image URLs.
/// </summary>
private static string GetTag(ItemImageInfo info)
{
return info.DateModified.Ticks.ToString("X");
}

/// <summary>
/// Queries random Movie/Series items, optionally filtered to specific libraries.
/// </summary>
private List<BaseItem> GetLibraryItems(List<string>? libraryIds, int limit)
Comment thread
enyineer marked this conversation as resolved.
{
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();
}

/// <summary>
/// Sets OrderBy to Random on the query using reflection, avoiding direct
/// reference to SortOrder which moved between Jellyfin 10.10 and 10.11.
/// </summary>
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
}
}

/// <summary>
/// Queries items from specified collections/playlists, filtered to Movie/Series.
/// </summary>
private List<BaseItem> GetCollectionItems(List<string> collectionIds, int limit)
{
var allItems = new List<BaseItem>();
var seenIds = new HashSet<Guid>();

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();
}

/// <summary>
/// Gets the Jellyseerr configuration (admin URL + user enablement).
/// </summary>
Expand Down
15 changes: 12 additions & 3 deletions backend/Models/MoonfinSettingsProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand All @@ -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<string>? MediaBarCollectionIds { get; set; }

[JsonPropertyName("mediaBarShuffleItems")]
public bool? MediaBarShuffleItems { get; set; }

[JsonPropertyName("mediaBarLibraryIds")]
public List<string>? MediaBarLibraryIds { get; set; }

[JsonPropertyName("seasonalSurprise")]
public string? SeasonalSurprise { get; set; }

Expand Down
11 changes: 9 additions & 2 deletions backend/Models/MoonfinUserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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<string>? MediaBarCollectionIds { get; set; }
[JsonPropertyName("mediaBarShuffleItems")]
public bool? MediaBarShuffleItems { get; set; }
[JsonPropertyName("mediaBarLibraryIds")]
public List<string>? MediaBarLibraryIds { get; set; }
[JsonPropertyName("seasonalSurprise")]
public string? SeasonalSurprise { get; set; }
[JsonPropertyName("backdropEnabled")]
Expand Down
Loading
Loading