Skip to content

Commit 16ae43e

Browse files
Added support for Paradox HSS and future custom rows
1 parent e283b66 commit 16ae43e

6 files changed

Lines changed: 547 additions & 3 deletions

File tree

backend/Api/MoonfinController.cs

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,126 @@ public ActionResult GetGenres()
688688
return Ok(new { Items = genres });
689689
}
690690

691+
/// <summary>
692+
/// Gets resolved home rows for the current user and profile.
693+
/// Prefers Home Screen Sections rows when available, then falls back to synced Moonfin rows,
694+
/// then legacy homeRowOrder conversion.
695+
/// </summary>
696+
[HttpGet("HomeRows")]
697+
[HttpGet("HomeRows/{profile}")]
698+
[Authorize]
699+
[ProducesResponseType(StatusCodes.Status200OK)]
700+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
701+
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
702+
public async Task<ActionResult<MoonfinHomeRowsResponse>> GetHomeRows(
703+
[FromRoute] string? profile = null,
704+
[FromQuery] string? language = null)
705+
{
706+
var config = MoonfinPlugin.Instance?.Configuration;
707+
708+
if (config?.EnableSettingsSync != true)
709+
{
710+
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { Error = "Settings sync is disabled" });
711+
}
712+
713+
var userId = this.GetUserIdFromClaims();
714+
if (userId == null)
715+
{
716+
return Unauthorized(new { Error = "User not authenticated" });
717+
}
718+
719+
var resolvedProfileName = string.IsNullOrWhiteSpace(profile) ? "global" : profile.ToLowerInvariant();
720+
if (!MoonfinUserSettings.ValidProfiles.Contains(resolvedProfileName))
721+
{
722+
return BadRequest(new { Error = $"Invalid profile: {resolvedProfileName}. Valid profiles: {string.Join(", ", MoonfinUserSettings.ValidProfiles)}" });
723+
}
724+
725+
var resolved = await _settingsService.GetResolvedProfileAsync(userId.Value, resolvedProfileName)
726+
?? config?.DefaultUserSettings
727+
?? new MoonfinSettingsProfile();
728+
729+
var response = new MoonfinHomeRowsResponse
730+
{
731+
Profile = resolvedProfileName,
732+
Source = "moonfin",
733+
Rows = []
734+
};
735+
736+
var hssRows = await TryGetHssRowsAsync(userId.Value, language);
737+
if (hssRows != null && hssRows.Count > 0)
738+
{
739+
response.Source = "hss";
740+
response.Rows = hssRows;
741+
return Ok(response);
742+
}
743+
744+
var rowsV2 = NormalizeRows(resolved.HomeRowsV2);
745+
if (rowsV2.Count > 0)
746+
{
747+
response.Source = string.IsNullOrWhiteSpace(resolved.HomeRowsSource) ? "moonfin" : resolved.HomeRowsSource;
748+
response.Rows = rowsV2;
749+
return Ok(response);
750+
}
751+
752+
response.Source = "legacy";
753+
response.Rows = ConvertLegacyHomeRowOrder(resolved.HomeRowOrder);
754+
return Ok(response);
755+
}
756+
757+
/// <summary>
758+
/// Saves home rows for a specific profile for the current user.
759+
/// </summary>
760+
[HttpPost("HomeRows")]
761+
[HttpPost("HomeRows/{profile}")]
762+
[Authorize]
763+
[ProducesResponseType(StatusCodes.Status200OK)]
764+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
765+
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
766+
public async Task<ActionResult<MoonfinSaveResponse>> SaveHomeRows(
767+
[FromBody] MoonfinSaveHomeRowsRequest request,
768+
[FromRoute] string? profile = null)
769+
{
770+
var config = MoonfinPlugin.Instance?.Configuration;
771+
772+
if (config?.EnableSettingsSync != true)
773+
{
774+
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { Error = "Settings sync is disabled" });
775+
}
776+
777+
var userId = this.GetUserIdFromClaims();
778+
if (userId == null)
779+
{
780+
return Unauthorized(new { Error = "User not authenticated" });
781+
}
782+
783+
var targetProfile = string.IsNullOrWhiteSpace(profile)
784+
? (string.IsNullOrWhiteSpace(request.Profile) ? "global" : request.Profile.ToLowerInvariant())
785+
: profile.ToLowerInvariant();
786+
787+
if (!MoonfinUserSettings.ValidProfiles.Contains(targetProfile))
788+
{
789+
return BadRequest(new { Error = $"Invalid profile: {targetProfile}. Valid profiles: {string.Join(", ", MoonfinUserSettings.ValidProfiles)}" });
790+
}
791+
792+
var normalizedRows = NormalizeRows(request.Rows);
793+
var profilePatch = new MoonfinSettingsProfile
794+
{
795+
HomeRowsV2 = normalizedRows.Count > 0 ? normalizedRows : null,
796+
HomeRowsSource = string.IsNullOrWhiteSpace(request.Source) ? null : request.Source,
797+
HomeRowOrder = request.HomeRowOrder is { Count: > 0 } ? request.HomeRowOrder : null
798+
};
799+
800+
var existed = _settingsService.UserSettingsExist(userId.Value);
801+
await _settingsService.SaveProfileAsync(userId.Value, targetProfile, profilePatch, request.ClientId ?? "moonfin-homeRows-endpoint");
802+
803+
return Ok(new MoonfinSaveResponse
804+
{
805+
Success = true,
806+
Created = !existed,
807+
UserId = userId.Value
808+
});
809+
}
810+
691811
/// <summary>
692812
/// Gets resolved media bar content for the current user.
693813
/// Combines user settings resolution with server-side item queries so all clients
@@ -793,6 +913,239 @@ private static object MapItemToDto(BaseItem item)
793913
};
794914
}
795915

