mirror of
https://github.com/claunia/marechai.git
synced 2026-07-08 17:57:08 +00:00
fix: Stop MobyGames cover importer from misattaching covers to wrong releases
Covers were being attached to the nearest existing SoftwareRelease even when no release actually matched the cover's region, e.g. a Spanish cover landing on a Japanese release just because it was the only release present. The importer no longer guesses: it stops trying to attach covers to a release at all, clustering them by Software + MobyGames cover-group id instead. SoftwareReleaseId becomes an optional, admin-only manual curation field, with a new bulk "assign whole group to a release" action in the cover admin UI. A new repair-covers command retroactively detaches previously misassigned covers using data already recorded during import, no re-scraping needed.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
// <auto-generated/>
|
||||
#pragma warning disable CS0618
|
||||
using Microsoft.Kiota.Abstractions.Extensions;
|
||||
using Microsoft.Kiota.Abstractions.Serialization;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System;
|
||||
namespace Marechai.ApiClient.Models
|
||||
{
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
|
||||
#pragma warning disable CS1591
|
||||
public partial class AssignCoverGroupToReleaseRequestDto : IAdditionalDataHolder, IParsable
|
||||
#pragma warning restore CS1591
|
||||
{
|
||||
/// <summary>Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well.</summary>
|
||||
public IDictionary<string, object> AdditionalData { get; set; }
|
||||
/// <summary>The release_id property</summary>
|
||||
public int? ReleaseId { get; set; }
|
||||
/// <summary>
|
||||
/// Instantiates a new <see cref="global::Marechai.ApiClient.Models.AssignCoverGroupToReleaseRequestDto"/> and sets the default values.
|
||||
/// </summary>
|
||||
public AssignCoverGroupToReleaseRequestDto()
|
||||
{
|
||||
AdditionalData = new Dictionary<string, object>();
|
||||
}
|
||||
/// <summary>
|
||||
/// Creates a new instance of the appropriate class based on discriminator value
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="global::Marechai.ApiClient.Models.AssignCoverGroupToReleaseRequestDto"/></returns>
|
||||
/// <param name="parseNode">The parse node to use to read the discriminator value and create the object</param>
|
||||
public static global::Marechai.ApiClient.Models.AssignCoverGroupToReleaseRequestDto CreateFromDiscriminatorValue(IParseNode parseNode)
|
||||
{
|
||||
if(ReferenceEquals(parseNode, null)) throw new ArgumentNullException(nameof(parseNode));
|
||||
return new global::Marechai.ApiClient.Models.AssignCoverGroupToReleaseRequestDto();
|
||||
}
|
||||
/// <summary>
|
||||
/// The deserialization information for the current model
|
||||
/// </summary>
|
||||
/// <returns>A IDictionary<string, Action<IParseNode>></returns>
|
||||
public virtual IDictionary<string, Action<IParseNode>> GetFieldDeserializers()
|
||||
{
|
||||
return new Dictionary<string, Action<IParseNode>>
|
||||
{
|
||||
{ "release_id", n => { ReleaseId = n.GetIntValue(); } },
|
||||
};
|
||||
}
|
||||
/// <summary>
|
||||
/// Serializes information the current object
|
||||
/// </summary>
|
||||
/// <param name="writer">Serialization writer to use to serialize this model</param>
|
||||
public virtual void Serialize(ISerializationWriter writer)
|
||||
{
|
||||
if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer));
|
||||
writer.WriteIntValue("release_id", ReleaseId);
|
||||
writer.WriteAdditionalData(AdditionalData);
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
@@ -29,6 +29,14 @@ namespace Marechai.ApiClient.Models
|
||||
#nullable restore
|
||||
#else
|
||||
public string Caption { get; set; }
|
||||
#endif
|
||||
/// <summary>The group_id property</summary>
|
||||
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
|
||||
#nullable enable
|
||||
public string? GroupId { get; set; }
|
||||
#nullable restore
|
||||
#else
|
||||
public string GroupId { get; set; }
|
||||
#endif
|
||||
/// <summary>The id property</summary>
|
||||
public Guid? Id { get; set; }
|
||||
@@ -64,6 +72,8 @@ namespace Marechai.ApiClient.Models
|
||||
#else
|
||||
public string ReleaseTitle { get; set; }
|
||||
#endif
|
||||
/// <summary>The software_id property</summary>
|
||||
public int? SoftwareId { get; set; }
|
||||
/// <summary>The software_release_id property</summary>
|
||||
public int? SoftwareReleaseId { get; set; }
|
||||
/// <summary>The type property</summary>
|
||||
@@ -103,11 +113,13 @@ namespace Marechai.ApiClient.Models
|
||||
{
|
||||
{ "canonical_caption", n => { CanonicalCaption = n.GetStringValue(); } },
|
||||
{ "caption", n => { Caption = n.GetStringValue(); } },
|
||||
{ "group_id", n => { GroupId = n.GetStringValue(); } },
|
||||
{ "id", n => { Id = n.GetGuidValue(); } },
|
||||
{ "original_extension", n => { OriginalExtension = n.GetStringValue(); } },
|
||||
{ "platform_name", n => { PlatformName = n.GetStringValue(); } },
|
||||
{ "region_names", n => { RegionNames = n.GetStringValue(); } },
|
||||
{ "release_title", n => { ReleaseTitle = n.GetStringValue(); } },
|
||||
{ "software_id", n => { SoftwareId = n.GetIntValue(); } },
|
||||
{ "software_release_id", n => { SoftwareReleaseId = n.GetIntValue(); } },
|
||||
{ "type", n => { Type = n.GetIntValue(); } },
|
||||
{ "type_name", n => { TypeName = n.GetStringValue(); } },
|
||||
@@ -122,11 +134,13 @@ namespace Marechai.ApiClient.Models
|
||||
if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer));
|
||||
writer.WriteStringValue("canonical_caption", CanonicalCaption);
|
||||
writer.WriteStringValue("caption", Caption);
|
||||
writer.WriteStringValue("group_id", GroupId);
|
||||
writer.WriteGuidValue("id", Id);
|
||||
writer.WriteStringValue("original_extension", OriginalExtension);
|
||||
writer.WriteStringValue("platform_name", PlatformName);
|
||||
writer.WriteStringValue("region_names", RegionNames);
|
||||
writer.WriteStringValue("release_title", ReleaseTitle);
|
||||
writer.WriteIntValue("software_id", SoftwareId);
|
||||
writer.WriteIntValue("software_release_id", SoftwareReleaseId);
|
||||
writer.WriteIntValue("type", Type);
|
||||
writer.WriteStringValue("type_name", TypeName);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// <auto-generated/>
|
||||
#pragma warning disable CS0618
|
||||
using Marechai.ApiClient.Software.Covers.Admin;
|
||||
using Marechai.ApiClient.Software.Covers.Groups;
|
||||
using Marechai.ApiClient.Software.Covers.Item;
|
||||
using Marechai.ApiClient.Software.Covers.Pending;
|
||||
using Marechai.ApiClient.Software.Covers.Upload;
|
||||
@@ -23,6 +24,11 @@ namespace Marechai.ApiClient.Software.Covers
|
||||
{
|
||||
get => new global::Marechai.ApiClient.Software.Covers.Admin.AdminRequestBuilder(PathParameters, RequestAdapter);
|
||||
}
|
||||
/// <summary>The groups property</summary>
|
||||
public global::Marechai.ApiClient.Software.Covers.Groups.GroupsRequestBuilder Groups
|
||||
{
|
||||
get => new global::Marechai.ApiClient.Software.Covers.Groups.GroupsRequestBuilder(PathParameters, RequestAdapter);
|
||||
}
|
||||
/// <summary>The pending property</summary>
|
||||
public global::Marechai.ApiClient.Software.Covers.Pending.PendingRequestBuilder Pending
|
||||
{
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// <auto-generated/>
|
||||
#pragma warning disable CS0618
|
||||
using Marechai.ApiClient.Software.Covers.Groups.Item;
|
||||
using Microsoft.Kiota.Abstractions.Extensions;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
namespace Marechai.ApiClient.Software.Covers.Groups
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds and executes requests for operations under \software\covers\groups
|
||||
/// </summary>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
|
||||
public partial class GroupsRequestBuilder : BaseRequestBuilder
|
||||
{
|
||||
/// <summary>Gets an item from the Marechai.ApiClient.software.covers.groups.item collection</summary>
|
||||
/// <param name="position">Unique identifier of the item</param>
|
||||
/// <returns>A <see cref="global::Marechai.ApiClient.Software.Covers.Groups.Item.WithGroupItemRequestBuilder"/></returns>
|
||||
public global::Marechai.ApiClient.Software.Covers.Groups.Item.WithGroupItemRequestBuilder this[string position]
|
||||
{
|
||||
get
|
||||
{
|
||||
var urlTplParams = new Dictionary<string, object>(PathParameters);
|
||||
urlTplParams.Add("groupId", position);
|
||||
return new global::Marechai.ApiClient.Software.Covers.Groups.Item.WithGroupItemRequestBuilder(urlTplParams, RequestAdapter);
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Instantiates a new <see cref="global::Marechai.ApiClient.Software.Covers.Groups.GroupsRequestBuilder"/> and sets the default values.
|
||||
/// </summary>
|
||||
/// <param name="pathParameters">Path parameters for the request</param>
|
||||
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
|
||||
public GroupsRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/covers/groups", pathParameters)
|
||||
{
|
||||
}
|
||||
/// <summary>
|
||||
/// Instantiates a new <see cref="global::Marechai.ApiClient.Software.Covers.Groups.GroupsRequestBuilder"/> and sets the default values.
|
||||
/// </summary>
|
||||
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
|
||||
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
|
||||
public GroupsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/covers/groups", rawUrl)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
@@ -0,0 +1,100 @@
|
||||
// <auto-generated/>
|
||||
#pragma warning disable CS0618
|
||||
using Marechai.ApiClient.Models;
|
||||
using Microsoft.Kiota.Abstractions.Extensions;
|
||||
using Microsoft.Kiota.Abstractions.Serialization;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using System;
|
||||
namespace Marechai.ApiClient.Software.Covers.Groups.Item.AssignRelease
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds and executes requests for operations under \software\covers\groups\{groupId}\assign-release
|
||||
/// </summary>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
|
||||
public partial class AssignReleaseRequestBuilder : BaseRequestBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Instantiates a new <see cref="global::Marechai.ApiClient.Software.Covers.Groups.Item.AssignRelease.AssignReleaseRequestBuilder"/> and sets the default values.
|
||||
/// </summary>
|
||||
/// <param name="pathParameters">Path parameters for the request</param>
|
||||
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
|
||||
public AssignReleaseRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/covers/groups/{groupId}/assign-release", pathParameters)
|
||||
{
|
||||
}
|
||||
/// <summary>
|
||||
/// Instantiates a new <see cref="global::Marechai.ApiClient.Software.Covers.Groups.Item.AssignRelease.AssignReleaseRequestBuilder"/> and sets the default values.
|
||||
/// </summary>
|
||||
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
|
||||
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
|
||||
public AssignReleaseRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/covers/groups/{groupId}/assign-release", rawUrl)
|
||||
{
|
||||
}
|
||||
/// <returns>A <see cref="Stream"/></returns>
|
||||
/// <param name="body">The request body</param>
|
||||
/// <param name="cancellationToken">Cancellation token to use when cancelling requests</param>
|
||||
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
|
||||
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 400 status code</exception>
|
||||
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 401 status code</exception>
|
||||
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 404 status code</exception>
|
||||
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
|
||||
#nullable enable
|
||||
public async Task<Stream?> PostAsync(global::Marechai.ApiClient.Models.AssignCoverGroupToReleaseRequestDto body, Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
|
||||
{
|
||||
#nullable restore
|
||||
#else
|
||||
public async Task<Stream> PostAsync(global::Marechai.ApiClient.Models.AssignCoverGroupToReleaseRequestDto body, Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
|
||||
{
|
||||
#endif
|
||||
if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body));
|
||||
var requestInfo = ToPostRequestInformation(body, requestConfiguration);
|
||||
var errorMapping = new Dictionary<string, ParsableFactory<IParsable>>
|
||||
{
|
||||
{ "400", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
|
||||
{ "401", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
|
||||
{ "404", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
|
||||
};
|
||||
return await RequestAdapter.SendPrimitiveAsync<Stream>(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
/// <returns>A <see cref="RequestInformation"/></returns>
|
||||
/// <param name="body">The request body</param>
|
||||
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
|
||||
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
|
||||
#nullable enable
|
||||
public RequestInformation ToPostRequestInformation(global::Marechai.ApiClient.Models.AssignCoverGroupToReleaseRequestDto body, Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
|
||||
{
|
||||
#nullable restore
|
||||
#else
|
||||
public RequestInformation ToPostRequestInformation(global::Marechai.ApiClient.Models.AssignCoverGroupToReleaseRequestDto body, Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default)
|
||||
{
|
||||
#endif
|
||||
if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body));
|
||||
var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters);
|
||||
requestInfo.Configure(requestConfiguration);
|
||||
requestInfo.Headers.TryAdd("Accept", "application/json, text/plain;q=0.9");
|
||||
requestInfo.SetContentFromParsable(RequestAdapter, "application/json", body);
|
||||
return requestInfo;
|
||||
}
|
||||
/// <summary>
|
||||
/// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="global::Marechai.ApiClient.Software.Covers.Groups.Item.AssignRelease.AssignReleaseRequestBuilder"/></returns>
|
||||
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
|
||||
public global::Marechai.ApiClient.Software.Covers.Groups.Item.AssignRelease.AssignReleaseRequestBuilder WithUrl(string rawUrl)
|
||||
{
|
||||
return new global::Marechai.ApiClient.Software.Covers.Groups.Item.AssignRelease.AssignReleaseRequestBuilder(rawUrl, RequestAdapter);
|
||||
}
|
||||
/// <summary>
|
||||
/// Configuration for the request such as headers, query parameters, and middleware options.
|
||||
/// </summary>
|
||||
[Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
|
||||
public partial class AssignReleaseRequestBuilderPostRequestConfiguration : RequestConfiguration<DefaultQueryParameters>
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
@@ -0,0 +1,41 @@
|
||||
// <auto-generated/>
|
||||
#pragma warning disable CS0618
|
||||
using Marechai.ApiClient.Software.Covers.Groups.Item.AssignRelease;
|
||||
using Microsoft.Kiota.Abstractions.Extensions;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
namespace Marechai.ApiClient.Software.Covers.Groups.Item
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds and executes requests for operations under \software\covers\groups\{groupId}
|
||||
/// </summary>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
|
||||
public partial class WithGroupItemRequestBuilder : BaseRequestBuilder
|
||||
{
|
||||
/// <summary>The assignRelease property</summary>
|
||||
public global::Marechai.ApiClient.Software.Covers.Groups.Item.AssignRelease.AssignReleaseRequestBuilder AssignRelease
|
||||
{
|
||||
get => new global::Marechai.ApiClient.Software.Covers.Groups.Item.AssignRelease.AssignReleaseRequestBuilder(PathParameters, RequestAdapter);
|
||||
}
|
||||
/// <summary>
|
||||
/// Instantiates a new <see cref="global::Marechai.ApiClient.Software.Covers.Groups.Item.WithGroupItemRequestBuilder"/> and sets the default values.
|
||||
/// </summary>
|
||||
/// <param name="pathParameters">Path parameters for the request</param>
|
||||
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
|
||||
public WithGroupItemRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/covers/groups/{groupId}", pathParameters)
|
||||
{
|
||||
}
|
||||
/// <summary>
|
||||
/// Instantiates a new <see cref="global::Marechai.ApiClient.Software.Covers.Groups.Item.WithGroupItemRequestBuilder"/> and sets the default values.
|
||||
/// </summary>
|
||||
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
|
||||
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
|
||||
public WithGroupItemRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/covers/groups/{groupId}", rawUrl)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
@@ -29,9 +29,19 @@ namespace Marechai.ApiClient.Software.Covers.Upload
|
||||
#nullable restore
|
||||
#else
|
||||
public byte[] File { get; set; }
|
||||
#endif
|
||||
/// <summary>The groupId property</summary>
|
||||
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
|
||||
#nullable enable
|
||||
public string? GroupId { get; set; }
|
||||
#nullable restore
|
||||
#else
|
||||
public string GroupId { get; set; }
|
||||
#endif
|
||||
/// <summary>The releaseId property</summary>
|
||||
public int? ReleaseId { get; set; }
|
||||
/// <summary>The softwareId property</summary>
|
||||
public int? SoftwareId { get; set; }
|
||||
/// <summary>The type property</summary>
|
||||
public int? Type { get; set; }
|
||||
/// <summary>
|
||||
@@ -61,7 +71,9 @@ namespace Marechai.ApiClient.Software.Covers.Upload
|
||||
{
|
||||
{ "caption", n => { Caption = n.GetStringValue(); } },
|
||||
{ "file", n => { File = n.GetByteArrayValue(); } },
|
||||
{ "groupId", n => { GroupId = n.GetStringValue(); } },
|
||||
{ "releaseId", n => { ReleaseId = n.GetIntValue(); } },
|
||||
{ "softwareId", n => { SoftwareId = n.GetIntValue(); } },
|
||||
{ "type", n => { Type = n.GetIntValue(); } },
|
||||
};
|
||||
}
|
||||
@@ -74,7 +86,9 @@ namespace Marechai.ApiClient.Software.Covers.Upload
|
||||
if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer));
|
||||
writer.WriteStringValue("caption", Caption);
|
||||
writer.WriteByteArrayValue("file", File);
|
||||
writer.WriteStringValue("groupId", GroupId);
|
||||
writer.WriteIntValue("releaseId", ReleaseId);
|
||||
writer.WriteIntValue("softwareId", SoftwareId);
|
||||
writer.WriteIntValue("type", Type);
|
||||
writer.WriteAdditionalData(AdditionalData);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"descriptionHash": "A88EB48B54E94488C4D344ADBB16029566E71C3B603604B7A2F91B8705E8B1E5E8218E2011C3875C7649E176D644CF079C91CFDA713572F6C0B897891334950C",
|
||||
"descriptionLocation": "../../../../../tmp/marechai-openapi.json",
|
||||
"descriptionHash": "92F5A73B292800E8C1CFC56D5768B35B2511C183969519E08D7A58F7C587813BE421876AA47EA1D18D8ACC90E55B4F183C440C97CA6045A043E89522030A4DE8",
|
||||
"descriptionLocation": "../openapi.yaml",
|
||||
"lockFileVersion": "1.0.0",
|
||||
"kiotaVersion": "1.31.1",
|
||||
"clientClassName": "Client",
|
||||
|
||||
42
Marechai.Data/Dtos/AssignCoverGroupToReleaseRequestDto.cs
Normal file
42
Marechai.Data/Dtos/AssignCoverGroupToReleaseRequestDto.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
/******************************************************************************
|
||||
// MARECHAI: Master repository of computing history artifacts information
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// Author(s) : Natalia Portillo <claunia@claunia.com>
|
||||
//
|
||||
// --[ License ] --------------------------------------------------------------
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// ----------------------------------------------------------------------------
|
||||
// Copyright © 2003-2026 Natalia Portillo
|
||||
*******************************************************************************/
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Marechai.Data.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Request body for
|
||||
/// <c>POST /software/covers/groups/{groupId}/assign-release</c>. Assigns every
|
||||
/// <c>SoftwareCover</c> sharing the given <c>GroupId</c> to a single
|
||||
/// <see cref="ReleaseId"/> in one action.
|
||||
/// </summary>
|
||||
public class AssignCoverGroupToReleaseRequestDto
|
||||
{
|
||||
[JsonPropertyName("release_id")]
|
||||
[Required]
|
||||
public ulong ReleaseId { get; set; }
|
||||
}
|
||||
@@ -31,9 +31,14 @@ namespace Marechai.Data.Dtos;
|
||||
|
||||
public class SoftwareCoverDto : BaseDto<Guid>
|
||||
{
|
||||
[JsonPropertyName("software_id")]
|
||||
public ulong? SoftwareId { get; set; }
|
||||
|
||||
[JsonPropertyName("software_release_id")]
|
||||
[Required]
|
||||
public ulong SoftwareReleaseId { get; set; }
|
||||
public ulong? SoftwareReleaseId { get; set; }
|
||||
|
||||
[JsonPropertyName("group_id")]
|
||||
public string? GroupId { get; set; }
|
||||
|
||||
[JsonPropertyName("release_title")]
|
||||
public string? ReleaseTitle { get; set; }
|
||||
|
||||
@@ -270,10 +270,7 @@ public sealed class SearchIndexInterceptor : SaveChangesInterceptor
|
||||
ulong swId = sw.Id;
|
||||
bool hasImg = ctx.SoftwareScreenshots.Any(s => s.SoftwareId == swId) ||
|
||||
ctx.SoftwarePromoArt.Any(p => p.SoftwareId == swId) ||
|
||||
ctx.SoftwareCovers.Any(c => ctx.SoftwareReleases
|
||||
.Where(r => r.SoftwareId == swId)
|
||||
.Select(r => r.Id)
|
||||
.Contains(c.SoftwareReleaseId));
|
||||
ctx.SoftwareCovers.Any(c => c.SoftwareId == swId);
|
||||
SearchIndexUpdater.Upsert(ctx, SearchEntityType.Software, (long)sw.Id, sw.Name, null, null, null, null, hasImg,
|
||||
(byte)sw.Kind);
|
||||
return true;
|
||||
|
||||
12537
Marechai.Database/Migrations/20260622185149_MakeCoverSoftwareLinkPrimary.Designer.cs
generated
Normal file
12537
Marechai.Database/Migrations/20260622185149_MakeCoverSoftwareLinkPrimary.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Marechai.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MakeCoverSoftwareLinkPrimary : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Every step below is existence-checked: MySQL/MariaDB DDL is not transactional, so a
|
||||
// statement failing partway through this migration can leave earlier ALTERs already
|
||||
// committed. Re-running it after a partial failure must complete the remaining work
|
||||
// instead of erroring on steps that already applied.
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @fk_exists = (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers'
|
||||
AND CONSTRAINT_NAME = 'FK_SoftwareCovers_SoftwareReleases_SoftwareReleaseId');
|
||||
SET @sql = IF(@fk_exists > 0,
|
||||
'ALTER TABLE `SoftwareCovers` DROP FOREIGN KEY `FK_SoftwareCovers_SoftwareReleases_SoftwareReleaseId`',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @col_nullable = (SELECT IS_NULLABLE FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers' AND COLUMN_NAME = 'SoftwareReleaseId');
|
||||
SET @sql = IF(@col_nullable = 'NO',
|
||||
'ALTER TABLE `SoftwareCovers` MODIFY COLUMN `SoftwareReleaseId` bigint unsigned NULL',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers' AND COLUMN_NAME = 'GroupId');
|
||||
SET @sql = IF(@col_exists = 0,
|
||||
'ALTER TABLE `SoftwareCovers` ADD COLUMN `GroupId` varchar(64) CHARACTER SET utf8mb4 NULL',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers' AND COLUMN_NAME = 'SoftwareId');
|
||||
SET @sql = IF(@col_exists = 0,
|
||||
'ALTER TABLE `SoftwareCovers` ADD COLUMN `SoftwareId` bigint unsigned NULL',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
// A previous partial application of this migration (MySQL/MariaDB DDL isn't
|
||||
// transactional) may have already added SoftwareId as NOT NULL DEFAULT 0 before
|
||||
// failing on a later step. Make sure it ends up nullable regardless of which path
|
||||
// added it, otherwise the backfill below fails for compilation-release covers.
|
||||
migrationBuilder.Sql(@"
|
||||
SET @col_nullable = (SELECT IS_NULLABLE FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers' AND COLUMN_NAME = 'SoftwareId');
|
||||
SET @sql = IF(@col_nullable = 'NO',
|
||||
'ALTER TABLE `SoftwareCovers` MODIFY COLUMN `SoftwareId` bigint unsigned NULL',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
// Backfill: every row existing before this migration has a SoftwareReleaseId. Most
|
||||
// resolve to a single owning Software (direct, or via the release's SoftwareVersion);
|
||||
// covers attached to a compilation release (which bundles several Software entries
|
||||
// with no single owner) are left NULL here and stay anchored by SoftwareReleaseId
|
||||
// alone, same as before this migration. Naturally idempotent — only touches rows that
|
||||
// still have a SoftwareReleaseId and re-derives the same value on a re-run.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE SoftwareCovers sc
|
||||
JOIN SoftwareReleases sr ON sr.Id = sc.SoftwareReleaseId
|
||||
LEFT JOIN SoftwareVersions sv ON sv.Id = sr.SoftwareVersionId
|
||||
SET sc.SoftwareId = COALESCE(sr.SoftwareId, sv.SoftwareId)
|
||||
WHERE sc.SoftwareReleaseId IS NOT NULL;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers' AND INDEX_NAME = 'IX_SoftwareCovers_GroupId');
|
||||
SET @sql = IF(@idx_exists = 0,
|
||||
'CREATE INDEX `IX_SoftwareCovers_GroupId` ON `SoftwareCovers` (`GroupId`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers' AND INDEX_NAME = 'IX_SoftwareCovers_SoftwareId');
|
||||
SET @sql = IF(@idx_exists = 0,
|
||||
'CREATE INDEX `IX_SoftwareCovers_SoftwareId` ON `SoftwareCovers` (`SoftwareId`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @fk_exists = (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers'
|
||||
AND CONSTRAINT_NAME = 'FK_SoftwareCovers_SoftwareReleases_SoftwareReleaseId');
|
||||
SET @sql = IF(@fk_exists = 0,
|
||||
'ALTER TABLE `SoftwareCovers` ADD CONSTRAINT `FK_SoftwareCovers_SoftwareReleases_SoftwareReleaseId` FOREIGN KEY (`SoftwareReleaseId`) REFERENCES `SoftwareReleases` (`Id`) ON DELETE SET NULL',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @fk_exists = (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers'
|
||||
AND CONSTRAINT_NAME = 'FK_SoftwareCovers_Softwares_SoftwareId');
|
||||
SET @sql = IF(@fk_exists = 0,
|
||||
'ALTER TABLE `SoftwareCovers` ADD CONSTRAINT `FK_SoftwareCovers_Softwares_SoftwareId` FOREIGN KEY (`SoftwareId`) REFERENCES `Softwares` (`Id`) ON DELETE CASCADE',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
SET @fk_exists = (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers'
|
||||
AND CONSTRAINT_NAME = 'FK_SoftwareCovers_SoftwareReleases_SoftwareReleaseId');
|
||||
SET @sql = IF(@fk_exists > 0,
|
||||
'ALTER TABLE `SoftwareCovers` DROP FOREIGN KEY `FK_SoftwareCovers_SoftwareReleases_SoftwareReleaseId`',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @fk_exists = (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers'
|
||||
AND CONSTRAINT_NAME = 'FK_SoftwareCovers_Softwares_SoftwareId');
|
||||
SET @sql = IF(@fk_exists > 0,
|
||||
'ALTER TABLE `SoftwareCovers` DROP FOREIGN KEY `FK_SoftwareCovers_Softwares_SoftwareId`',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers' AND INDEX_NAME = 'IX_SoftwareCovers_GroupId');
|
||||
SET @sql = IF(@idx_exists > 0,
|
||||
'DROP INDEX `IX_SoftwareCovers_GroupId` ON `SoftwareCovers`',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers' AND INDEX_NAME = 'IX_SoftwareCovers_SoftwareId');
|
||||
SET @sql = IF(@idx_exists > 0,
|
||||
'DROP INDEX `IX_SoftwareCovers_SoftwareId` ON `SoftwareCovers`',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers' AND COLUMN_NAME = 'GroupId');
|
||||
SET @sql = IF(@col_exists > 0,
|
||||
'ALTER TABLE `SoftwareCovers` DROP COLUMN `GroupId`',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers' AND COLUMN_NAME = 'SoftwareId');
|
||||
SET @sql = IF(@col_exists > 0,
|
||||
'ALTER TABLE `SoftwareCovers` DROP COLUMN `SoftwareId`',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @col_nullable = (SELECT IS_NULLABLE FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers' AND COLUMN_NAME = 'SoftwareReleaseId');
|
||||
SET @sql = IF(@col_nullable = 'YES',
|
||||
'ALTER TABLE `SoftwareCovers` MODIFY COLUMN `SoftwareReleaseId` bigint unsigned NOT NULL DEFAULT 0',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
SET @fk_exists = (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'SoftwareCovers'
|
||||
AND CONSTRAINT_NAME = 'FK_SoftwareCovers_SoftwareReleases_SoftwareReleaseId');
|
||||
SET @sql = IF(@fk_exists = 0,
|
||||
'ALTER TABLE `SoftwareCovers` ADD CONSTRAINT `FK_SoftwareCovers_SoftwareReleases_SoftwareReleaseId` FOREIGN KEY (`SoftwareReleaseId`) REFERENCES `SoftwareReleases` (`Id`) ON DELETE CASCADE',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7321,11 +7321,18 @@ namespace Marechai.Database.Migrations
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<DateTime>("CreatedOn"));
|
||||
|
||||
b.Property<string>("GroupId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<string>("OriginalExtension")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<ulong>("SoftwareReleaseId")
|
||||
b.Property<ulong?>("SoftwareId")
|
||||
.HasColumnType("bigint unsigned");
|
||||
|
||||
b.Property<ulong?>("SoftwareReleaseId")
|
||||
.HasColumnType("bigint unsigned");
|
||||
|
||||
b.Property<byte>("Type")
|
||||
@@ -7339,6 +7346,10 @@ namespace Marechai.Database.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GroupId");
|
||||
|
||||
b.HasIndex("SoftwareId");
|
||||
|
||||
b.HasIndex("SoftwareReleaseId");
|
||||
|
||||
b.HasIndex("Type", "SoftwareReleaseId")
|
||||
@@ -11323,13 +11334,19 @@ namespace Marechai.Database.Migrations
|
||||
|
||||
modelBuilder.Entity("Marechai.Database.Models.SoftwareCover", b =>
|
||||
{
|
||||
b.HasOne("Marechai.Database.Models.Software", "Software")
|
||||
.WithMany("Covers")
|
||||
.HasForeignKey("SoftwareId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("Marechai.Database.Models.SoftwareRelease", "Release")
|
||||
.WithMany("Covers")
|
||||
.HasForeignKey("SoftwareReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
|
||||
b.Navigation("Software");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marechai.Database.Models.SoftwareCoverCaptionTranslation", b =>
|
||||
@@ -12362,6 +12379,8 @@ namespace Marechai.Database.Migrations
|
||||
|
||||
b.Navigation("CompilationReleases");
|
||||
|
||||
b.Navigation("Covers");
|
||||
|
||||
b.Navigation("Credits");
|
||||
|
||||
b.Navigation("CriticReviews");
|
||||
|
||||
@@ -2580,6 +2580,8 @@ public class MarechaiContext : IdentityDbContext<ApplicationUser, ApplicationRol
|
||||
modelBuilder.Entity<SoftwareCover>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => x.SoftwareReleaseId);
|
||||
entity.HasIndex(x => x.SoftwareId);
|
||||
entity.HasIndex(x => x.GroupId);
|
||||
|
||||
// Type-leading composite for the FrontCoverId backfill query
|
||||
// (`PopulateFrontCoverIdsAsync` in SoftwareController), which filters
|
||||
@@ -2589,10 +2591,17 @@ public class MarechaiContext : IdentityDbContext<ApplicationUser, ApplicationRol
|
||||
entity.HasIndex(x => new { x.Type, x.SoftwareReleaseId })
|
||||
.HasDatabaseName("idx_software_covers_type_release");
|
||||
|
||||
entity.HasOne(x => x.Software)
|
||||
.WithMany(x => x.Covers)
|
||||
.HasForeignKey(x => x.SoftwareId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(x => x.Release)
|
||||
.WithMany(x => x.Covers)
|
||||
.HasForeignKey(x => x.SoftwareReleaseId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<UnM49>(entity =>
|
||||
|
||||
@@ -20,6 +20,7 @@ public class Software : BaseModel<ulong>
|
||||
public virtual ICollection<SoftwareVersion> Versions { get; set; }
|
||||
public virtual ICollection<SoftwareCompanyRole> CompanyRoles { get; set; }
|
||||
public virtual ICollection<SoftwareScreenshot> Screenshots { get; set; }
|
||||
public virtual ICollection<SoftwareCover> Covers { get; set; }
|
||||
public virtual ICollection<SoftwareRelease> DirectReleases { get; set; }
|
||||
public virtual ICollection<SoftwareBySoftwareRelease> CompilationReleases { get; set; }
|
||||
public virtual ICollection<SoftwareDescription> Descriptions { get; set; }
|
||||
|
||||
@@ -31,10 +31,17 @@ namespace Marechai.Database.Models;
|
||||
|
||||
public class SoftwareCover : BaseModel<Guid>
|
||||
{
|
||||
[Required]
|
||||
public ulong SoftwareReleaseId { get; set; }
|
||||
// Nullable: a cover attached to a compilation release (which bundles several Software
|
||||
// entries with no single owner) is anchored by SoftwareReleaseId alone instead.
|
||||
public ulong? SoftwareId { get; set; }
|
||||
public virtual Software Software { get; set; }
|
||||
|
||||
public ulong? SoftwareReleaseId { get; set; }
|
||||
public virtual SoftwareRelease Release { get; set; }
|
||||
|
||||
[StringLength(64)]
|
||||
public string GroupId { get; set; }
|
||||
|
||||
[Required]
|
||||
public SoftwareCoverType Type { get; set; }
|
||||
|
||||
|
||||
@@ -148,8 +148,7 @@ class Program
|
||||
var coverStateService = new CoverStateService(factory);
|
||||
|
||||
var coverDownloadService = new CoverDownloadService(
|
||||
factory, sourceDb, platformMatcher, countryMatcher,
|
||||
coverStateService, httpClient, assetRoot ?? "");
|
||||
factory, sourceDb, coverStateService, httpClient, assetRoot ?? "");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -171,6 +170,30 @@ class Program
|
||||
break;
|
||||
}
|
||||
|
||||
case "repair-covers":
|
||||
{
|
||||
int repairBatchSize = 1000;
|
||||
bool repairDryRun = false;
|
||||
|
||||
for(int i = 0; i < args.Length; i++)
|
||||
{
|
||||
if(args[i] == "--batch-size" && i + 1 < args.Length && int.TryParse(args[i + 1], out int rbs))
|
||||
repairBatchSize = rbs;
|
||||
|
||||
if(args[i] == "--dry-run")
|
||||
repairDryRun = true;
|
||||
}
|
||||
|
||||
var repairStateService = new CoverStateService(factory);
|
||||
|
||||
var repairService = new CoverDownloadService(
|
||||
factory, sourceDb, repairStateService, httpClient: null, assetRootPath: "");
|
||||
|
||||
await repairService.RepairMisassignedCoversAsync(repairDryRun, repairBatchSize);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "import-reviews":
|
||||
{
|
||||
int reviewBatchSize = config.GetValue("Import:BatchSize", 500);
|
||||
|
||||
@@ -16,8 +16,6 @@ public class CoverDownloadService
|
||||
{
|
||||
readonly IDbContextFactory<MarechaiContext> _contextFactory;
|
||||
readonly SourceDatabaseService _sourceDb;
|
||||
readonly PlatformMatcher _platformMatcher;
|
||||
readonly CountryMatcher _countryMatcher;
|
||||
readonly CoverStateService _coverStateService;
|
||||
readonly MobyGamesHttpClient _httpClient;
|
||||
readonly string _assetRootPath;
|
||||
@@ -25,16 +23,12 @@ public class CoverDownloadService
|
||||
public CoverDownloadService(
|
||||
IDbContextFactory<MarechaiContext> contextFactory,
|
||||
SourceDatabaseService sourceDb,
|
||||
PlatformMatcher platformMatcher,
|
||||
CountryMatcher countryMatcher,
|
||||
CoverStateService coverStateService,
|
||||
MobyGamesHttpClient httpClient,
|
||||
string assetRootPath)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
_sourceDb = sourceDb;
|
||||
_platformMatcher = platformMatcher;
|
||||
_countryMatcher = countryMatcher;
|
||||
_coverStateService = coverStateService;
|
||||
_httpClient = httpClient;
|
||||
_assetRootPath = assetRootPath;
|
||||
@@ -55,11 +49,6 @@ public class CoverDownloadService
|
||||
? $"\n Starting cover download \e[33;1m(--download-only: conversion skipped, writing to {photosRoot}/)\e[0m...\n"
|
||||
: "\n Starting cover download...\n");
|
||||
|
||||
// Load reference data
|
||||
Console.WriteLine(" Loading reference data...");
|
||||
await _platformMatcher.LoadAsync();
|
||||
await _countryMatcher.LoadAsync();
|
||||
|
||||
// Get all imported games with SoftwareId
|
||||
await using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
@@ -78,7 +67,6 @@ public class CoverDownloadService
|
||||
|
||||
int totalCovers = 0;
|
||||
int matchedCovers = 0;
|
||||
int unmatchedCovers = 0;
|
||||
int downloadedCount = 0;
|
||||
int skippedCount = 0;
|
||||
int failedCount = 0;
|
||||
@@ -181,33 +169,11 @@ public class CoverDownloadService
|
||||
if(!string.IsNullOrWhiteSpace(group.Packaging))
|
||||
Console.WriteLine($" Packaging: {group.Packaging}");
|
||||
|
||||
// Match platform
|
||||
var platform = await _platformMatcher.MatchOrCreateAsync(group.Platform);
|
||||
|
||||
// Match countries to UnM49
|
||||
var matchedCountries = new List<UnM49>();
|
||||
|
||||
foreach(string country in group.Countries)
|
||||
{
|
||||
var unm49 = _countryMatcher.Match(country);
|
||||
|
||||
if(unm49 is not null)
|
||||
matchedCountries.Add(unm49);
|
||||
}
|
||||
|
||||
// Find best matching SoftwareRelease
|
||||
var release = await FindBestReleaseAsync(
|
||||
game.SoftwareId!.Value, platform?.Id, matchedCountries);
|
||||
|
||||
if(release is not null)
|
||||
{
|
||||
Console.WriteLine($" \e[32mMatched release #{release.Id}\e[0m");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($" \e[33m[NO RELEASE]\e[0m");
|
||||
}
|
||||
|
||||
// Covers are no longer attached to a specific SoftwareRelease at import time —
|
||||
// a region-specific cover is evidence a matching release MAY exist, not proof of
|
||||
// which one. Every cover is downloaded and clustered by Software + GroupId
|
||||
// instead; correct release attribution (if any) is left to manual curation via
|
||||
// the admin cover UI's "assign group to release" action.
|
||||
foreach(var cover in group.Covers)
|
||||
{
|
||||
string coverTypeStr = cover.Type;
|
||||
@@ -215,16 +181,8 @@ public class CoverDownloadService
|
||||
|
||||
if(dryRun)
|
||||
{
|
||||
string releaseStr = release is not null
|
||||
? $"→ Release #{release.Id}"
|
||||
: "\e[33m[NO RELEASE]\e[0m";
|
||||
|
||||
Console.WriteLine($" {coverTypeStr,-25} cover-{cover.CoverId} {releaseStr}");
|
||||
|
||||
if(release is not null)
|
||||
matchedCovers++;
|
||||
else
|
||||
unmatchedCovers++;
|
||||
Console.WriteLine($" {coverTypeStr,-25} cover-{cover.CoverId} group {group.GroupId}");
|
||||
matchedCovers++;
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -247,44 +205,19 @@ public class CoverDownloadService
|
||||
continue;
|
||||
}
|
||||
|
||||
if(release is null)
|
||||
{
|
||||
// No matching release — track as NoRelease
|
||||
if(existingState is null)
|
||||
{
|
||||
await _coverStateService.CreateStateAsync(new MobyGamesCoverDownloadState
|
||||
{
|
||||
MobyGameId = game.MobyGameId,
|
||||
SoftwareId = game.SoftwareId!.Value,
|
||||
CoverPageUrl = cover.DetailPageUrl,
|
||||
CoverType = coverTypeStr,
|
||||
Platform = Truncate(group.Platform, 256),
|
||||
Countries = string.Join(", ", group.Countries),
|
||||
GroupId = group.GroupId,
|
||||
Status = MobyGamesCoverDownloadStatus.NoRelease,
|
||||
ProcessedOn = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
unmatchedCovers++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create or get state record
|
||||
if(existingState is null)
|
||||
{
|
||||
existingState = new MobyGamesCoverDownloadState
|
||||
{
|
||||
MobyGameId = game.MobyGameId,
|
||||
SoftwareId = game.SoftwareId!.Value,
|
||||
CoverPageUrl = cover.DetailPageUrl,
|
||||
CoverType = coverTypeStr,
|
||||
Platform = Truncate(group.Platform, 256),
|
||||
Countries = string.Join(", ", group.Countries),
|
||||
GroupId = group.GroupId,
|
||||
Status = MobyGamesCoverDownloadStatus.Pending,
|
||||
SoftwareReleaseId = release.Id
|
||||
MobyGameId = game.MobyGameId,
|
||||
SoftwareId = game.SoftwareId!.Value,
|
||||
CoverPageUrl = cover.DetailPageUrl,
|
||||
CoverType = coverTypeStr,
|
||||
Platform = Truncate(group.Platform, 256),
|
||||
Countries = string.Join(", ", group.Countries),
|
||||
GroupId = group.GroupId,
|
||||
Status = MobyGamesCoverDownloadStatus.Pending
|
||||
};
|
||||
|
||||
await _coverStateService.CreateStateAsync(existingState);
|
||||
@@ -362,7 +295,8 @@ public class CoverDownloadService
|
||||
var softwareCover = new SoftwareCover
|
||||
{
|
||||
Id = coverId,
|
||||
SoftwareReleaseId = release.Id,
|
||||
SoftwareId = game.SoftwareId!.Value,
|
||||
GroupId = group.GroupId,
|
||||
Type = coverType,
|
||||
Caption = coverTypeStr,
|
||||
OriginalExtension = extension
|
||||
@@ -414,8 +348,7 @@ public class CoverDownloadService
|
||||
Console.WriteLine(" \e[33;1m[DRY RUN]\e[0m No changes made");
|
||||
Console.WriteLine($" Games scanned: {gamesProcessed}");
|
||||
Console.WriteLine($" Total covers found: {totalCovers}");
|
||||
Console.WriteLine($" Matched to release: {matchedCovers}");
|
||||
Console.WriteLine($" No matching release: {unmatchedCovers}");
|
||||
Console.WriteLine($" Would download: {matchedCovers}");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -424,56 +357,98 @@ public class CoverDownloadService
|
||||
Console.WriteLine($" Downloaded: {downloadedCount}");
|
||||
Console.WriteLine($" Skipped (existing): {skippedCount}");
|
||||
Console.WriteLine($" Failed: {failedCount}");
|
||||
Console.WriteLine($" No matching release: {unmatchedCovers}");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ────────────────────────────────────\n");
|
||||
}
|
||||
|
||||
async Task<SoftwareRelease> FindBestReleaseAsync(ulong softwareId, ulong? platformId,
|
||||
List<UnM49> matchedCountries)
|
||||
/// <summary>
|
||||
/// One-time/idempotent remediation for covers downloaded before the importer stopped
|
||||
/// forcing every cover onto a (possibly wrong) <see cref="SoftwareRelease" />. Every
|
||||
/// row in <see cref="MobyGamesCoverDownloadState" /> that reached
|
||||
/// <see cref="MobyGamesCoverDownloadStatus.Downloaded" /> and has a linked
|
||||
/// <see cref="SoftwareCover" /> already carries everything needed to fix it — no
|
||||
/// network access required. The cover's <c>SoftwareReleaseId</c> is cleared and its
|
||||
/// <c>SoftwareId</c>/<c>GroupId</c> are (re)populated from the state row, so it stops
|
||||
/// sitting on a release it was never actually evidence for and becomes correctly
|
||||
/// clustered with the rest of its original MobyGames cover group instead. Re-running
|
||||
/// this after a real pass is a no-op for already-fixed rows.
|
||||
/// </summary>
|
||||
public async Task RepairMisassignedCoversAsync(bool dryRun, int batchSize = 1000)
|
||||
{
|
||||
Console.WriteLine(dryRun
|
||||
? "\n \e[33;1m[DRY RUN]\e[0m Scanning for misassigned covers...\n"
|
||||
: "\n Repairing misassigned covers...\n");
|
||||
|
||||
await using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
// Get all releases for this software
|
||||
var releases = await context.SoftwareReleases
|
||||
.Where(r => r.SoftwareId == softwareId)
|
||||
.Include(r => r.Regions)
|
||||
.ToListAsync();
|
||||
int scanned = 0;
|
||||
int detached = 0;
|
||||
int skipped = 0;
|
||||
long lastId = 0;
|
||||
|
||||
if(releases.Count == 0) return null;
|
||||
|
||||
// Filter by platform if we have one
|
||||
var candidates = platformId is not null
|
||||
? releases.Where(r => r.PlatformId == platformId).ToList()
|
||||
: releases;
|
||||
|
||||
// If platform filter emptied our candidates, fall back to all releases
|
||||
if(candidates.Count == 0)
|
||||
candidates = releases;
|
||||
|
||||
if(candidates.Count == 1) return candidates[0];
|
||||
|
||||
// Score by country overlap
|
||||
if(matchedCountries.Count > 0)
|
||||
while(true)
|
||||
{
|
||||
var countryIds = matchedCountries.Select(c => c.Id).ToHashSet();
|
||||
List<MobyGamesCoverDownloadState> batch = await context.MobyGamesCoverDownloadStates
|
||||
.Where(s => s.Id > lastId &&
|
||||
s.Status ==
|
||||
MobyGamesCoverDownloadStatus
|
||||
.Downloaded &&
|
||||
s.SoftwareCoverId != null)
|
||||
.OrderBy(s => s.Id)
|
||||
.Take(batchSize)
|
||||
.ToListAsync();
|
||||
|
||||
var scored = candidates
|
||||
.Select(r => new
|
||||
{
|
||||
Release = r,
|
||||
Score = r.Regions?.Count(reg => countryIds.Contains(reg.UnM49Id)) ?? 0
|
||||
})
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ToList();
|
||||
if(batch.Count == 0) break;
|
||||
|
||||
// Return the best match if it has any overlap
|
||||
if(scored[0].Score > 0) return scored[0].Release;
|
||||
lastId = batch[^1].Id;
|
||||
|
||||
foreach(MobyGamesCoverDownloadState state in batch)
|
||||
{
|
||||
scanned++;
|
||||
|
||||
SoftwareCover cover = await context.SoftwareCovers.FirstOrDefaultAsync(c => c.Id == state.SoftwareCoverId);
|
||||
|
||||
if(cover is null)
|
||||
{
|
||||
skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
bool alreadyCorrect = cover.SoftwareReleaseId is null &&
|
||||
cover.SoftwareId == state.SoftwareId &&
|
||||
cover.GroupId == state.GroupId;
|
||||
|
||||
if(alreadyCorrect)
|
||||
{
|
||||
skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
Console.WriteLine($" [DETACH] cover {cover.Id}: release {cover.SoftwareReleaseId} -> " +
|
||||
$"software {state.SoftwareId}, group {state.GroupId ?? "(none)"}");
|
||||
|
||||
if(!dryRun)
|
||||
{
|
||||
cover.SoftwareReleaseId = null;
|
||||
cover.SoftwareId = state.SoftwareId;
|
||||
cover.GroupId = state.GroupId;
|
||||
}
|
||||
|
||||
detached++;
|
||||
}
|
||||
|
||||
if(!dryRun) await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Fallback: return the first candidate
|
||||
return candidates.FirstOrDefault();
|
||||
Console.WriteLine("\n ────────────────────────────────────");
|
||||
Console.WriteLine(dryRun ? " \e[33;1m[DRY RUN]\e[0m No changes made" : " Repair complete");
|
||||
Console.WriteLine($" Scanned: {scanned}");
|
||||
Console.WriteLine($" Detached/fixed: {detached}");
|
||||
Console.WriteLine($" Already correct/skipped: {skipped}");
|
||||
Console.WriteLine(" ────────────────────────────────────\n");
|
||||
}
|
||||
|
||||
static SoftwareCoverType MapCoverType(string mobyType)
|
||||
|
||||
@@ -116,49 +116,22 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
|
||||
|
||||
if(softwareIds.Count > 0)
|
||||
{
|
||||
// Path 1: cover.Release.SoftwareId — release attached to software directly.
|
||||
// Index used: SoftwareReleases.SoftwareId + SoftwareCovers.SoftwareReleaseId.
|
||||
var direct = await context.SoftwareCovers
|
||||
.Where(sc => sc.Type == SoftwareCoverType.Front &&
|
||||
sc.Release.SoftwareId.HasValue &&
|
||||
softwareIds.Contains(sc.Release.SoftwareId.Value))
|
||||
.Select(sc => new
|
||||
{
|
||||
SoftwareId = sc.Release.SoftwareId.Value,
|
||||
CoverId = sc.Id
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
// SoftwareCovers.SoftwareId is the direct, always-populated link to the owning
|
||||
// Software, regardless of whether (or which) release the cover has been attached
|
||||
// to — no need to traverse Release/SoftwareVersion to find it.
|
||||
var rows = await context.SoftwareCovers
|
||||
.Where(sc => sc.Type == SoftwareCoverType.Front &&
|
||||
sc.SoftwareId.HasValue &&
|
||||
softwareIds.Contains(sc.SoftwareId.Value))
|
||||
.Select(sc => new
|
||||
{
|
||||
SoftwareId = sc.SoftwareId.Value,
|
||||
CoverId = sc.Id
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach(IGrouping<ulong, Guid> g in direct.GroupBy(x => x.SoftwareId, x => x.CoverId))
|
||||
foreach(IGrouping<ulong, Guid> g in rows.GroupBy(x => x.SoftwareId, x => x.CoverId))
|
||||
softwareCovers[g.Key] = g.Min();
|
||||
|
||||
// Path 2: cover.Release.SoftwareVersion.SoftwareId — release attached to a
|
||||
// version of the software. SoftwareVersion.SoftwareId is non-nullable ulong.
|
||||
var indirect = await context.SoftwareCovers
|
||||
.Where(sc => sc.Type == SoftwareCoverType.Front &&
|
||||
sc.Release.SoftwareVersionId.HasValue &&
|
||||
softwareIds.Contains(sc.Release.SoftwareVersion.SoftwareId))
|
||||
.Select(sc => new
|
||||
{
|
||||
SoftwareId = sc.Release.SoftwareVersion.SoftwareId,
|
||||
CoverId = sc.Id
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach(IGrouping<ulong, Guid> g in indirect.GroupBy(x => x.SoftwareId, x => x.CoverId))
|
||||
{
|
||||
Guid candidate = g.Min();
|
||||
|
||||
if(softwareCovers.TryGetValue(g.Key, out Guid existing))
|
||||
{
|
||||
// Both paths matched: pick the lower Guid to mirror the original
|
||||
// FirstOrDefault(OrderBy(Id)) semantics across the union of covers.
|
||||
if(candidate.CompareTo(existing) < 0)
|
||||
softwareCovers[g.Key] = candidate;
|
||||
}
|
||||
else
|
||||
softwareCovers[g.Key] = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
var compilationCovers = new Dictionary<ulong, Guid>();
|
||||
@@ -169,10 +142,11 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
|
||||
// covers directly via SoftwareCovers.SoftwareReleaseId (single indexed column).
|
||||
var rows = await context.SoftwareCovers
|
||||
.Where(sc => sc.Type == SoftwareCoverType.Front &&
|
||||
compilationReleaseIds.Contains(sc.SoftwareReleaseId))
|
||||
sc.SoftwareReleaseId.HasValue &&
|
||||
compilationReleaseIds.Contains(sc.SoftwareReleaseId.Value))
|
||||
.Select(sc => new
|
||||
{
|
||||
ReleaseId = sc.SoftwareReleaseId,
|
||||
ReleaseId = sc.SoftwareReleaseId.Value,
|
||||
CoverId = sc.Id
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
@@ -77,7 +77,7 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co
|
||||
bool isEnglish = string.Equals(langCode, "eng", StringComparison.Ordinal);
|
||||
|
||||
IQueryable<SoftwareCover> source = context.SoftwareCovers
|
||||
.Where(c => c.Release.SoftwareId == softwareId)
|
||||
.Where(c => c.SoftwareId == softwareId)
|
||||
.OrderBy(c => c.Type)
|
||||
.ThenBy(c => c.Release.PlatformId);
|
||||
|
||||
@@ -87,15 +87,19 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co
|
||||
return source.Select(c => new SoftwareCoverDto
|
||||
{
|
||||
Id = c.Id,
|
||||
SoftwareId = c.SoftwareId,
|
||||
SoftwareReleaseId = c.SoftwareReleaseId,
|
||||
ReleaseTitle = c.Release.Title,
|
||||
GroupId = c.GroupId,
|
||||
ReleaseTitle = c.Release != null ? c.Release.Title : null,
|
||||
Type = (int)c.Type,
|
||||
TypeName = c.Type.ToString(),
|
||||
Caption = c.Caption,
|
||||
CanonicalCaption = c.Caption,
|
||||
OriginalExtension = c.OriginalExtension,
|
||||
PlatformName = c.Release.Platform != null ? c.Release.Platform.Name : null,
|
||||
RegionNames = c.Release.Regions != null
|
||||
PlatformName = c.Release != null && c.Release.Platform != null
|
||||
? c.Release.Platform.Name
|
||||
: null,
|
||||
RegionNames = c.Release != null && c.Release.Regions != null
|
||||
? string.Join(", ", c.Release.Regions.Select(r => r.UnM49.Name))
|
||||
: null
|
||||
})
|
||||
@@ -104,8 +108,10 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co
|
||||
return source.Select(c => new SoftwareCoverDto
|
||||
{
|
||||
Id = c.Id,
|
||||
SoftwareId = c.SoftwareId,
|
||||
SoftwareReleaseId = c.SoftwareReleaseId,
|
||||
ReleaseTitle = c.Release.Title,
|
||||
GroupId = c.GroupId,
|
||||
ReleaseTitle = c.Release != null ? c.Release.Title : null,
|
||||
Type = (int)c.Type,
|
||||
TypeName = c.Type.ToString(),
|
||||
Caption = c.Caption == null
|
||||
@@ -117,8 +123,10 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co
|
||||
.FirstOrDefault() ?? c.Caption),
|
||||
CanonicalCaption = c.Caption,
|
||||
OriginalExtension = c.OriginalExtension,
|
||||
PlatformName = c.Release.Platform != null ? c.Release.Platform.Name : null,
|
||||
RegionNames = c.Release.Regions != null
|
||||
PlatformName = c.Release != null && c.Release.Platform != null
|
||||
? c.Release.Platform.Name
|
||||
: null,
|
||||
RegionNames = c.Release != null && c.Release.Regions != null
|
||||
? string.Join(", ", c.Release.Regions.Select(r => r.UnM49.Name))
|
||||
: null
|
||||
})
|
||||
@@ -139,8 +147,10 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co
|
||||
.Select(c => new SoftwareCoverDto
|
||||
{
|
||||
Id = c.Id,
|
||||
SoftwareId = c.SoftwareId,
|
||||
SoftwareReleaseId = c.SoftwareReleaseId,
|
||||
ReleaseTitle = c.Release.Title,
|
||||
GroupId = c.GroupId,
|
||||
ReleaseTitle = c.Release != null ? c.Release.Title : null,
|
||||
Type = (int)c.Type,
|
||||
TypeName = c.Type.ToString(),
|
||||
Caption = isEnglish || c.Caption == null
|
||||
@@ -152,10 +162,10 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co
|
||||
.FirstOrDefault() ?? c.Caption),
|
||||
CanonicalCaption = c.Caption,
|
||||
OriginalExtension = c.OriginalExtension,
|
||||
PlatformName = c.Release.Platform != null
|
||||
PlatformName = c.Release != null && c.Release.Platform != null
|
||||
? c.Release.Platform.Name
|
||||
: null,
|
||||
RegionNames = c.Release.Regions != null
|
||||
RegionNames = c.Release != null && c.Release.Regions != null
|
||||
? string.Join(", ",
|
||||
c.Release.Regions.Select(r => r.UnM49.Name))
|
||||
: null
|
||||
@@ -174,7 +184,9 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<SoftwareCoverDto>> UploadAsync(IFormFile file,
|
||||
[FromForm] ulong releaseId,
|
||||
[FromForm] ulong softwareId,
|
||||
[FromForm] ulong? releaseId,
|
||||
[FromForm] string groupId,
|
||||
[FromForm] SoftwareCoverType type,
|
||||
[FromForm] string caption)
|
||||
{
|
||||
@@ -197,10 +209,18 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co
|
||||
!_allowedContentTypes.Contains(file.ContentType.ToLowerInvariant()))
|
||||
return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
bool releaseExists = await context.SoftwareReleases.AnyAsync(r => r.Id == (ulong)releaseId);
|
||||
bool softwareExists = await context.Softwares.AnyAsync(s => s.Id == softwareId);
|
||||
|
||||
if(!releaseExists)
|
||||
return Problem(detail: "Referenced software release does not exist.", statusCode: StatusCodes.Status400BadRequest);
|
||||
if(!softwareExists)
|
||||
return Problem(detail: "Referenced software does not exist.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
if(releaseId is not null)
|
||||
{
|
||||
bool releaseExists = await context.SoftwareReleases.AnyAsync(r => r.Id == releaseId.Value);
|
||||
|
||||
if(!releaseExists)
|
||||
return Problem(detail: "Referenced software release does not exist.", statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
@@ -209,7 +229,9 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co
|
||||
var model = new SoftwareCover
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SoftwareId = softwareId,
|
||||
SoftwareReleaseId = releaseId,
|
||||
GroupId = groupId,
|
||||
Type = type,
|
||||
Caption = caption,
|
||||
OriginalExtension = extension.TrimStart('.')
|
||||
@@ -242,7 +264,9 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co
|
||||
return Ok(new SoftwareCoverDto
|
||||
{
|
||||
Id = model.Id,
|
||||
SoftwareId = model.SoftwareId,
|
||||
SoftwareReleaseId = model.SoftwareReleaseId,
|
||||
GroupId = model.GroupId,
|
||||
Type = (int)model.Type,
|
||||
TypeName = model.Type.ToString(),
|
||||
Caption = model.Caption,
|
||||
@@ -263,30 +287,33 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co
|
||||
|
||||
if(userId is null) return Unauthorized();
|
||||
|
||||
SoftwareCover model = await context.SoftwareCovers
|
||||
.Include(c => c.Release)
|
||||
.ThenInclude(r => r.SoftwareVersion)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
SoftwareCover model = await context.SoftwareCovers.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if(model is null) return NotFound();
|
||||
|
||||
// Allow release reassignment only within the same software
|
||||
if(dto.SoftwareReleaseId != 0 && (ulong)dto.SoftwareReleaseId != model.SoftwareReleaseId)
|
||||
// Allow release reassignment only within the same software, or clearing it (null).
|
||||
if(dto.SoftwareReleaseId != model.SoftwareReleaseId)
|
||||
{
|
||||
SoftwareRelease newRelease = await context.SoftwareReleases
|
||||
.Include(r => r.SoftwareVersion)
|
||||
.FirstOrDefaultAsync(r => r.Id == (ulong)dto.SoftwareReleaseId);
|
||||
if(dto.SoftwareReleaseId is null)
|
||||
{
|
||||
model.SoftwareReleaseId = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
SoftwareRelease newRelease = await context.SoftwareReleases
|
||||
.Include(r => r.SoftwareVersion)
|
||||
.FirstOrDefaultAsync(r => r.Id == dto.SoftwareReleaseId.Value);
|
||||
|
||||
if(newRelease is null)
|
||||
return Problem(detail: "Referenced software release does not exist.", statusCode: StatusCodes.Status400BadRequest);
|
||||
if(newRelease is null)
|
||||
return Problem(detail: "Referenced software release does not exist.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
ulong? currentSoftwareId = model.Release.SoftwareId ?? model.Release.SoftwareVersion?.SoftwareId;
|
||||
ulong? newSoftwareId = newRelease.SoftwareId ?? newRelease.SoftwareVersion?.SoftwareId;
|
||||
ulong? newSoftwareId = newRelease.SoftwareId ?? newRelease.SoftwareVersion?.SoftwareId;
|
||||
|
||||
if(currentSoftwareId != newSoftwareId)
|
||||
return Problem(detail: "Release does not belong to the same software.", statusCode: StatusCodes.Status400BadRequest);
|
||||
if(newSoftwareId != model.SoftwareId)
|
||||
return Problem(detail: "Release does not belong to the same software.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
model.SoftwareReleaseId = (ulong)dto.SoftwareReleaseId;
|
||||
model.SoftwareReleaseId = dto.SoftwareReleaseId;
|
||||
}
|
||||
}
|
||||
|
||||
// Admin / suggestion edit submits the canonical English caption (DTO.CanonicalCaption
|
||||
@@ -300,6 +327,59 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-assign every <c>SoftwareCover</c> sharing the given MobyGames
|
||||
/// <paramref name="groupId" /> to a single <see cref="SoftwareRelease" />. Used by the
|
||||
/// admin cover UI once a human has identified which release a whole regional cover
|
||||
/// group (front/back/spine/etc. for the same edition) actually belongs to, instead of
|
||||
/// requiring one reassignment per cover.
|
||||
/// </summary>
|
||||
[HttpPost("groups/{groupId}/assign-release")]
|
||||
[Authorize(Roles = "Admin,UberAdmin")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> AssignGroupToReleaseAsync(string groupId,
|
||||
[FromBody] AssignCoverGroupToReleaseRequestDto dto)
|
||||
{
|
||||
string userId = User.FindFirstValue(ClaimTypes.Sid);
|
||||
|
||||
if(userId is null) return Unauthorized();
|
||||
|
||||
List<SoftwareCover> covers = await context.SoftwareCovers
|
||||
.Where(c => c.GroupId == groupId)
|
||||
.ToListAsync();
|
||||
|
||||
if(covers.Count == 0) return NotFound();
|
||||
|
||||
SoftwareRelease release = await context.SoftwareReleases.FirstOrDefaultAsync(r => r.Id == dto.ReleaseId);
|
||||
|
||||
if(release is null)
|
||||
return Problem(detail: "Referenced software release does not exist.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
ulong? groupSoftwareId = covers[0].SoftwareId;
|
||||
|
||||
if(groupSoftwareId is null)
|
||||
return Problem(detail: "Cover group has no associated software; cannot bulk-assign.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
if(covers.Any(c => c.SoftwareId != groupSoftwareId))
|
||||
return Problem(detail: "Cover group spans more than one software; refusing to bulk-assign.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
if(release.SoftwareId != groupSoftwareId)
|
||||
return Problem(detail: "Release does not belong to the same software as the cover group.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
foreach(SoftwareCover cover in covers)
|
||||
cover.SoftwareReleaseId = dto.ReleaseId;
|
||||
|
||||
await context.SaveChangesWithUserAsync(userId);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:Guid}")]
|
||||
[Authorize(Roles = "Admin,UberAdmin")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
|
||||
@@ -103,94 +103,139 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Class="pa-4">
|
||||
@foreach(SoftwareCoverDto cover in _covers)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3">
|
||||
<MudCard Elevation="1" Class="rounded-lg">
|
||||
<MudCardContent Class="pa-2 d-flex justify-center">
|
||||
<picture>
|
||||
<source srcset="@(AssetUrls.BaseUrl)/assets/photos/software-covers/thumbs/avif/4k/@(cover.Id).avif" type="image/avif">
|
||||
<source srcset="@(AssetUrls.BaseUrl)/assets/photos/software-covers/thumbs/webp/4k/@(cover.Id).webp" type="image/webp">
|
||||
<img alt="@(cover.Caption ?? cover.TypeName)"
|
||||
src="@(AssetUrls.BaseUrl)/assets/photos/software-covers/thumbs/jpeg/4k/@(cover.Id).jpg"
|
||||
srcset="@(AssetUrls.BaseUrl)/assets/photos/software-covers/thumbs/jpeg/4k/@(cover.Id).jpg"
|
||||
style="max-height: 256px; max-width: 100%; border-radius: 8px;" width="auto" height="auto"/>
|
||||
</picture>
|
||||
</MudCardContent>
|
||||
<MudCardContent Class="pa-2">
|
||||
@if(!string.IsNullOrWhiteSpace(cover.PlatformName))
|
||||
@foreach(IGrouping<string, SoftwareCoverDto> group in GetCoverGroups())
|
||||
{
|
||||
<MudPaper Elevation="0" Class="ma-4 pa-3 rounded-lg" Style="border: 1px solid var(--mud-palette-lines-default);">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Collections" Size="Size.Small"/>
|
||||
<MudText Typo="Typo.subtitle2">@L["Cover group"]: @group.Key</MudText>
|
||||
<MudChip T="string" Size="Size.Small">@group.Count()</MudChip>
|
||||
<MudSpacer/>
|
||||
<MudSelect T="SoftwareReleaseDto"
|
||||
Value="@(_groupReleaseSelections.GetValueOrDefault(group.Key))"
|
||||
ValueChanged="@(r => _groupReleaseSelections[group.Key] = r)"
|
||||
Label="@L["Assign group to release"]" Variant="Variant.Outlined" Dense="true"
|
||||
ToStringFunc="@(r => FormatReleaseLabel(r))"
|
||||
Style="min-width: 280px;">
|
||||
@if(_releases is not null)
|
||||
{
|
||||
@foreach(SoftwareReleaseDto release in _releases)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="text-truncate">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Devices" Size="Size.Small" Class="mr-1"/>
|
||||
@cover.PlatformName
|
||||
</MudText>
|
||||
}
|
||||
@if(!string.IsNullOrWhiteSpace(cover.RegionNames))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@cover.RegionNames</MudText>
|
||||
}
|
||||
@if(!string.IsNullOrWhiteSpace(cover.ReleaseTitle))
|
||||
{
|
||||
<MudText Typo="Typo.caption">@cover.ReleaseTitle</MudText>
|
||||
<MudSelectItem T="SoftwareReleaseDto" Value="release">@FormatReleaseLabel(release)</MudSelectItem>
|
||||
}
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small"
|
||||
Disabled="@(!_groupReleaseSelections.TryGetValue(group.Key, out SoftwareReleaseDto r) || r is null)"
|
||||
OnClick="() => AssignGroupToReleaseAsync(group.Key)">
|
||||
@L["Assign"]
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudGrid>
|
||||
@foreach(SoftwareCoverDto cover in group)
|
||||
{
|
||||
@RenderCover(cover)
|
||||
}
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@if(_selectedRelease is not null)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||
@FormatReleaseLabel(_releases?.FirstOrDefault(r => r.Id == cover.SoftwareReleaseId))
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSelect T="SoftwareReleaseDto"
|
||||
Value="@(_releases?.FirstOrDefault(r => r.Id == cover.SoftwareReleaseId))"
|
||||
ValueChanged="@(r => OnCoverReleaseChanged(cover, r))"
|
||||
Label="@L["Release"]" Variant="Variant.Outlined" Dense="true"
|
||||
ToStringFunc="@(r => FormatReleaseLabel(r))"
|
||||
Class="mt-2">
|
||||
@if(_releases is not null)
|
||||
{
|
||||
@foreach(SoftwareReleaseDto release in _releases)
|
||||
{
|
||||
<MudSelectItem T="SoftwareReleaseDto" Value="release">@FormatReleaseLabel(release)</MudSelectItem>
|
||||
}
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
|
||||
<MudSelect T="int?"
|
||||
Value="@(cover.Type)"
|
||||
ValueChanged="@(t => OnCoverTypeChanged(cover, t))"
|
||||
Label="@L["Cover Type"]" Variant="Variant.Outlined" Dense="true"
|
||||
Class="mt-2">
|
||||
<MudSelectItem T="int?" Value="0">@L["Front Cover"]</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="1">@L["Back Cover"]</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="2">@L["Inside Cover (Front)"]</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="3">@L["Inside Cover (Back)"]</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="4">@L["Media"]</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="5">@L["Spine"]</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="6">@L["Manual"]</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="7">@L["Other"]</MudSelectItem>
|
||||
</MudSelect>
|
||||
|
||||
@* Admins always edit the canonical English caption regardless of UI locale; localized
|
||||
captions surface on public pages via SoftwareCoverCaptionTranslations. *@
|
||||
<MudTextField T="string"
|
||||
Value="@cover.CanonicalCaption"
|
||||
ValueChanged="@(c => OnCaptionChanged(cover, c))"
|
||||
Label="@L["Caption"]" Variant="Variant.Outlined"
|
||||
DebounceInterval="500"
|
||||
Class="mt-2"/>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Error" Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Delete"
|
||||
OnClick="() => ConfirmDeleteCover(cover)">@L["Delete"]</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
if(GetUngroupedCovers() is { Count: > 0 } ungrouped)
|
||||
{
|
||||
<MudGrid Class="pa-4">
|
||||
@foreach(SoftwareCoverDto cover in ungrouped)
|
||||
{
|
||||
@RenderCover(cover)
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code
|
||||
{
|
||||
RenderFragment RenderCover(SoftwareCoverDto cover) => @<MudItem xs="12" sm="6" md="4" lg="3">
|
||||
<MudCard Elevation="1" Class="rounded-lg">
|
||||
<MudCardContent Class="pa-2 d-flex justify-center">
|
||||
<picture>
|
||||
<source srcset="@(AssetUrls.BaseUrl)/assets/photos/software-covers/thumbs/avif/4k/@(cover.Id).avif" type="image/avif">
|
||||
<source srcset="@(AssetUrls.BaseUrl)/assets/photos/software-covers/thumbs/webp/4k/@(cover.Id).webp" type="image/webp">
|
||||
<img alt="@(cover.Caption ?? cover.TypeName)"
|
||||
src="@(AssetUrls.BaseUrl)/assets/photos/software-covers/thumbs/jpeg/4k/@(cover.Id).jpg"
|
||||
srcset="@(AssetUrls.BaseUrl)/assets/photos/software-covers/thumbs/jpeg/4k/@(cover.Id).jpg"
|
||||
style="max-height: 256px; max-width: 100%; border-radius: 8px;" width="auto" height="auto"/>
|
||||
</picture>
|
||||
</MudCardContent>
|
||||
<MudCardContent Class="pa-2">
|
||||
@if(!string.IsNullOrWhiteSpace(cover.PlatformName))
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="text-truncate">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Devices" Size="Size.Small" Class="mr-1"/>
|
||||
@cover.PlatformName
|
||||
</MudText>
|
||||
}
|
||||
@if(!string.IsNullOrWhiteSpace(cover.RegionNames))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@cover.RegionNames</MudText>
|
||||
}
|
||||
@if(!string.IsNullOrWhiteSpace(cover.ReleaseTitle))
|
||||
{
|
||||
<MudText Typo="Typo.caption">@cover.ReleaseTitle</MudText>
|
||||
}
|
||||
|
||||
@if(_selectedRelease is not null)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||
@FormatReleaseLabel(_releases?.FirstOrDefault(r => r.Id == cover.SoftwareReleaseId))
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSelect T="SoftwareReleaseDto"
|
||||
Value="@(_releases?.FirstOrDefault(r => r.Id == cover.SoftwareReleaseId))"
|
||||
ValueChanged="@(r => OnCoverReleaseChanged(cover, r))"
|
||||
Label="@L["Release"]" Variant="Variant.Outlined" Dense="true"
|
||||
ToStringFunc="@(r => FormatReleaseLabel(r))"
|
||||
Class="mt-2">
|
||||
@if(_releases is not null)
|
||||
{
|
||||
@foreach(SoftwareReleaseDto release in _releases)
|
||||
{
|
||||
<MudSelectItem T="SoftwareReleaseDto" Value="release">@FormatReleaseLabel(release)</MudSelectItem>
|
||||
}
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
|
||||
<MudSelect T="int?"
|
||||
Value="@(cover.Type)"
|
||||
ValueChanged="@(t => OnCoverTypeChanged(cover, t))"
|
||||
Label="@L["Cover Type"]" Variant="Variant.Outlined" Dense="true"
|
||||
Class="mt-2">
|
||||
<MudSelectItem T="int?" Value="0">@L["Front Cover"]</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="1">@L["Back Cover"]</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="2">@L["Inside Cover (Front)"]</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="3">@L["Inside Cover (Back)"]</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="4">@L["Media"]</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="5">@L["Spine"]</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="6">@L["Manual"]</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="7">@L["Other"]</MudSelectItem>
|
||||
</MudSelect>
|
||||
|
||||
@* Admins always edit the canonical English caption regardless of UI locale; localized
|
||||
captions surface on public pages via SoftwareCoverCaptionTranslations. *@
|
||||
<MudTextField T="string"
|
||||
Value="@cover.CanonicalCaption"
|
||||
ValueChanged="@(c => OnCaptionChanged(cover, c))"
|
||||
Label="@L["Caption"]" Variant="Variant.Outlined"
|
||||
DebounceInterval="500"
|
||||
Class="mt-2"/>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Error" Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Delete"
|
||||
OnClick="() => ConfirmDeleteCover(cover)">@L["Delete"]</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</MudItem>;
|
||||
}
|
||||
|
||||
@@ -35,13 +35,14 @@ namespace Marechai.Pages.Admin;
|
||||
|
||||
public partial class SoftwareCovers
|
||||
{
|
||||
List<SoftwareCoverDto> _covers;
|
||||
string _errorMessage;
|
||||
bool _isLoading = true;
|
||||
List<SoftwareReleaseDto> _releases;
|
||||
SoftwareReleaseDto _selectedRelease;
|
||||
string _softwareName;
|
||||
string _successMessage;
|
||||
List<SoftwareCoverDto> _covers;
|
||||
string _errorMessage;
|
||||
bool _isLoading = true;
|
||||
List<SoftwareReleaseDto> _releases;
|
||||
SoftwareReleaseDto _selectedRelease;
|
||||
string _softwareName;
|
||||
string _successMessage;
|
||||
Dictionary<string, SoftwareReleaseDto> _groupReleaseSelections = new();
|
||||
|
||||
[Parameter] public int SoftwareId { get; set; }
|
||||
[Parameter] public int? ReleaseId { get; set; }
|
||||
@@ -77,9 +78,44 @@ public partial class SoftwareCovers
|
||||
if(_selectedRelease?.Id is not null)
|
||||
_covers = _covers.Where(c => c.SoftwareReleaseId == _selectedRelease.Id).ToList();
|
||||
|
||||
_groupReleaseSelections.Clear();
|
||||
_isLoading = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Covers sharing a MobyGames-import GroupId, clustered together so an admin can
|
||||
/// identify the whole regional cover set (front/back/spine/etc. for one edition) and
|
||||
/// assign it to a release in one action instead of one cover at a time. Only relevant
|
||||
/// when browsing all of a software's covers (not a single pre-selected release).
|
||||
/// </summary>
|
||||
IEnumerable<IGrouping<string, SoftwareCoverDto>> GetCoverGroups() =>
|
||||
_selectedRelease is not null || _covers is null
|
||||
? []
|
||||
: _covers.Where(c => !string.IsNullOrEmpty(c.GroupId)).GroupBy(c => c.GroupId);
|
||||
|
||||
List<SoftwareCoverDto> GetUngroupedCovers() =>
|
||||
_selectedRelease is not null || _covers is null
|
||||
? _covers ?? []
|
||||
: _covers.Where(c => string.IsNullOrEmpty(c.GroupId)).ToList();
|
||||
|
||||
async Task AssignGroupToReleaseAsync(string groupId)
|
||||
{
|
||||
if(!_groupReleaseSelections.TryGetValue(groupId, out SoftwareReleaseDto release) || release?.Id is null)
|
||||
return;
|
||||
|
||||
(bool succeeded, string error) = await SoftwareService.AssignCoverGroupToReleaseAsync(groupId, release.Id.Value);
|
||||
|
||||
if(succeeded)
|
||||
{
|
||||
_successMessage = string.Format(L["Assigned cover group to {0}."].Value, FormatReleaseLabel(release));
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_errorMessage = error;
|
||||
}
|
||||
}
|
||||
|
||||
async Task OpenBatchUploadDialog()
|
||||
{
|
||||
if(_selectedRelease?.Id is null) return;
|
||||
|
||||
@@ -1389,4 +1389,16 @@
|
||||
<data name="By category" xml:space="preserve">
|
||||
<value>Nach Kategorie</value>
|
||||
</data>
|
||||
<data name="Cover group" xml:space="preserve">
|
||||
<value>Cover-Gruppe</value>
|
||||
</data>
|
||||
<data name="Assign group to release" xml:space="preserve">
|
||||
<value>Gruppe einer Veröffentlichung zuweisen</value>
|
||||
</data>
|
||||
<data name="Assign" xml:space="preserve">
|
||||
<value>Zuweisen</value>
|
||||
</data>
|
||||
<data name="Assigned cover group to {0}." xml:space="preserve">
|
||||
<value>Cover-Gruppe {0} zugewiesen.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -1257,4 +1257,16 @@
|
||||
<data name="By category" xml:space="preserve">
|
||||
<value>By category</value>
|
||||
</data>
|
||||
<data name="Cover group" xml:space="preserve">
|
||||
<value>Cover group</value>
|
||||
</data>
|
||||
<data name="Assign group to release" xml:space="preserve">
|
||||
<value>Assign group to release</value>
|
||||
</data>
|
||||
<data name="Assign" xml:space="preserve">
|
||||
<value>Assign</value>
|
||||
</data>
|
||||
<data name="Assigned cover group to {0}." xml:space="preserve">
|
||||
<value>Assigned cover group to {0}.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -515,6 +515,10 @@
|
||||
<data name="Spine" xml:space="preserve"><value>Lomo</value></data>
|
||||
<data name="Manual" xml:space="preserve"><value>Manual</value></data>
|
||||
<data name="Other" xml:space="preserve"><value>Otro</value></data>
|
||||
<data name="Cover group" xml:space="preserve"><value>Grupo de portadas</value></data>
|
||||
<data name="Assign group to release" xml:space="preserve"><value>Asignar grupo a edición</value></data>
|
||||
<data name="Assign" xml:space="preserve"><value>Asignar</value></data>
|
||||
<data name="Assigned cover group to {0}." xml:space="preserve"><value>Grupo de portadas asignado a {0}.</value></data>
|
||||
<data name="Credits" xml:space="preserve">
|
||||
<value>Créditos</value>
|
||||
<comment>Credits</comment>
|
||||
|
||||
@@ -1389,4 +1389,16 @@
|
||||
<data name="By category" xml:space="preserve">
|
||||
<value>Par catégorie</value>
|
||||
</data>
|
||||
<data name="Cover group" xml:space="preserve">
|
||||
<value>Groupe de jaquettes</value>
|
||||
</data>
|
||||
<data name="Assign group to release" xml:space="preserve">
|
||||
<value>Affecter le groupe à l'édition</value>
|
||||
</data>
|
||||
<data name="Assign" xml:space="preserve">
|
||||
<value>Affecter</value>
|
||||
</data>
|
||||
<data name="Assigned cover group to {0}." xml:space="preserve">
|
||||
<value>Groupe de jaquettes affecté à {0}.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -1308,4 +1308,16 @@
|
||||
<data name="By category" xml:space="preserve">
|
||||
<value>Per categoria</value>
|
||||
</data>
|
||||
<data name="Cover group" xml:space="preserve">
|
||||
<value>Gruppo di copertine</value>
|
||||
</data>
|
||||
<data name="Assign group to release" xml:space="preserve">
|
||||
<value>Assegna gruppo a edizione</value>
|
||||
</data>
|
||||
<data name="Assign" xml:space="preserve">
|
||||
<value>Assegna</value>
|
||||
</data>
|
||||
<data name="Assigned cover group to {0}." xml:space="preserve">
|
||||
<value>Gruppo di copertine assegnato a {0}.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -1758,4 +1758,16 @@
|
||||
<data name="By category" xml:space="preserve">
|
||||
<value>Per categorie</value>
|
||||
</data>
|
||||
<data name="Cover group" xml:space="preserve">
|
||||
<value>Omslaggroep</value>
|
||||
</data>
|
||||
<data name="Assign group to release" xml:space="preserve">
|
||||
<value>Groep toewijzen aan release</value>
|
||||
</data>
|
||||
<data name="Assign" xml:space="preserve">
|
||||
<value>Toewijzen</value>
|
||||
</data>
|
||||
<data name="Assigned cover group to {0}." xml:space="preserve">
|
||||
<value>Omslaggroep toegewezen aan {0}.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -1930,6 +1930,25 @@ public class SoftwareService(Marechai.ApiClient.Client client, IRequestAdapter r
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool succeeded, string error)> AssignCoverGroupToReleaseAsync(string groupId, int releaseId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.Software.Covers.Groups[groupId].AssignRelease.PostAsync(
|
||||
new AssignCoverGroupToReleaseRequestDto { ReleaseId = releaseId });
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
catch(ApiException ex)
|
||||
{
|
||||
return (false, ExtractErrorMessage(ex));
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SoftwareMergePreviewDto> GetMergePreviewAsync(int targetId, int sourceId)
|
||||
{
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user