@@ -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+
12561625public class MoonfinDetailsScreenBlurRequest
12571626{
12581627 public string ? Profile { get ; set ; }
0 commit comments