916+
private static List<MoonfinCustomHomeRow> NormalizeRows(List<MoonfinCustomHomeRow>? rows)
917+
{
918+
if (rows == null || rows.Count == 0)
919+
{
920+
return [];
921+
}
922+
923+
return rows
924+
.Where(r => r != null)
925+
.OrderBy(r => r.Order ?? int.MaxValue)
926+
.Select((r, index) => new MoonfinCustomHomeRow
927+
{
928+
Id = r.Id,
929+
Title = r.Title,
930+
Kind = r.Kind,
931+
Source = r.Source,
932+
Enabled = r.Enabled ?? true,
933+
Order = r.Order ?? index,
934+
Route = r.Route,
935+
ViewMode = r.ViewMode,
936+
AdditionalData = r.AdditionalData
937+
})
938+
.Where(r => r.Enabled != false)
939+
.ToList();
940+
}
941+
942+
private static List<MoonfinCustomHomeRow> ConvertLegacyHomeRowOrder(List<string>? homeRowOrder)
943+
{
944+
if (homeRowOrder == null || homeRowOrder.Count == 0)
945+
{
946+
return [];
947+
}
948+
949+
return homeRowOrder
950+
.Where(v => !string.IsNullOrWhiteSpace(v) && !string.Equals(v, "none", StringComparison.OrdinalIgnoreCase))
951+
.Select((value, index) => new MoonfinCustomHomeRow
952+
{
953+
Id = value,
954+
Title = value,
955+
Kind = "builtin",
956+
Source = "jellyfin",
957+
Enabled = true,
958+
Order = index
959+
})
960+
.ToList();
961+
}
962+
963+
private async Task<List<MoonfinCustomHomeRow>?> TryGetHssRowsAsync(Guid userId, string? language)
964+
{
965+
var scheme = Request.Scheme;
966+
var host = Request.Host.HasValue ? Request.Host.Value : null;
967+
if (string.IsNullOrWhiteSpace(host))
968+
{
969+
return null;
970+
}
971+
972+
var basePath = Request.PathBase.HasValue ? Request.PathBase.Value : string.Empty;
973+
var baseUrl = $"{scheme}://{host}{basePath}";
974+
var authHeader = Request.Headers.Authorization.ToString();
975+
var tokenHeader = Request.Headers["X-Emby-Token"].ToString();
976+
var apiKey = Request.Query.TryGetValue("api_key", out var apiKeyValues)
977+
? apiKeyValues.ToString()
978+
: null;
979+
980+
if (string.IsNullOrWhiteSpace(tokenHeader) && !string.IsNullOrWhiteSpace(apiKey))
981+
{
982+
tokenHeader = apiKey;
983+
}
984+
985+
if (string.IsNullOrWhiteSpace(authHeader) && string.IsNullOrWhiteSpace(tokenHeader))
986+
{
987+
return null;
988+
}
989+
990+
try
991+
{
992+
using var client = _httpClientFactory.CreateClient();
993+
client.Timeout = TimeSpan.FromSeconds(5);
994+
995+
using var metaReq = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/HomeScreen/Meta");
996+
if (!string.IsNullOrWhiteSpace(authHeader))
997+
{
998+
metaReq.Headers.TryAddWithoutValidation("Authorization", authHeader);
999+
}
1000+
if (!string.IsNullOrWhiteSpace(tokenHeader))
1001+
{
1002+
metaReq.Headers.TryAddWithoutValidation("X-Emby-Token", tokenHeader);
1003+
}
1004+
using var metaResp = await client.SendAsync(metaReq);
1005+
if (!metaResp.IsSuccessStatusCode)
1006+
{
1007+
return null;
1008+
}
1009+
1010+
using var metaDoc = JsonDocument.Parse(await metaResp.Content.ReadAsStringAsync());
1011+
if (!TryGetBoolean(metaDoc.RootElement, "enabled", out var enabled) || !enabled)
1012+
{
1013+
return [];
1014+
}
1015+
1016+
var lang = string.IsNullOrWhiteSpace(language) ? "en" : language;
1017+
var sectionsUrl = $"{baseUrl}/HomeScreen/Sections?UserId={Uri.EscapeDataString(userId.ToString())}&Language={Uri.EscapeDataString(lang)}";
1018+
using var sectionsReq = new HttpRequestMessage(HttpMethod.Get, sectionsUrl);
1019+
if (!string.IsNullOrWhiteSpace(authHeader))
1020+
{
1021+
sectionsReq.Headers.TryAddWithoutValidation("Authorization", authHeader);
1022+
}
1023+
if (!string.IsNullOrWhiteSpace(tokenHeader))
1024+
{
1025+
sectionsReq.Headers.TryAddWithoutValidation("X-Emby-Token", tokenHeader);
1026+
}
1027+
using var sectionsResp = await client.SendAsync(sectionsReq);
1028+
if (!sectionsResp.IsSuccessStatusCode)
1029+
{
1030+
return null;
1031+
}
1032+
1033+
using var sectionsDoc = JsonDocument.Parse(await sectionsResp.Content.ReadAsStringAsync());
1034+
if (!TryGetItemsArray(sectionsDoc.RootElement, out var itemsArray))
1035+
{
1036+
return [];
1037+
}
1038+
1039+
var rows = new List<MoonfinCustomHomeRow>();
1040+
var index = 0;
1041+
foreach (var item in itemsArray.EnumerateArray())
1042+
{
1043+
var sectionId = GetString(item, "section") ?? GetString(item, "id") ?? $"hss-section-{index}";
1044+
var title = GetString(item, "displayText") ?? GetString(item, "name") ?? sectionId;
1045+
rows.Add(new MoonfinCustomHomeRow
1046+
{
1047+
Id = $"hss-{sectionId}",
1048+
Title = title,
1049+
Kind = "custom",
1050+
Source = "hss",
1051+
Enabled = true,
1052+
Order = index,
1053+
Route = GetString(item, "route"),
1054+
ViewMode = GetString(item, "viewMode"),
1055+
AdditionalData = item.GetRawText()
1056+
});
1057+
index++;
1058+
}
1059+
1060+
return rows;
1061+
}
1062+
catch
1063+
{
1064+
return null;
1065+
}
1066+
}
1067+
1068+
private static bool TryGetItemsArray(JsonElement root, out JsonElement itemsArray)
1069+
{
1070+
itemsArray = default;
1071+
if (root.ValueKind != JsonValueKind.Object)
1072+
{
1073+
return false;
1074+
}
1075+
1076+
if (root.TryGetProperty("items", out var lowerItems) && lowerItems.ValueKind == JsonValueKind.Array)
1077+
{
1078+
itemsArray = lowerItems;
1079+
return true;
1080+
}
1081+
1082+
if (root.TryGetProperty("Items", out var upperItems) && upperItems.ValueKind == JsonValueKind.Array)
1083+
{
1084+
itemsArray = upperItems;
1085+
return true;
1086+
}
1087+
1088+
return false;
1089+
}
1090+
1091+
private static bool TryGetBoolean(JsonElement element, string key, out bool value)
1092+
{
1093+
value = false;
1094+
if (element.ValueKind != JsonValueKind.Object)
1095+
{
1096+
return false;
1097+
}
1098+
1099+
if (!TryGetPropertyIgnoreCase(element, key, out var prop))
1100+
{
1101+
return false;
1102+
}
1103+
1104+
if (prop.ValueKind == JsonValueKind.True)
1105+
{
1106+
value = true;
1107+
return true;
1108+
}
1109+
1110+
if (prop.ValueKind == JsonValueKind.False)
1111+
{
1112+
value = false;
1113+
return true;
1114+
}
1115+
1116+
return false;
1117+
}
1118+
1119+
private static string? GetString(JsonElement element, string key)
1120+
{
1121+
if (!TryGetPropertyIgnoreCase(element, key, out var prop))
1122+
{
1123+
return null;
1124+
}
1125+
1126+
return prop.ValueKind == JsonValueKind.String ? prop.GetString() : null;
1127+
}
1128+
1129+
private static bool TryGetPropertyIgnoreCase(JsonElement element, string key, out JsonElement value)
1130+
{
1131+
value = default;
1132+
if (element.ValueKind != JsonValueKind.Object)
1133+
{
1134+
return false;
1135+
}
1136+
1137+
foreach (var prop in element.EnumerateObject())
1138+
{
1139+
if (string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase))
1140+
{
1141+
value = prop.Value;
1142+
return true;
1143+
}
1144+
}
1145+
1146+
return false;
1147+
}
1148+
7961149
private static bool HasBackdropImage(BaseItem item)
7971150
{
7981151
return item.GetImageInfo(ImageType.Backdrop, 0) != null;
@@ -1253,6 +1606,22 @@ public class MoonfinSaveResponse
12531606
public Guid UserId { get; set; }
12541607
}
12551608

1609+
public class MoonfinHomeRowsResponse
1610+
{
1611+
public string Profile { get; set; } = "global";
1612+
public string Source { get; set; } = "moonfin";
1613+
public List<MoonfinCustomHomeRow> Rows { get; set; } = [];
1614+
}
1615+
1616+
public class MoonfinSaveHomeRowsRequest
1617+
{
1618+
public string? Profile { get; set; }
1619+
public string? Source { get; set; }
1620+
public List<MoonfinCustomHomeRow>? Rows { get; set; }
1621+
public List<string>? HomeRowOrder { get; set; }
1622+
public string? ClientId { get; set; }
1623+
}
1624+
12561625
public class MoonfinDetailsScreenBlurRequest
12571626
{
12581627
public string? Profile { get; set; }

0 commit comments

Comments
 (0)