Add automatic screenshot caption translation system.

- Introduced a new migration to create the SoftwareScreenshotCaptionTranslations table for storing translated captions of software screenshots.
- Updated MarechaiContext to include a DbSet for SoftwareScreenshotCaptionTranslation.
- Implemented the SoftwareScreenshotCaptionTranslation model with necessary properties and relationships.
- Enhanced SoftwareScreenshotsController to support fetching localized captions based on the requested language.
- Added SoftwareScreenshotCaptionTranslationProvider to handle background translation tasks for missing captions.
- Updated SoftwareService to pass the UI language when fetching screenshots.
This commit is contained in:
2026-05-14 23:45:10 +01:00
parent 1f0b28918c
commit e0d28bb813
15 changed files with 12010 additions and 35 deletions

View File

@@ -14,6 +14,14 @@ namespace Marechai.ApiClient.Models
{
/// <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 canonical_caption property</summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public string? CanonicalCaption { get; set; }
#nullable restore
#else
public string CanonicalCaption { get; set; }
#endif
/// <summary>The caption property</summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
@@ -87,6 +95,7 @@ namespace Marechai.ApiClient.Models
{
return new Dictionary<string, Action<IParseNode>>
{
{ "canonical_caption", n => { CanonicalCaption = n.GetStringValue(); } },
{ "caption", n => { Caption = n.GetStringValue(); } },
{ "id", n => { Id = n.GetGuidValue(); } },
{ "original_extension", n => { OriginalExtension = n.GetStringValue(); } },
@@ -105,6 +114,7 @@ namespace Marechai.ApiClient.Models
public virtual void Serialize(ISerializationWriter writer)
{
if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer));
writer.WriteStringValue("canonical_caption", CanonicalCaption);
writer.WriteStringValue("caption", Caption);
writer.WriteGuidValue("id", Id);
writer.WriteStringValue("original_extension", OriginalExtension);

View File

@@ -22,7 +22,7 @@ namespace Marechai.ApiClient.Software.Screenshots.Item
/// </summary>
/// <param name="pathParameters">Path parameters for the request</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public ScreenshotsItemRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots/{id}", pathParameters)
public ScreenshotsItemRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots/{id}{?lang*}", pathParameters)
{
}
/// <summary>
@@ -30,7 +30,7 @@ namespace Marechai.ApiClient.Software.Screenshots.Item
/// </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 ScreenshotsItemRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots/{id}", rawUrl)
public ScreenshotsItemRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots/{id}{?lang*}", rawUrl)
{
}
/// <returns>A <see cref="Stream"/></returns>
@@ -61,11 +61,11 @@ namespace Marechai.ApiClient.Software.Screenshots.Item
/// <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<global::Marechai.ApiClient.Models.SoftwareScreenshotDto?> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
public async Task<global::Marechai.ApiClient.Models.SoftwareScreenshotDto?> GetAsync(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.Item.ScreenshotsItemRequestBuilder.ScreenshotsItemRequestBuilderGetQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
{
#nullable restore
#else
public async Task<global::Marechai.ApiClient.Models.SoftwareScreenshotDto> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
public async Task<global::Marechai.ApiClient.Models.SoftwareScreenshotDto> GetAsync(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.Item.ScreenshotsItemRequestBuilder.ScreenshotsItemRequestBuilderGetQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
{
#endif
var requestInfo = ToGetRequestInformation(requestConfiguration);
@@ -121,11 +121,11 @@ namespace Marechai.ApiClient.Software.Screenshots.Item
/// <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 ToGetRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.Item.ScreenshotsItemRequestBuilder.ScreenshotsItemRequestBuilderGetQueryParameters>>? requestConfiguration = default)
{
#nullable restore
#else
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default)
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.Item.ScreenshotsItemRequestBuilder.ScreenshotsItemRequestBuilderGetQueryParameters>> requestConfiguration = default)
{
#endif
var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters);
@@ -168,13 +168,28 @@ namespace Marechai.ApiClient.Software.Screenshots.Item
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class ScreenshotsItemRequestBuilderDeleteRequestConfiguration : RequestConfiguration<DefaultQueryParameters>
{
}
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
#pragma warning disable CS1591
public partial class ScreenshotsItemRequestBuilderGetQueryParameters
#pragma warning restore CS1591
{
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
[QueryParameter("lang")]
public string? Lang { get; set; }
#nullable restore
#else
[QueryParameter("lang")]
public string Lang { get; set; }
#endif
}
/// <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 ScreenshotsItemRequestBuilderGetRequestConfiguration : RequestConfiguration<DefaultQueryParameters>
public partial class ScreenshotsItemRequestBuilderGetRequestConfiguration : RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.Item.ScreenshotsItemRequestBuilder.ScreenshotsItemRequestBuilderGetQueryParameters>
{
}
/// <summary>

View File

@@ -60,7 +60,7 @@ namespace Marechai.ApiClient.Software.Screenshots
/// </summary>
/// <param name="pathParameters">Path parameters for the request</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public ScreenshotsRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots", pathParameters)
public ScreenshotsRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots{?lang*}", pathParameters)
{
}
/// <summary>
@@ -68,7 +68,7 @@ namespace Marechai.ApiClient.Software.Screenshots
/// </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 ScreenshotsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots", rawUrl)
public ScreenshotsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots{?lang*}", rawUrl)
{
}
/// <returns>A List&lt;global::Marechai.ApiClient.Models.SoftwareScreenshotDto&gt;</returns>
@@ -76,11 +76,11 @@ namespace Marechai.ApiClient.Software.Screenshots
/// <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 async Task<List<global::Marechai.ApiClient.Models.SoftwareScreenshotDto>?> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
public async Task<List<global::Marechai.ApiClient.Models.SoftwareScreenshotDto>?> GetAsync(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.ScreenshotsRequestBuilder.ScreenshotsRequestBuilderGetQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
{
#nullable restore
#else
public async Task<List<global::Marechai.ApiClient.Models.SoftwareScreenshotDto>> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
public async Task<List<global::Marechai.ApiClient.Models.SoftwareScreenshotDto>> GetAsync(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.ScreenshotsRequestBuilder.ScreenshotsRequestBuilderGetQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
{
#endif
var requestInfo = ToGetRequestInformation(requestConfiguration);
@@ -91,11 +91,11 @@ namespace Marechai.ApiClient.Software.Screenshots
/// <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 ToGetRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.ScreenshotsRequestBuilder.ScreenshotsRequestBuilderGetQueryParameters>>? requestConfiguration = default)
{
#nullable restore
#else
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default)
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.ScreenshotsRequestBuilder.ScreenshotsRequestBuilderGetQueryParameters>> requestConfiguration = default)
{
#endif
var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters);
@@ -112,12 +112,27 @@ namespace Marechai.ApiClient.Software.Screenshots
{
return new global::Marechai.ApiClient.Software.Screenshots.ScreenshotsRequestBuilder(rawUrl, RequestAdapter);
}
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
#pragma warning disable CS1591
public partial class ScreenshotsRequestBuilderGetQueryParameters
#pragma warning restore CS1591
{
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
[QueryParameter("lang")]
public string? Lang { get; set; }
#nullable restore
#else
[QueryParameter("lang")]
public string Lang { get; set; }
#endif
}
/// <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 ScreenshotsRequestBuilderGetRequestConfiguration : RequestConfiguration<DefaultQueryParameters>
public partial class ScreenshotsRequestBuilderGetRequestConfiguration : RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.ScreenshotsRequestBuilder.ScreenshotsRequestBuilderGetQueryParameters>
{
}
}

View File

@@ -1,5 +1,5 @@
{
"descriptionHash": "C8BE3C4A928AAB7DF91A2FFA3C0C8316578EB8277ECA10289413BF28D7321E3211BA7260DCEB540788A6157F97962C68315DEB0077333BA19B4E4AC9A0B82736",
"descriptionHash": "F3336F2FFC38B52D0D4357D4620CF3C84D134027812D2A4D98AB2B36EC56350296959FED844511F3AF5078328BF198486A41AA161BA5184EC96D16099EF3B3FE",
"descriptionLocation": "../../../../../tmp/openapi.json",
"lockFileVersion": "1.0.0",
"kiotaVersion": "1.31.1",

View File

@@ -502,7 +502,12 @@ public class SoftwareBrowsingService
{
try
{
return await _apiClient.Software.Screenshots[screenshotId.ToString()].GetAsync();
string lang = GetIso639CodeFromCulture();
return await _apiClient.Software.Screenshots[screenshotId.ToString()].GetAsync(config =>
{
config.QueryParameters.Lang = lang;
});
}
catch(Exception ex)
{

View File

@@ -50,9 +50,23 @@ public class SoftwareScreenshotDto : BaseDto<Guid>
[JsonPropertyName("version_string")]
public string? VersionString { get; set; }
/// <summary>
/// Caption shown to the end user. Localized to the requested language when the read
/// endpoint receives <c>?lang=</c> (or via the <c>Accept-Language</c> header) and a
/// translation row exists in <c>SoftwareScreenshotCaptionTranslations</c>; falls back to
/// the canonical English caption (= <see cref="CanonicalCaption" />) otherwise.
/// </summary>
[JsonPropertyName("caption")]
public string? Caption { get; set; }
/// <summary>
/// The canonical English caption from <c>SoftwareScreenshots.Caption</c>. Always populated
/// identically to the underlying column regardless of the requested language so admin
/// edit-path UIs can rewrite the source-of-truth value rather than a localized copy.
/// </summary>
[JsonPropertyName("canonical_caption")]
public string? CanonicalCaption { get; set; }
[JsonPropertyName("original_extension")]
[Required]
public string OriginalExtension { get; set; }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marechai.Database.Migrations
{
/// <inheritdoc />
public partial class AddSoftwareScreenshotCaptionTranslations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SoftwareScreenshotCaptionTranslations",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
ScreenshotId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
LanguageCode = table.Column<string>(type: "char(3)", maxLength: 3, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Caption = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedOn = table.Column<DateTime>(type: "datetime(6)", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
UpdatedOn = table.Column<DateTime>(type: "datetime(6)", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.ComputedColumn)
},
constraints: table =>
{
table.PrimaryKey("PK_SoftwareScreenshotCaptionTranslations", x => x.Id);
table.ForeignKey(
name: "fk_software_screenshot_caption_translations_language",
column: x => x.LanguageCode,
principalTable: "ISO_639-3",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_software_screenshot_caption_translations_screenshot",
column: x => x.ScreenshotId,
principalTable: "SoftwareScreenshots",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "idx_software_screenshot_caption_translations_screenshot_language",
table: "SoftwareScreenshotCaptionTranslations",
columns: new[] { "ScreenshotId", "LanguageCode" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SoftwareScreenshotCaptionTranslations_LanguageCode",
table: "SoftwareScreenshotCaptionTranslations",
column: "LanguageCode");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SoftwareScreenshotCaptionTranslations");
}
}
}

View File

@@ -7605,6 +7605,49 @@ namespace Marechai.Database.Migrations
b.ToTable("SoftwareScreenshots");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareScreenshotCaptionTranslation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Caption")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("CreatedOn")
.ValueGeneratedOnAdd()
.HasColumnType("datetime(6)");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<DateTime>("CreatedOn"));
b.Property<string>("LanguageCode")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("char(3)");
b.Property<Guid>("ScreenshotId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedOn")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime(6)");
MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property<DateTime>("UpdatedOn"));
b.HasKey("Id");
b.HasIndex("LanguageCode");
b.HasIndex("ScreenshotId", "LanguageCode")
.IsUnique()
.HasDatabaseName("idx_software_screenshot_caption_translations_screenshot_language");
b.ToTable("SoftwareScreenshotCaptionTranslations");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareUserRating", b =>
{
b.Property<string>("UserId")
@@ -10637,6 +10680,27 @@ namespace Marechai.Database.Migrations
b.Navigation("Version");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareScreenshotCaptionTranslation", b =>
{
b.HasOne("Marechai.Database.Models.Iso639", "Language")
.WithMany()
.HasForeignKey("LanguageCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_software_screenshot_caption_translations_language");
b.HasOne("Marechai.Database.Models.SoftwareScreenshot", "Screenshot")
.WithMany()
.HasForeignKey("ScreenshotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_software_screenshot_caption_translations_screenshot");
b.Navigation("Language");
b.Navigation("Screenshot");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareUserRating", b =>
{
b.HasOne("Marechai.Database.Models.Software", "Software")

View File

@@ -156,6 +156,7 @@ public class MarechaiContext : IdentityDbContext<ApplicationUser, ApplicationRol
public virtual DbSet<UnM49BySoftwareRelease> UnM49BySoftwareRelease { get; set; }
public virtual DbSet<LanguageBySoftwareRelease> LanguageBySoftwareRelease { get; set; }
public virtual DbSet<SoftwareScreenshot> SoftwareScreenshots { get; set; }
public virtual DbSet<SoftwareScreenshotCaptionTranslation> SoftwareScreenshotCaptionTranslations { get; set; }
public virtual DbSet<SoftwareCover> SoftwareCovers { get; set; }
public virtual DbSet<SoftwareCoverCaptionTranslation> SoftwareCoverCaptionTranslations { get; set; }
public virtual DbSet<PeopleBySoftwareRoleTranslation> PeopleBySoftwareRoleTranslations { get; set; }
@@ -2949,6 +2950,24 @@ public class MarechaiContext : IdentityDbContext<ApplicationUser, ApplicationRol
.HasConstraintName("fk_people_by_software_role_translations_language");
});
modelBuilder.Entity<SoftwareScreenshotCaptionTranslation>(entity =>
{
entity.HasIndex(e => new { e.ScreenshotId, e.LanguageCode })
.IsUnique()
.HasDatabaseName("idx_software_screenshot_caption_translations_screenshot_language");
entity.HasOne(e => e.Screenshot)
.WithMany()
.HasForeignKey(e => e.ScreenshotId)
.HasConstraintName("fk_software_screenshot_caption_translations_screenshot")
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Language)
.WithMany()
.HasForeignKey(e => e.LanguageCode)
.HasConstraintName("fk_software_screenshot_caption_translations_language");
});
modelBuilder.Entity<MobyGamesPromoArtDownloadState>(entity =>
{
entity.HasIndex(e => e.PromoPageUrl).IsUnique();

View File

@@ -0,0 +1,52 @@
/******************************************************************************
// 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;
using System.ComponentModel.DataAnnotations;
namespace Marechai.Database.Models;
/// <summary>
/// One translated <see cref="SoftwareScreenshot.Caption" /> per (<see cref="ScreenshotId" />,
/// <see cref="LanguageCode" />) pair. English (<c>eng</c>) is treated as the identity copy of
/// <see cref="SoftwareScreenshot.Caption" /> and is NEVER stored here — read endpoints fall back
/// to the parent <c>Caption</c> column when no translation row exists for the requested
/// language. The background <c>TranslationWorker</c> populates this table by calling OpenAI /
/// NLLB; rows are append-only and are bulk-deleted whenever the canonical caption changes.
/// </summary>
public class SoftwareScreenshotCaptionTranslation : BaseModel<int>
{
public Guid ScreenshotId { get; set; }
[StringLength(3)]
[Required]
public string LanguageCode { get; set; }
[Required]
public string Caption { get; set; }
public virtual SoftwareScreenshot Screenshot { get; set; }
public virtual Iso639 Language { get; set; }
}

View File

@@ -113,8 +113,11 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigurati
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SoftwareScreenshotDto>> GetAsync(Guid id)
public async Task<ActionResult<SoftwareScreenshotDto>> GetAsync(Guid id, [FromQuery] string lang = null)
{
string langCode = LanguageResolver.Resolve(HttpContext, lang);
bool isEnglish = string.Equals(langCode, "eng", StringComparison.Ordinal);
SoftwareScreenshotDto dto = await context.SoftwareScreenshots
.Where(s => s.Id == id)
.Select(s => new SoftwareScreenshotDto
@@ -126,7 +129,14 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigurati
PlatformName = s.Platform != null ? s.Platform.Name : null,
SoftwareVersionId = s.SoftwareVersionId,
VersionString = s.Version != null ? s.Version.VersionString : null,
Caption = s.Caption,
Caption = isEnglish || s.Caption == null
? s.Caption
: (context.SoftwareScreenshotCaptionTranslations
.Where(t => t.ScreenshotId == s.Id &&
t.LanguageCode == langCode)
.Select(t => t.Caption)
.FirstOrDefault() ?? s.Caption),
CanonicalCaption = s.Caption,
OriginalExtension = s.OriginalExtension
})
.FirstOrDefaultAsync();
@@ -139,21 +149,32 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigurati
[HttpGet]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<SoftwareScreenshotDto>> GetAllAsync() =>
context.SoftwareScreenshots
.Select(s => new SoftwareScreenshotDto
{
Id = s.Id,
SoftwareId = s.SoftwareId,
SoftwareName = s.Software.Name,
SoftwarePlatformId = s.SoftwarePlatformId,
PlatformName = s.Platform != null ? s.Platform.Name : null,
SoftwareVersionId = s.SoftwareVersionId,
VersionString = s.Version != null ? s.Version.VersionString : null,
Caption = s.Caption,
OriginalExtension = s.OriginalExtension
})
.ToListAsync();
public Task<List<SoftwareScreenshotDto>> GetAllAsync([FromQuery] string lang = null)
{
string langCode = LanguageResolver.Resolve(HttpContext, lang);
bool isEnglish = string.Equals(langCode, "eng", StringComparison.Ordinal);
return context.SoftwareScreenshots
.Select(s => new SoftwareScreenshotDto
{
Id = s.Id,
SoftwareId = s.SoftwareId,
SoftwareName = s.Software.Name,
SoftwarePlatformId = s.SoftwarePlatformId,
PlatformName = s.Platform != null ? s.Platform.Name : null,
SoftwareVersionId = s.SoftwareVersionId,
VersionString = s.Version != null ? s.Version.VersionString : null,
Caption = isEnglish || s.Caption == null
? s.Caption
: (context.SoftwareScreenshotCaptionTranslations
.Where(t => t.ScreenshotId == s.Id && t.LanguageCode == langCode)
.Select(t => t.Caption)
.FirstOrDefault() ?? s.Caption),
CanonicalCaption = s.Caption,
OriginalExtension = s.OriginalExtension
})
.ToListAsync();
}
[HttpPost("upload")]
[Authorize(Roles = "Admin,UberAdmin")]
@@ -258,6 +279,7 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigurati
SoftwarePlatformId = model.SoftwarePlatformId,
SoftwareVersionId = model.SoftwareVersionId,
Caption = model.Caption,
CanonicalCaption = model.Caption,
OriginalExtension = model.OriginalExtension
});
}
@@ -278,7 +300,24 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigurati
if(model is null) return NotFound();
model.Caption = dto.Caption;
// Admin / suggestion edit may submit the canonical English caption via
// DTO.CanonicalCaption (preferred) or fall back to DTO.Caption when the dialog
// doesn't carry a separate canonical field.
string newCaption = !string.IsNullOrEmpty(dto.CanonicalCaption) ? dto.CanonicalCaption : dto.Caption;
string oldCaption = model.Caption;
// Invalidate cached translations whenever the canonical caption text changes,
// so the background TranslationWorker re-translates on its next sweep. Comparison
// is char-exact (Ordinal) — translation output is char-exact too, so any
// case/whitespace edit warrants re-translation.
if(!string.Equals(oldCaption ?? string.Empty, newCaption ?? string.Empty, StringComparison.Ordinal))
{
await context.SoftwareScreenshotCaptionTranslations
.Where(t => t.ScreenshotId == id)
.ExecuteDeleteAsync();
}
model.Caption = newCaption;
model.SoftwarePlatformId = dto.SoftwarePlatformId;
model.SoftwareVersionId = dto.SoftwareVersionId;

View File

@@ -399,6 +399,15 @@ file class Program
builder.Services.AddSingleton<Marechai.Translation.ITranslationProvider,
PeopleBySoftwareRoleTranslationProvider>();
// Software screenshot caption translation provider — FK-link translation table keyed by
// SoftwareScreenshot.Id (Guid). NO in-memory cache; read endpoints project the localized
// caption via a correlated sub-query against SoftwareScreenshotCaptionTranslations with
// English fallback. Each screenshot's caption is translated independently (no cross-
// screenshot dedup); UpdateAsync invalidates the rows when the canonical caption text
// changes so the worker re-translates on its next sweep.
builder.Services.AddSingleton<Marechai.Translation.ITranslationProvider,
SoftwareScreenshotCaptionTranslationProvider>();
// Background worker that fills SoftwareGenreTranslations using OpenAI (preferred) /
// NLLB (fallback). Exits permanently if neither provider is configured. MUST be
// registered after AddMarechaiTranslation + AddSingleton<SoftwareGenreTranslationCache>.

View File

@@ -0,0 +1,155 @@
/******************************************************************************
// 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;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Marechai.Database.Models;
using Marechai.Translation;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Marechai.Server.Services;
/// <summary>
/// <see cref="ITranslationProvider" /> for <see cref="SoftwareScreenshot.Caption" />. Carries
/// NO in-memory cache singleton: read endpoints project the localized caption directly from
/// the DB via a LEFT JOIN sub-query (the per-request RTT was already paid to fetch the
/// screenshot row). The worker fills <see cref="SoftwareScreenshotCaptionTranslation" /> in
/// the background so subsequent non-English requests find a row instead of falling back to
/// the canonical English caption.
/// </summary>
/// <remarks>
/// <see cref="EnsureCacheLoadedAsync" /> and <see cref="DiscoverNewItemsAsync" /> are no-ops
/// because <see cref="TranslateMissingAsync" /> queries the DB every tick anyway with an
/// anti-join. Captions can be very numerous (one per screenshot, no cross-screenshot dedup),
/// so the worker batches the SaveChanges to keep the transaction moderate.
/// </remarks>
public sealed class SoftwareScreenshotCaptionTranslationProvider(
IServiceScopeFactory scopeFactory,
TranslationService translationService,
ILogger<SoftwareScreenshotCaptionTranslationProvider> logger) : ITranslationProvider
{
/// <summary>
/// Number of translations to accumulate before flushing to the database. Keeps the
/// transaction window short so a worker cancellation mid-sweep doesn't lose more than
/// this many already-translated rows.
/// </summary>
const int FlushBatchSize = 50;
public string Name => "SoftwareScreenshotCaption";
public Task EnsureCacheLoadedAsync(CancellationToken ct) => Task.CompletedTask;
public Task<int> DiscoverNewItemsAsync(CancellationToken ct) => Task.FromResult(0);
/// <summary>
/// Anti-join query for screenshots whose <see cref="SoftwareScreenshot.Caption" /> is
/// non-empty AND that lack a translation row in <paramref name="languageCode" />,
/// translate each via <see cref="TranslationService.TranslateAsync" /> serially (preserves
/// OpenAI rate-limit safety), then bulk-insert in <see cref="FlushBatchSize" /> chunks.
/// Returns the number of rows inserted.
/// </summary>
public async Task<int> TranslateMissingAsync(string languageCode, CancellationToken ct)
{
await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope();
MarechaiContext ctx = scope.ServiceProvider.GetRequiredService<MarechaiContext>();
List<(Guid Id, string Caption)> missing = (await ctx.SoftwareScreenshots
.AsNoTracking()
.Where(s => s.Caption != null && s.Caption != "" &&
!ctx.SoftwareScreenshotCaptionTranslations
.Any(t => t.ScreenshotId == s.Id &&
t.LanguageCode == languageCode))
.OrderBy(s => s.CreatedOn)
.ThenBy(s => s.Id)
.Select(s => new { s.Id, s.Caption })
.ToListAsync(ct))
.Select(x => (x.Id, x.Caption))
.ToList();
if(missing.Count == 0) return 0;
logger.LogInformation(
"SoftwareScreenshotCaption provider: {Count} captions missing translation into {Lang}.",
missing.Count, languageCode);
// Domain hint passed to OpenAI so short label translations stay in the right register
// (e.g. in-game / in-application screenshot captions).
const string domainContext =
"The text is a caption / descriptive label for an in-game or in-application screenshot of a piece of " +
"software (e.g. 'Title screen', 'Main menu', 'Game over screen', 'Boss fight at level 3', " +
"'Settings dialog', 'Inventory view'). Keep brand names, character names, level names, version " +
"numbers and copyright notices unchanged.";
var inserted = 0;
var batch = new List<SoftwareScreenshotCaptionTranslation>(FlushBatchSize);
foreach((Guid id, string englishCaption) in missing)
{
ct.ThrowIfCancellationRequested();
if(string.IsNullOrWhiteSpace(englishCaption)) continue;
(string translated, string error) = await translationService.TranslateAsync(
englishCaption, languageCode, progress: null, plainText: true, domainContext: domainContext);
if(string.IsNullOrWhiteSpace(translated))
{
logger.LogWarning(
"SoftwareScreenshotCaption provider: failed to translate screenshot {ScreenshotId} (\"{English}\") into {Lang}: {Error}",
id, englishCaption, languageCode, error ?? "no result");
continue;
}
batch.Add(new SoftwareScreenshotCaptionTranslation
{
ScreenshotId = id,
LanguageCode = languageCode,
Caption = translated
});
if(batch.Count < FlushBatchSize) continue;
ctx.SoftwareScreenshotCaptionTranslations.AddRange(batch);
await ctx.SaveChangesAsync(ct);
inserted += batch.Count;
batch.Clear();
}
if(batch.Count > 0)
{
ctx.SoftwareScreenshotCaptionTranslations.AddRange(batch);
await ctx.SaveChangesAsync(ct);
inserted += batch.Count;
}
return inserted;
}
}

View File

@@ -938,7 +938,12 @@ public class SoftwareService(Marechai.ApiClient.Client client, IRequestAdapter r
{
try
{
return await client.Software.Screenshots[id].GetAsync();
string lang = UiLanguage.GetIso639_3();
return await client.Software.Screenshots[id].GetAsync(config =>
{
config.QueryParameters.Lang = lang;
});
}
catch
{