feat: Make software compilations an independent entity

Compilations were previously modeled as a SoftwareRelease row with
IsCompilation=true, with bundled contents tracked via release-keyed
junction tables. This conflated "release" with "the thing being
released" and blocked compilations from having their own identity,
predecessor chain, or covers independent of any one release.

Introduce SoftwareCompilation as a sibling entity to Software:
- Has its own Name, optional bundling link to a Software or Machine
  (e.g. games bundled with an OS, or a dedicated handheld), and a
  predecessor/successor self-reference reusing SoftwareRelationshipType.
- Owns its releases (SoftwareRelease.SoftwareCompilationId) and covers
  (SoftwareCover.SoftwareCompilationId), mirroring Software's existing
  patterns.
- Owns its contained software/versions via new SoftwareBySoftwareCompilation
  / SoftwareVersionBySoftwareCompilation junctions (moved off
  SoftwareRelease, since different releases of the same compilation
  contain the same games).
- Can itself be nested inside other compilations via
  SoftwareCompilationBySoftwareCompilation, a many-to-many junction
  (a sub-compilation can ship inside several different bundles). This
  also fixes a MobyGames importer bug where a compilation containing
  another compilation as one of its titles couldn't be resolved, because
  the original Software row backing the inner compilation was deleted
  with no replacement pointer recorded; MobyGamesImportState now gets a
  SoftwareCompilationId for exactly this case.

Three migrations carry out the conversion: schema addition, a data
backfill that converts existing IsCompilation=true releases into real
SoftwareCompilation rows (re-pointing their releases, covers, and
junction rows), and a cleanup migration dropping the old column/tables.
Applied and verified against the dev database.

Add SoftwareCompilationsController with CRUD, releases, covers,
included-software/version junctions, and nested-compilation endpoints
(with a cycle guard). Update SoftwareReleasesController,
SoftwareController's catalog UNION queries, SearchController, and
SoftwareReleaseSuggestionApplier to use SoftwareCompilationId instead
of the removed IsCompilation flag.

Remaining work (Marechai.ApiClient DTO mirrors, Blazor UI, Marechai.App
compile fixes, MobyGames CompilationRelationService rewrite) is tracked
separately and not yet included in this commit.
This commit is contained in:
2026-06-26 03:51:51 +01:00
parent 3aecbf566b
commit e399e14d38
30 changed files with 41065 additions and 442 deletions

View File

@@ -2,10 +2,10 @@ using System.Text.Json.Serialization;
namespace Marechai.Data.Dtos;
public class SoftwareBySoftwareReleaseDto
public class SoftwareBySoftwareCompilationDto
{
[JsonPropertyName("release_id")]
public ulong ReleaseId { get; set; }
[JsonPropertyName("software_compilation_id")]
public ulong SoftwareCompilationId { get; set; }
[JsonPropertyName("software_id")]
public ulong SoftwareId { get; set; }
[JsonPropertyName("software_name")]

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace Marechai.Data.Dtos;
public class SoftwareCompilationDto : BaseDto<ulong>
{
[JsonPropertyName("name")]
[Required]
public required string Name { get; set; }
[JsonPropertyName("software_id")]
public ulong? SoftwareId { get; set; }
[JsonPropertyName("software")]
public string? Software { get; set; }
[JsonPropertyName("machine_id")]
public int? MachineId { get; set; }
[JsonPropertyName("machine")]
public string? Machine { get; set; }
[JsonPropertyName("predecessor_id")]
public ulong? PredecessorId { get; set; }
[JsonPropertyName("predecessor")]
public string? Predecessor { get; set; }
[JsonPropertyName("relationship_type")]
public SoftwareRelationshipType RelationshipType { get; set; }
[JsonPropertyName("successors")]
public List<SoftwareCompilationSuccessorDto> Successors { get; set; } = [];
[JsonPropertyName("front_cover_id")]
public Guid? FrontCoverId { get; set; }
}

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace Marechai.Data.Dtos;
public class SoftwareCompilationSuccessorDto
{
[JsonPropertyName("id")]
public ulong Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("relationship_type")]
public SoftwareRelationshipType RelationshipType { get; set; }
}

View File

@@ -33,12 +33,14 @@ public class SoftwareReleaseDto : BaseDto<ulong>
{
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("is_compilation")]
public bool IsCompilation { get; set; }
[JsonPropertyName("software_id")]
public ulong? SoftwareId { get; set; }
[JsonPropertyName("software")]
public string? Software { get; set; }
[JsonPropertyName("software_compilation_id")]
public ulong? SoftwareCompilationId { get; set; }
[JsonPropertyName("software_compilation")]
public string? SoftwareCompilation { get; set; }
[JsonPropertyName("software_version_id")]
public ulong? SoftwareVersionId { get; set; }
[JsonPropertyName("software_version")]

View File

@@ -2,10 +2,10 @@ using System.Text.Json.Serialization;
namespace Marechai.Data.Dtos;
public class SoftwareVersionBySoftwareReleaseDto
public class SoftwareVersionBySoftwareCompilationDto
{
[JsonPropertyName("release_id")]
public ulong ReleaseId { get; set; }
[JsonPropertyName("software_compilation_id")]
public ulong SoftwareCompilationId { get; set; }
[JsonPropertyName("software_version_id")]
public ulong SoftwareVersionId { get; set; }
[JsonPropertyName("software_version")]

View File

@@ -153,7 +153,7 @@ public sealed class SearchIndexInterceptor : SaveChangesInterceptor
SoundSynth s => UpsertSoundSynth(ctx, s),
Person pe => UpsertPerson(ctx, pe),
Software sw => UpsertSoftware(ctx, sw),
SoftwareRelease sr => UpsertSoftwareRelease(ctx, sr),
SoftwareCompilation sc => UpsertSoftwareCompilation(ctx, sc),
_ => false
};
}
@@ -183,10 +183,10 @@ public sealed class SearchIndexInterceptor : SaveChangesInterceptor
return isDelete ? new PendingSync(SearchEntityType.Person, pe.Id, true) : new PendingSync(pe);
case Software sw:
return isDelete ? new PendingSync(SearchEntityType.Software, (long)sw.Id, true) : new PendingSync(sw);
case SoftwareRelease sr:
case SoftwareCompilation sc:
return isDelete
? new PendingSync(SearchEntityType.SoftwareCompilation, (long)sr.Id, true)
: new PendingSync(sr);
? new PendingSync(SearchEntityType.SoftwareCompilation, (long)sc.Id, true)
: new PendingSync(sc);
}
return null;
@@ -276,25 +276,12 @@ public sealed class SearchIndexInterceptor : SaveChangesInterceptor
return true;
}
static bool UpsertSoftwareRelease(MarechaiContext ctx, SoftwareRelease sr)
static bool UpsertSoftwareCompilation(MarechaiContext ctx, SoftwareCompilation sc)
{
// Only compilations get their own search-entry; non-compilation releases are reachable via
// the parent Software (already indexed) and would otherwise create huge duplicate noise.
if(!sr.IsCompilation)
{
// Drop any stale entry left over from when this release WAS a compilation.
long srId = (long)sr.Id;
SearchEntry stale = ctx.SearchEntries.FirstOrDefault(e =>
e.EntityType == SearchEntityType.SoftwareCompilation &&
e.EntityId == srId);
if(stale != null) { ctx.SearchEntries.Remove(stale); return true; }
return false;
}
bool hasImg = ctx.SoftwareCovers.Any(c => c.SoftwareReleaseId == sr.Id);
bool hasImg = ctx.SoftwareCovers.Any(c => c.SoftwareCompilationId == sc.Id);
// Compilations are always games per project policy.
SearchIndexUpdater.Upsert(ctx, SearchEntityType.SoftwareCompilation, (long)sr.Id, sr.Title, null,
SearchIndexUpdater.YearOf(sr.ReleaseDate), null, sr.PublisherId, hasImg,
SearchIndexUpdater.Upsert(ctx, SearchEntityType.SoftwareCompilation, (long)sc.Id, sc.Name, null,
null, null, null, hasImg,
(byte)Marechai.Data.SoftwareKind.Game);
return true;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,280 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marechai.Database.Migrations
{
/// <inheritdoc />
public partial class AddSoftwareCompilations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Purely additive: the legacy IsCompilation column and SoftwareBySoftwareRelease /
// SoftwareVersionBySoftwareRelease junction tables are left in place here so the
// follow-up data-backfill migration can still read them. They are dropped in
// RemoveIsCompilationFromSoftwareReleases, after the backfill is verified.
migrationBuilder.AddColumn<ulong>(
name: "SoftwareCompilationId",
table: "SoftwareReleases",
type: "bigint unsigned",
nullable: true);
migrationBuilder.AddColumn<ulong>(
name: "SoftwareCompilationId",
table: "SoftwareCovers",
type: "bigint unsigned",
nullable: true);
migrationBuilder.AddColumn<ulong>(
name: "SoftwareCompilationId",
table: "MobyGamesImportStates",
type: "bigint unsigned",
nullable: true);
migrationBuilder.CreateTable(
name: "SoftwareCompilations",
columns: table => new
{
Id = table.Column<ulong>(type: "bigint unsigned", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Name = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SoftwareId = table.Column<ulong>(type: "bigint unsigned", nullable: true),
MachineId = table.Column<int>(type: "int(11)", nullable: true),
PredecessorId = table.Column<ulong>(type: "bigint unsigned", nullable: true),
RelationshipType = table.Column<byte>(type: "tinyint unsigned", nullable: false),
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_SoftwareCompilations", x => x.Id);
table.ForeignKey(
name: "FK_SoftwareCompilations_SoftwareCompilations_PredecessorId",
column: x => x.PredecessorId,
principalTable: "SoftwareCompilations",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_SoftwareCompilations_Softwares_SoftwareId",
column: x => x.SoftwareId,
principalTable: "Softwares",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_SoftwareCompilations_machines_MachineId",
column: x => x.MachineId,
principalTable: "machines",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "SoftwareBySoftwareCompilation",
columns: table => new
{
SoftwareCompilationId = table.Column<ulong>(type: "bigint unsigned", nullable: false),
SoftwareId = table.Column<ulong>(type: "bigint unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SoftwareBySoftwareCompilation", x => new { x.SoftwareCompilationId, x.SoftwareId });
table.ForeignKey(
name: "FK_SoftwareBySoftwareCompilation_SoftwareCompilations_SoftwareC~",
column: x => x.SoftwareCompilationId,
principalTable: "SoftwareCompilations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SoftwareBySoftwareCompilation_Softwares_SoftwareId",
column: x => x.SoftwareId,
principalTable: "Softwares",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "SoftwareCompilationBySoftwareCompilation",
columns: table => new
{
ParentCompilationId = table.Column<ulong>(type: "bigint unsigned", nullable: false),
ChildCompilationId = table.Column<ulong>(type: "bigint unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SoftwareCompilationBySoftwareCompilation", x => new { x.ParentCompilationId, x.ChildCompilationId });
table.ForeignKey(
name: "FK_SoftwareCompilationBySoftwareCompilation_SoftwareCompilation~",
column: x => x.ChildCompilationId,
principalTable: "SoftwareCompilations",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SoftwareCompilationBySoftwareCompilation_SoftwareCompilatio~1",
column: x => x.ParentCompilationId,
principalTable: "SoftwareCompilations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "SoftwareVersionBySoftwareCompilation",
columns: table => new
{
SoftwareCompilationId = table.Column<ulong>(type: "bigint unsigned", nullable: false),
SoftwareVersionId = table.Column<ulong>(type: "bigint unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SoftwareVersionBySoftwareCompilation", x => new { x.SoftwareCompilationId, x.SoftwareVersionId });
table.ForeignKey(
name: "FK_SoftwareVersionBySoftwareCompilation_SoftwareCompilations_So~",
column: x => x.SoftwareCompilationId,
principalTable: "SoftwareCompilations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SoftwareVersionBySoftwareCompilation_SoftwareVersions_Softwa~",
column: x => x.SoftwareVersionId,
principalTable: "SoftwareVersions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_SoftwareReleases_SoftwareCompilationId",
table: "SoftwareReleases",
column: "SoftwareCompilationId");
migrationBuilder.CreateIndex(
name: "IX_SoftwareCovers_SoftwareCompilationId",
table: "SoftwareCovers",
column: "SoftwareCompilationId");
migrationBuilder.CreateIndex(
name: "IX_MobyGamesImportStates_SoftwareCompilationId",
table: "MobyGamesImportStates",
column: "SoftwareCompilationId");
migrationBuilder.CreateIndex(
name: "IX_SoftwareBySoftwareCompilation_SoftwareId",
table: "SoftwareBySoftwareCompilation",
column: "SoftwareId");
migrationBuilder.CreateIndex(
name: "IX_SoftwareCompilationBySoftwareCompilation_ChildCompilationId",
table: "SoftwareCompilationBySoftwareCompilation",
column: "ChildCompilationId");
migrationBuilder.CreateIndex(
name: "IX_SoftwareCompilations_MachineId",
table: "SoftwareCompilations",
column: "MachineId");
migrationBuilder.CreateIndex(
name: "IX_SoftwareCompilations_Name",
table: "SoftwareCompilations",
column: "Name");
migrationBuilder.CreateIndex(
name: "IX_SoftwareCompilations_PredecessorId",
table: "SoftwareCompilations",
column: "PredecessorId");
migrationBuilder.CreateIndex(
name: "IX_SoftwareCompilations_SoftwareId",
table: "SoftwareCompilations",
column: "SoftwareId");
migrationBuilder.CreateIndex(
name: "IX_SoftwareVersionBySoftwareCompilation_SoftwareVersionId",
table: "SoftwareVersionBySoftwareCompilation",
column: "SoftwareVersionId");
migrationBuilder.AddForeignKey(
name: "FK_MobyGamesImportStates_SoftwareCompilations_SoftwareCompilati~",
table: "MobyGamesImportStates",
column: "SoftwareCompilationId",
principalTable: "SoftwareCompilations",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_SoftwareCovers_SoftwareCompilations_SoftwareCompilationId",
table: "SoftwareCovers",
column: "SoftwareCompilationId",
principalTable: "SoftwareCompilations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_SoftwareReleases_SoftwareCompilations_SoftwareCompilationId",
table: "SoftwareReleases",
column: "SoftwareCompilationId",
principalTable: "SoftwareCompilations",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_MobyGamesImportStates_SoftwareCompilations_SoftwareCompilati~",
table: "MobyGamesImportStates");
migrationBuilder.DropForeignKey(
name: "FK_SoftwareCovers_SoftwareCompilations_SoftwareCompilationId",
table: "SoftwareCovers");
migrationBuilder.DropForeignKey(
name: "FK_SoftwareReleases_SoftwareCompilations_SoftwareCompilationId",
table: "SoftwareReleases");
migrationBuilder.DropTable(
name: "SoftwareBySoftwareCompilation");
migrationBuilder.DropTable(
name: "SoftwareCompilationBySoftwareCompilation");
migrationBuilder.DropTable(
name: "SoftwareVersionBySoftwareCompilation");
migrationBuilder.DropTable(
name: "SoftwareCompilations");
migrationBuilder.DropIndex(
name: "IX_SoftwareReleases_SoftwareCompilationId",
table: "SoftwareReleases");
migrationBuilder.DropIndex(
name: "IX_SoftwareCovers_SoftwareCompilationId",
table: "SoftwareCovers");
migrationBuilder.DropIndex(
name: "IX_MobyGamesImportStates_SoftwareCompilationId",
table: "MobyGamesImportStates");
migrationBuilder.DropColumn(
name: "SoftwareCompilationId",
table: "SoftwareReleases");
migrationBuilder.DropColumn(
name: "SoftwareCompilationId",
table: "SoftwareCovers");
migrationBuilder.DropColumn(
name: "SoftwareCompilationId",
table: "MobyGamesImportStates");
}
}
}

View File

@@ -0,0 +1,139 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marechai.Database.Migrations
{
/// <inheritdoc />
public partial class BackfillSoftwareCompilationsFromIsCompilationReleases : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Temporary join-key column, dropped again in RemoveIsCompilationFromSoftwareReleases
// once every step below has re-pointed everything it needs to via it.
migrationBuilder.AddColumn<ulong>(
name: "LegacyReleaseId",
table: "SoftwareCompilations",
type: "bigint unsigned",
nullable: true);
// 1. One SoftwareCompilations row per legacy IsCompilation=1 release (1:1 — merging
// same-compilation regional releases is a manual curation task, not automated here).
migrationBuilder.Sql(
"""
INSERT INTO SoftwareCompilations (Name, RelationshipType, LegacyReleaseId, CreatedOn, UpdatedOn)
SELECT COALESCE(NULLIF(r.Title, ''), 'Compilation'), 0, r.Id, NOW(6), NOW(6)
FROM SoftwareReleases r
WHERE r.IsCompilation = 1;
""");
// 2. Where the release had no Title, build a name from its included software/version
// junction rows, mirroring the old BuildSoftwareReleaseNewsNameAsync algorithm.
migrationBuilder.Sql(
"""
UPDATE SoftwareCompilations comp
JOIN (
SELECT j.ReleaseId AS ReleaseId,
GROUP_CONCAT(j.ItemName ORDER BY j.ItemName SEPARATOR ' + ') AS BuiltName
FROM (
SELECT svbsr.ReleaseId AS ReleaseId,
CONVERT(CONCAT(sw.Name, ' ', sv.VersionString) USING utf8mb4) COLLATE utf8mb4_general_ci AS ItemName
FROM SoftwareVersionBySoftwareRelease svbsr
JOIN SoftwareVersions sv ON sv.Id = svbsr.SoftwareVersionId
JOIN Softwares sw ON sw.Id = sv.SoftwareId
UNION ALL
SELECT sbsr.ReleaseId AS ReleaseId,
CONVERT(sw2.Name USING utf8mb4) COLLATE utf8mb4_general_ci AS ItemName
FROM SoftwareBySoftwareRelease sbsr
JOIN Softwares sw2 ON sw2.Id = sbsr.SoftwareId
) j
GROUP BY j.ReleaseId
) names ON names.ReleaseId = comp.LegacyReleaseId
JOIN SoftwareReleases r ON r.Id = comp.LegacyReleaseId
SET comp.Name = names.BuiltName
WHERE r.Title IS NULL OR r.Title = '';
""");
// 3. Re-point each former compilation release at its new SoftwareCompilation.
migrationBuilder.Sql(
"""
UPDATE SoftwareReleases r
JOIN SoftwareCompilations comp ON comp.LegacyReleaseId = r.Id
SET r.SoftwareCompilationId = comp.Id
WHERE r.IsCompilation = 1;
""");
// 4. Re-point covers that were anchored to a compilation release. Keep SoftwareReleaseId
// set in addition — it remains factually true which release the cover came from.
migrationBuilder.Sql(
"""
UPDATE SoftwareCovers sc
JOIN SoftwareReleases r ON sc.SoftwareReleaseId = r.Id
JOIN SoftwareCompilations comp ON comp.LegacyReleaseId = r.Id
SET sc.SoftwareCompilationId = comp.Id
WHERE r.IsCompilation = 1;
""");
// 5. Copy the old release-keyed junction rows into the new compilation-keyed tables.
migrationBuilder.Sql(
"""
INSERT INTO SoftwareBySoftwareCompilation (SoftwareCompilationId, SoftwareId)
SELECT comp.Id, j.SoftwareId
FROM SoftwareBySoftwareRelease j
JOIN SoftwareCompilations comp ON comp.LegacyReleaseId = j.ReleaseId;
""");
migrationBuilder.Sql(
"""
INSERT INTO SoftwareVersionBySoftwareCompilation (SoftwareCompilationId, SoftwareVersionId)
SELECT comp.Id, j.SoftwareVersionId
FROM SoftwareVersionBySoftwareRelease j
JOIN SoftwareCompilations comp ON comp.LegacyReleaseId = j.ReleaseId;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DELETE jbsc FROM SoftwareBySoftwareCompilation jbsc
JOIN SoftwareCompilations comp ON comp.Id = jbsc.SoftwareCompilationId
WHERE comp.LegacyReleaseId IS NOT NULL;
""");
migrationBuilder.Sql(
"""
DELETE jvbsc FROM SoftwareVersionBySoftwareCompilation jvbsc
JOIN SoftwareCompilations comp ON comp.Id = jvbsc.SoftwareCompilationId
WHERE comp.LegacyReleaseId IS NOT NULL;
""");
migrationBuilder.Sql(
"""
UPDATE SoftwareCovers sc
JOIN SoftwareCompilations comp ON comp.Id = sc.SoftwareCompilationId
SET sc.SoftwareCompilationId = NULL
WHERE comp.LegacyReleaseId IS NOT NULL;
""");
migrationBuilder.Sql(
"""
UPDATE SoftwareReleases r
JOIN SoftwareCompilations comp ON comp.Id = r.SoftwareCompilationId
SET r.SoftwareCompilationId = NULL
WHERE comp.LegacyReleaseId IS NOT NULL;
""");
migrationBuilder.Sql(
"""
DELETE FROM SoftwareCompilations WHERE LegacyReleaseId IS NOT NULL;
""");
migrationBuilder.DropColumn(
name: "LegacyReleaseId",
table: "SoftwareCompilations");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marechai.Database.Migrations
{
/// <inheritdoc />
public partial class RemoveIsCompilationFromSoftwareReleases : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Safety net: any release that was flagged IsCompilation=1 but never picked up a
// SoftwareCompilationId in the backfill migration (e.g. rows inserted between the two
// migrations) has no owner at all once IsCompilation is dropped below — remove it
// outright rather than leave a dangling, unreachable release row.
migrationBuilder.Sql(
"""
DELETE FROM SoftwareReleases WHERE IsCompilation = 1 AND SoftwareCompilationId IS NULL;
""");
migrationBuilder.DropColumn(
name: "LegacyReleaseId",
table: "SoftwareCompilations");
migrationBuilder.DropTable(
name: "SoftwareBySoftwareRelease");
migrationBuilder.DropTable(
name: "SoftwareVersionBySoftwareRelease");
migrationBuilder.DropColumn(
name: "IsCompilation",
table: "SoftwareReleases");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<ulong>(
name: "LegacyReleaseId",
table: "SoftwareCompilations",
type: "bigint unsigned",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsCompilation",
table: "SoftwareReleases",
type: "bit(1)",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "SoftwareBySoftwareRelease",
columns: table => new
{
ReleaseId = table.Column<ulong>(type: "bigint unsigned", nullable: false),
SoftwareId = table.Column<ulong>(type: "bigint unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SoftwareBySoftwareRelease", x => new { x.ReleaseId, x.SoftwareId });
table.ForeignKey(
name: "FK_SoftwareBySoftwareRelease_SoftwareReleases_ReleaseId",
column: x => x.ReleaseId,
principalTable: "SoftwareReleases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SoftwareBySoftwareRelease_Softwares_SoftwareId",
column: x => x.SoftwareId,
principalTable: "Softwares",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "SoftwareVersionBySoftwareRelease",
columns: table => new
{
ReleaseId = table.Column<ulong>(type: "bigint unsigned", nullable: false),
SoftwareVersionId = table.Column<ulong>(type: "bigint unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SoftwareVersionBySoftwareRelease", x => new { x.ReleaseId, x.SoftwareVersionId });
table.ForeignKey(
name: "FK_SoftwareVersionBySoftwareRelease_SoftwareReleases_ReleaseId",
column: x => x.ReleaseId,
principalTable: "SoftwareReleases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SoftwareVersionBySoftwareRelease_SoftwareVersions_SoftwareVe~",
column: x => x.SoftwareVersionId,
principalTable: "SoftwareVersions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_SoftwareBySoftwareRelease_SoftwareId",
table: "SoftwareBySoftwareRelease",
column: "SoftwareId");
migrationBuilder.CreateIndex(
name: "IX_SoftwareVersionBySoftwareRelease_SoftwareVersionId",
table: "SoftwareVersionBySoftwareRelease",
column: "SoftwareVersionId");
}
}
}

View File

@@ -5219,6 +5219,9 @@ namespace Marechai.Database.Migrations
b.Property<DateTime?>("ProcessedOn")
.HasColumnType("datetime(6)");
b.Property<ulong?>("SoftwareCompilationId")
.HasColumnType("bigint unsigned");
b.Property<ulong?>("SoftwareId")
.HasColumnType("bigint unsigned");
@@ -5236,6 +5239,8 @@ namespace Marechai.Database.Migrations
b.HasIndex("MobyGameId")
.IsUnique();
b.HasIndex("SoftwareCompilationId");
b.HasIndex("Status");
b.ToTable("MobyGamesImportStates");
@@ -7726,19 +7731,19 @@ namespace Marechai.Database.Migrations
b.ToTable("SoftwareBarcodes");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareBySoftwareRelease", b =>
modelBuilder.Entity("Marechai.Database.Models.SoftwareBySoftwareCompilation", b =>
{
b.Property<ulong>("ReleaseId")
b.Property<ulong>("SoftwareCompilationId")
.HasColumnType("bigint unsigned");
b.Property<ulong>("SoftwareId")
.HasColumnType("bigint unsigned");
b.HasKey("ReleaseId", "SoftwareId");
b.HasKey("SoftwareCompilationId", "SoftwareId");
b.HasIndex("SoftwareId");
b.ToTable("SoftwareBySoftwareRelease");
b.ToTable("SoftwareBySoftwareCompilation");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareCompanyRole", b =>
@@ -7761,6 +7766,70 @@ namespace Marechai.Database.Migrations
b.ToTable("SoftwareCompanyRoles");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareCompilation", b =>
{
b.Property<ulong>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint unsigned");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<ulong>("Id"));
b.Property<DateTime>("CreatedOn")
.ValueGeneratedOnAdd()
.HasColumnType("datetime(6)");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<DateTime>("CreatedOn"));
b.Property<int?>("MachineId")
.HasColumnType("int(11)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<ulong?>("PredecessorId")
.HasColumnType("bigint unsigned");
b.Property<byte>("RelationshipType")
.HasColumnType("tinyint unsigned");
b.Property<ulong?>("SoftwareId")
.HasColumnType("bigint unsigned");
b.Property<DateTime>("UpdatedOn")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime(6)");
MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property<DateTime>("UpdatedOn"));
b.HasKey("Id");
b.HasIndex("MachineId");
b.HasIndex("Name");
b.HasIndex("PredecessorId");
b.HasIndex("SoftwareId");
b.ToTable("SoftwareCompilations");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareCompilationBySoftwareCompilation", b =>
{
b.Property<ulong>("ParentCompilationId")
.HasColumnType("bigint unsigned");
b.Property<ulong>("ChildCompilationId")
.HasColumnType("bigint unsigned");
b.HasKey("ParentCompilationId", "ChildCompilationId");
b.HasIndex("ChildCompilationId");
b.ToTable("SoftwareCompilationBySoftwareCompilation");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareCover", b =>
{
b.Property<Guid>("Id")
@@ -7784,6 +7853,9 @@ namespace Marechai.Database.Migrations
.IsRequired()
.HasColumnType("longtext");
b.Property<ulong?>("SoftwareCompilationId")
.HasColumnType("bigint unsigned");
b.Property<ulong?>("SoftwareId")
.HasColumnType("bigint unsigned");
@@ -7803,6 +7875,8 @@ namespace Marechai.Database.Migrations
b.HasIndex("GroupId");
b.HasIndex("SoftwareCompilationId");
b.HasIndex("SoftwareId");
b.HasIndex("SoftwareReleaseId");
@@ -8361,9 +8435,6 @@ namespace Marechai.Database.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<DateTime>("CreatedOn"));
b.Property<bool>("IsCompilation")
.HasColumnType("bit(1)");
b.Property<ulong?>("PlatformId")
.HasColumnType("bigint unsigned");
@@ -8376,6 +8447,9 @@ namespace Marechai.Database.Migrations
b.Property<byte>("ReleaseDatePrecision")
.HasColumnType("tinyint unsigned");
b.Property<ulong?>("SoftwareCompilationId")
.HasColumnType("bigint unsigned");
b.Property<ulong?>("SoftwareId")
.HasColumnType("bigint unsigned");
@@ -8397,6 +8471,8 @@ namespace Marechai.Database.Migrations
b.HasIndex("PublisherId");
b.HasIndex("SoftwareCompilationId");
b.HasIndex("SoftwareId");
b.HasIndex("SoftwareVersionId", "PlatformId");
@@ -8804,19 +8880,19 @@ namespace Marechai.Database.Migrations
b.ToTable("SoftwareVersions");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareVersionBySoftwareRelease", b =>
modelBuilder.Entity("Marechai.Database.Models.SoftwareVersionBySoftwareCompilation", b =>
{
b.Property<ulong>("ReleaseId")
b.Property<ulong>("SoftwareCompilationId")
.HasColumnType("bigint unsigned");
b.Property<ulong>("SoftwareVersionId")
.HasColumnType("bigint unsigned");
b.HasKey("ReleaseId", "SoftwareVersionId");
b.HasKey("SoftwareCompilationId", "SoftwareVersionId");
b.HasIndex("SoftwareVersionId");
b.ToTable("SoftwareVersionBySoftwareRelease");
b.ToTable("SoftwareVersionBySoftwareCompilation");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareVideo", b =>
@@ -11220,6 +11296,16 @@ namespace Marechai.Database.Migrations
b.Navigation("Release");
});
modelBuilder.Entity("Marechai.Database.Models.MobyGamesImportState", b =>
{
b.HasOne("Marechai.Database.Models.SoftwareCompilation", "SoftwareCompilation")
.WithMany()
.HasForeignKey("SoftwareCompilationId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SoftwareCompilation");
});
modelBuilder.Entity("Marechai.Database.Models.OldDosCategory", b =>
{
b.HasOne("Marechai.Database.Models.OldDosCategory", "Parent")
@@ -11765,23 +11851,23 @@ namespace Marechai.Database.Migrations
b.Navigation("Release");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareBySoftwareRelease", b =>
modelBuilder.Entity("Marechai.Database.Models.SoftwareBySoftwareCompilation", b =>
{
b.HasOne("Marechai.Database.Models.SoftwareRelease", "Release")
b.HasOne("Marechai.Database.Models.SoftwareCompilation", "SoftwareCompilation")
.WithMany("IncludedSoftware")
.HasForeignKey("ReleaseId")
.HasForeignKey("SoftwareCompilationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Marechai.Database.Models.Software", "Software")
.WithMany("CompilationReleases")
.WithMany("CompilationMemberships")
.HasForeignKey("SoftwareId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Release");
b.Navigation("Software");
b.Navigation("SoftwareCompilation");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareCompanyRole", b =>
@@ -11811,8 +11897,57 @@ namespace Marechai.Database.Migrations
b.Navigation("Software");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareCompilation", b =>
{
b.HasOne("Marechai.Database.Models.Machine", "Machine")
.WithMany()
.HasForeignKey("MachineId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Marechai.Database.Models.SoftwareCompilation", "Predecessor")
.WithMany("Successors")
.HasForeignKey("PredecessorId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Marechai.Database.Models.Software", "Software")
.WithMany()
.HasForeignKey("SoftwareId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Machine");
b.Navigation("Predecessor");
b.Navigation("Software");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareCompilationBySoftwareCompilation", b =>
{
b.HasOne("Marechai.Database.Models.SoftwareCompilation", "ChildCompilation")
.WithMany("ContainingCompilations")
.HasForeignKey("ChildCompilationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Marechai.Database.Models.SoftwareCompilation", "ParentCompilation")
.WithMany("IncludedCompilations")
.HasForeignKey("ParentCompilationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_SoftwareCompilationBySoftwareCompilation_SoftwareCompilatio~1");
b.Navigation("ChildCompilation");
b.Navigation("ParentCompilation");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareCover", b =>
{
b.HasOne("Marechai.Database.Models.SoftwareCompilation", "SoftwareCompilation")
.WithMany("Covers")
.HasForeignKey("SoftwareCompilationId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Marechai.Database.Models.Software", "Software")
.WithMany("Covers")
.HasForeignKey("SoftwareId")
@@ -11826,6 +11961,8 @@ namespace Marechai.Database.Migrations
b.Navigation("Release");
b.Navigation("Software");
b.Navigation("SoftwareCompilation");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareCoverCaptionTranslation", b =>
@@ -12021,6 +12158,11 @@ namespace Marechai.Database.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Marechai.Database.Models.SoftwareCompilation", "SoftwareCompilation")
.WithMany("Releases")
.HasForeignKey("SoftwareCompilationId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Marechai.Database.Models.Software", "Software")
.WithMany("DirectReleases")
.HasForeignKey("SoftwareId")
@@ -12037,6 +12179,8 @@ namespace Marechai.Database.Migrations
b.Navigation("Software");
b.Navigation("SoftwareCompilation");
b.Navigation("SoftwareVersion");
});
@@ -12222,21 +12366,21 @@ namespace Marechai.Database.Migrations
b.Navigation("Software");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareVersionBySoftwareRelease", b =>
modelBuilder.Entity("Marechai.Database.Models.SoftwareVersionBySoftwareCompilation", b =>
{
b.HasOne("Marechai.Database.Models.SoftwareRelease", "Release")
b.HasOne("Marechai.Database.Models.SoftwareCompilation", "SoftwareCompilation")
.WithMany("IncludedVersions")
.HasForeignKey("ReleaseId")
.HasForeignKey("SoftwareCompilationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Marechai.Database.Models.SoftwareVersion", "SoftwareVersion")
.WithMany("CompilationReleases")
.WithMany("CompilationMemberships")
.HasForeignKey("SoftwareVersionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Release");
b.Navigation("SoftwareCompilation");
b.Navigation("SoftwareVersion");
});
@@ -12858,7 +13002,7 @@ namespace Marechai.Database.Migrations
b.Navigation("CompanyRoles");
b.Navigation("CompilationReleases");
b.Navigation("CompilationMemberships");
b.Navigation("Covers");
@@ -12889,6 +13033,23 @@ namespace Marechai.Database.Migrations
b.Navigation("Videos");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareCompilation", b =>
{
b.Navigation("ContainingCompilations");
b.Navigation("Covers");
b.Navigation("IncludedCompilations");
b.Navigation("IncludedSoftware");
b.Navigation("IncludedVersions");
b.Navigation("Releases");
b.Navigation("Successors");
});
modelBuilder.Entity("Marechai.Database.Models.SoftwareFamily", b =>
{
b.Navigation("Children");
@@ -12927,10 +13088,6 @@ namespace Marechai.Database.Migrations
b.Navigation("Covers");
b.Navigation("IncludedSoftware");
b.Navigation("IncludedVersions");
b.Navigation("Languages");
b.Navigation("MinimumGpus");
@@ -12962,7 +13119,7 @@ namespace Marechai.Database.Migrations
b.Navigation("Companies");
b.Navigation("CompilationReleases");
b.Navigation("CompilationMemberships");
b.Navigation("OSCompatibility");

View File

@@ -175,8 +175,10 @@ public class MarechaiContext : IdentityDbContext<ApplicationUser, ApplicationRol
public virtual DbSet<MinimumGpuBySoftwareRelease> MinimumGpuBySoftwareRelease { get; set; }
public virtual DbSet<RecommendedGpuBySoftwareRelease> RecommendedGpuBySoftwareRelease { get; set; }
public virtual DbSet<SoundSynthBySoftwareRelease> SoundSynthBySoftwareRelease { get; set; }
public virtual DbSet<SoftwareVersionBySoftwareRelease> SoftwareVersionBySoftwareRelease { get; set; }
public virtual DbSet<SoftwareBySoftwareRelease> SoftwareBySoftwareRelease { get; set; }
public virtual DbSet<SoftwareCompilation> SoftwareCompilations { get; set; }
public virtual DbSet<SoftwareVersionBySoftwareCompilation> SoftwareVersionBySoftwareCompilation { get; set; }
public virtual DbSet<SoftwareBySoftwareCompilation> SoftwareBySoftwareCompilation { get; set; }
public virtual DbSet<SoftwareCompilationBySoftwareCompilation> SoftwareCompilationBySoftwareCompilation { get; set; }
public virtual DbSet<UnM49> UnM49 { get; set; }
public virtual DbSet<UnM49BySoftwareRelease> UnM49BySoftwareRelease { get; set; }
public virtual DbSet<LanguageBySoftwareRelease> LanguageBySoftwareRelease { get; set; }
@@ -2488,6 +2490,85 @@ public class MarechaiContext : IdentityDbContext<ApplicationUser, ApplicationRol
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<SoftwareCompilation>(entity =>
{
entity.HasIndex(x => x.Name);
entity.HasOne(x => x.Software)
.WithMany()
.HasForeignKey(x => x.SoftwareId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(x => x.Machine)
.WithMany()
.HasForeignKey(x => x.MachineId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasIndex(x => x.PredecessorId);
entity.HasOne(x => x.Predecessor)
.WithMany(x => x.Successors)
.HasForeignKey(x => x.PredecessorId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<SoftwareBySoftwareCompilation>(entity =>
{
entity.HasKey(x => new
{
x.SoftwareCompilationId,
x.SoftwareId
});
entity.HasOne(x => x.SoftwareCompilation)
.WithMany(x => x.IncludedSoftware)
.HasForeignKey(x => x.SoftwareCompilationId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Software)
.WithMany(x => x.CompilationMemberships)
.HasForeignKey(x => x.SoftwareId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<SoftwareVersionBySoftwareCompilation>(entity =>
{
entity.HasKey(x => new
{
x.SoftwareCompilationId,
x.SoftwareVersionId
});
entity.HasOne(x => x.SoftwareCompilation)
.WithMany(x => x.IncludedVersions)
.HasForeignKey(x => x.SoftwareCompilationId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.SoftwareVersion)
.WithMany(x => x.CompilationMemberships)
.HasForeignKey(x => x.SoftwareVersionId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<SoftwareCompilationBySoftwareCompilation>(entity =>
{
entity.HasKey(x => new
{
x.ParentCompilationId,
x.ChildCompilationId
});
entity.HasOne(x => x.ParentCompilation)
.WithMany(x => x.IncludedCompilations)
.HasForeignKey(x => x.ParentCompilationId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ChildCompilation)
.WithMany(x => x.ContainingCompilations)
.HasForeignKey(x => x.ChildCompilationId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<SoftwareDescription>(entity =>
{
entity.HasIndex(e => e.Text).IsFullText();
@@ -2600,6 +2681,12 @@ public class MarechaiContext : IdentityDbContext<ApplicationUser, ApplicationRol
.IsRequired(false)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.SoftwareCompilation)
.WithMany(x => x.Releases)
.HasForeignKey(x => x.SoftwareCompilationId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Platform)
.WithMany(x => x.SoftwareReleases)
.HasForeignKey(x => x.PlatformId)
@@ -2615,6 +2702,7 @@ public class MarechaiContext : IdentityDbContext<ApplicationUser, ApplicationRol
{
entity.HasIndex(x => x.SoftwareReleaseId);
entity.HasIndex(x => x.SoftwareId);
entity.HasIndex(x => x.SoftwareCompilationId);
entity.HasIndex(x => x.GroupId);
// Type-leading composite for the FrontCoverId backfill query
@@ -2636,6 +2724,12 @@ public class MarechaiContext : IdentityDbContext<ApplicationUser, ApplicationRol
.HasForeignKey(x => x.SoftwareReleaseId)
.IsRequired(false)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(x => x.SoftwareCompilation)
.WithMany(x => x.Covers)
.HasForeignKey(x => x.SoftwareCompilationId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<UnM49>(entity =>
@@ -2823,43 +2917,6 @@ public class MarechaiContext : IdentityDbContext<ApplicationUser, ApplicationRol
.HasForeignKey(x => x.SoundSynthId);
});
modelBuilder.Entity<SoftwareVersionBySoftwareRelease>(entity =>
{
entity.HasKey(x => new
{
x.ReleaseId,
x.SoftwareVersionId
});
entity.HasOne(x => x.Release)
.WithMany(x => x.IncludedVersions)
.HasForeignKey(x => x.ReleaseId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.SoftwareVersion)
.WithMany(x => x.CompilationReleases)
.HasForeignKey(x => x.SoftwareVersionId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<SoftwareBySoftwareRelease>(entity =>
{
entity.HasKey(x => new
{
x.ReleaseId,
x.SoftwareId
});
entity.HasOne(x => x.Release)
.WithMany(x => x.IncludedSoftware)
.HasForeignKey(x => x.ReleaseId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Software)
.WithMany(x => x.CompilationReleases)
.HasForeignKey(x => x.SoftwareId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<CollectedBook>(entity =>
{
@@ -2999,6 +3056,11 @@ public class MarechaiContext : IdentityDbContext<ApplicationUser, ApplicationRol
{
entity.HasIndex(e => e.MobyGameId).IsUnique();
entity.HasIndex(e => e.Status);
entity.HasOne(e => e.SoftwareCompilation)
.WithMany()
.HasForeignKey(e => e.SoftwareCompilationId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<IgdbPlatform>(entity =>

View File

@@ -22,5 +22,8 @@ public class MobyGamesImportState : BaseModel<long>
public ulong? SoftwareId { get; set; }
public ulong? SoftwareCompilationId { get; set; }
public virtual SoftwareCompilation SoftwareCompilation { get; set; }
public int? MobyNumericId { get; set; }
}

View File

@@ -23,7 +23,7 @@ public class Software : BaseModel<ulong>
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<SoftwareBySoftwareCompilation> CompilationMemberships { get; set; }
public virtual ICollection<SoftwareDescription> Descriptions { get; set; }
public virtual ICollection<SoftwareAlternativeTitle> AlternativeTitles { get; set; }
public virtual ICollection<GenreBySoftware> Genres { get; set; }

View File

@@ -0,0 +1,10 @@
namespace Marechai.Database.Models;
public class SoftwareBySoftwareCompilation
{
public ulong SoftwareCompilationId { get; set; }
public virtual SoftwareCompilation SoftwareCompilation { get; set; }
public ulong SoftwareId { get; set; }
public virtual Software Software { get; set; }
}

View File

@@ -1,10 +0,0 @@
namespace Marechai.Database.Models;
public class SoftwareBySoftwareRelease
{
public ulong ReleaseId { get; set; }
public virtual SoftwareRelease Release { get; set; }
public ulong SoftwareId { get; set; }
public virtual Software Software { get; set; }
}

View File

@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Marechai.Data;
namespace Marechai.Database.Models;
public class SoftwareCompilation : BaseModel<ulong>
{
[Required]
public string Name { get; set; }
public ulong? SoftwareId { get; set; }
public virtual Software Software { get; set; }
public int? MachineId { get; set; }
public virtual Machine Machine { get; set; }
public ulong? PredecessorId { get; set; }
public virtual SoftwareCompilation Predecessor { get; set; }
public SoftwareRelationshipType RelationshipType { get; set; } = SoftwareRelationshipType.Sequel;
public virtual ICollection<SoftwareCompilation> Successors { get; set; }
public virtual ICollection<SoftwareRelease> Releases { get; set; }
public virtual ICollection<SoftwareCover> Covers { get; set; }
public virtual ICollection<SoftwareBySoftwareCompilation> IncludedSoftware { get; set; }
public virtual ICollection<SoftwareVersionBySoftwareCompilation> IncludedVersions { get; set; }
public virtual ICollection<SoftwareCompilationBySoftwareCompilation> IncludedCompilations { get; set; }
public virtual ICollection<SoftwareCompilationBySoftwareCompilation> ContainingCompilations { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace Marechai.Database.Models;
public class SoftwareCompilationBySoftwareCompilation
{
public ulong ParentCompilationId { get; set; }
public virtual SoftwareCompilation ParentCompilation { get; set; }
public ulong ChildCompilationId { get; set; }
public virtual SoftwareCompilation ChildCompilation { get; set; }
}

View File

@@ -31,14 +31,18 @@ namespace Marechai.Database.Models;
public class SoftwareCover : BaseModel<Guid>
{
// Nullable: a cover attached to a compilation release (which bundles several Software
// entries with no single owner) is anchored by SoftwareReleaseId alone instead.
// Nullable: a cover is anchored by exactly one of SoftwareId, SoftwareReleaseId, or
// SoftwareCompilationId, depending on whether it belongs to a piece of Software, a
// specific SoftwareRelease, or a SoftwareCompilation (which has no single owning release).
public ulong? SoftwareId { get; set; }
public virtual Software Software { get; set; }
public ulong? SoftwareReleaseId { get; set; }
public virtual SoftwareRelease Release { get; set; }
public ulong? SoftwareCompilationId { get; set; }
public virtual SoftwareCompilation SoftwareCompilation { get; set; }
[StringLength(64)]
public string GroupId { get; set; }

View File

@@ -10,11 +10,12 @@ public class SoftwareRelease : BaseModel<ulong>
{
public string Title { get; set; }
public bool IsCompilation { get; set; }
public ulong? SoftwareId { get; set; }
public virtual Software Software { get; set; }
public ulong? SoftwareCompilationId { get; set; }
public virtual SoftwareCompilation SoftwareCompilation { get; set; }
public ulong? SoftwareVersionId { get; set; }
public virtual SoftwareVersion SoftwareVersion { get; set; }
@@ -37,8 +38,6 @@ public class SoftwareRelease : BaseModel<ulong>
public virtual ICollection<MinimumGpuBySoftwareRelease> MinimumGpus { get; set; }
public virtual ICollection<RecommendedGpuBySoftwareRelease> RecommendedGpus { get; set; }
public virtual ICollection<SoundSynthBySoftwareRelease> SupportedSoundSynths { get; set; }
public virtual ICollection<SoftwareVersionBySoftwareRelease> IncludedVersions { get; set; }
public virtual ICollection<SoftwareBySoftwareRelease> IncludedSoftware { get; set; }
public virtual ICollection<CollectedSoftwareRelease> CollectedBy { get; set; }
public virtual ICollection<SoftwareAttribute> Attributes { get; set; }
public virtual ICollection<SoftwareCover> Covers { get; set; }

View File

@@ -47,5 +47,5 @@ public class SoftwareVersion : BaseModel<ulong>
public virtual ICollection<SoftwareRelease> Releases { get; set; }
public virtual ICollection<CompanyBySoftwareVersion> Companies { get; set; }
public virtual ICollection<SoftwareScreenshot> Screenshots { get; set; }
public virtual ICollection<SoftwareVersionBySoftwareRelease> CompilationReleases { get; set; }
public virtual ICollection<SoftwareVersionBySoftwareCompilation> CompilationMemberships { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace Marechai.Database.Models;
public class SoftwareVersionBySoftwareCompilation
{
public ulong SoftwareCompilationId { get; set; }
public virtual SoftwareCompilation SoftwareCompilation { get; set; }
public ulong SoftwareVersionId { get; set; }
public virtual SoftwareVersion SoftwareVersion { get; set; }
}

View File

@@ -1,10 +0,0 @@
namespace Marechai.Database.Models;
public class SoftwareVersionBySoftwareRelease
{
public ulong ReleaseId { get; set; }
public virtual SoftwareRelease Release { get; set; }
public ulong SoftwareVersionId { get; set; }
public virtual SoftwareVersion SoftwareVersion { get; set; }
}

View File

@@ -168,11 +168,11 @@ public class SearchController(MarechaiContext context, FuzzySearchService fuzzy)
if(req.IncludesSoftwareId.HasValue)
{
ulong wantedSwId = (ulong)req.IncludesSoftwareId.Value;
// For each compilation row (EntityType=13), EntityId is the SoftwareRelease.Id (cast to long).
// Match when the compilation contains the wanted software via SoftwareBySoftwareRelease.
IQueryable<long> compilationIdsContaining = context.SoftwareBySoftwareRelease
// For each compilation row (EntityType=13), EntityId is the SoftwareCompilation.Id (cast to long).
// Match when the compilation contains the wanted software via SoftwareBySoftwareCompilation.
IQueryable<long> compilationIdsContaining = context.SoftwareBySoftwareCompilation
.Where(s => s.SoftwareId == wantedSwId)
.Select(s => (long)s.ReleaseId);
.Select(s => (long)s.SoftwareCompilationId);
baseQ = baseQ.Where(e => (byte)e.EntityType == 13 && compilationIdsContaining.Contains(e.EntityId));
}
if(!string.IsNullOrWhiteSpace(req.Letter))
@@ -256,9 +256,9 @@ public class SearchController(MarechaiContext context, FuzzySearchService fuzzy)
if(req.IncludesSoftwareId.HasValue)
{
ulong wantedSwId = (ulong)req.IncludesSoftwareId.Value;
HashSet<long> compIds = (await context.SoftwareBySoftwareRelease
HashSet<long> compIds = (await context.SoftwareBySoftwareCompilation
.Where(s => s.SoftwareId == wantedSwId)
.Select(s => (long)s.ReleaseId)
.Select(s => (long)s.SoftwareCompilationId)
.ToListAsync()).ToHashSet();
ftFiltered = ftFiltered.Where(e => (byte)e.EntityType == 13 && compIds.Contains(e.EntityId));
}

View File

@@ -0,0 +1,469 @@
/*******************************************************************************
// 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.Security.Claims;
using System.Threading.Tasks;
using Marechai.Data;
using Marechai.Data.Dtos;
using Marechai.Database.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Marechai.Server.Controllers;
[Route("/software-compilations")]
[ApiController]
public class SoftwareCompilationsController(MarechaiContext context) : ControllerBase
{
[HttpGet("count")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<int> GetCountAsync([FromQuery] string search = null)
{
IQueryable<SoftwareCompilation> query = context.SoftwareCompilations;
if(!string.IsNullOrWhiteSpace(search)) query = query.Where(c => c.Name.Contains(search));
return query.CountAsync();
}
[HttpGet]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<SoftwareCompilationDto>> GetAsync([FromQuery] int? skip = null, [FromQuery] int? take = null,
[FromQuery] string search = null)
{
IQueryable<SoftwareCompilation> query = context.SoftwareCompilations;
if(!string.IsNullOrWhiteSpace(search)) query = query.Where(c => c.Name.Contains(search));
query = query.OrderBy(c => MarechaiContext.NaturalSortKey(c.Name));
if(skip.HasValue) query = query.Skip(skip.Value);
if(take.HasValue) query = query.Take(take.Value);
return query.Select(c => ProjectToDto(c)).ToListAsync();
}
[HttpGet("{id:ulong}")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<SoftwareCompilationDto> GetAsync(ulong id) =>
context.SoftwareCompilations.Where(c => c.Id == id).Select(c => ProjectToDto(c)).FirstOrDefaultAsync();
static SoftwareCompilationDto ProjectToDto(SoftwareCompilation c) => new()
{
Id = c.Id,
Name = c.Name,
SoftwareId = c.SoftwareId,
Software = c.Software.Name,
MachineId = c.MachineId,
Machine = c.Machine.Name,
PredecessorId = c.PredecessorId,
Predecessor = c.Predecessor.Name,
RelationshipType = c.RelationshipType,
Successors = c.Successors.Select(x => new SoftwareCompilationSuccessorDto
{
Id = x.Id,
Name = x.Name,
RelationshipType = x.RelationshipType
}).ToList()
};
[HttpPost]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<ulong>> CreateAsync([FromBody] SoftwareCompilationDto dto)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
var model = new SoftwareCompilation
{
Name = dto.Name,
SoftwareId = dto.SoftwareId,
MachineId = dto.MachineId,
PredecessorId = dto.PredecessorId,
RelationshipType = dto.RelationshipType
};
await context.SoftwareCompilations.AddAsync(model);
await context.News.AddAsync(new News
{
Date = DateTime.UtcNow,
Type = NewsType.NewSoftwareInDb,
Name = model.Name
});
await context.SaveChangesWithUserAsync(userId);
return model.Id;
}
[HttpPut("{id:ulong}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdateAsync(ulong id, [FromBody] SoftwareCompilationDto dto)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareCompilation model = await context.SoftwareCompilations.FindAsync(id);
if(model is null) return NotFound();
model.Name = dto.Name;
model.SoftwareId = dto.SoftwareId;
model.MachineId = dto.MachineId;
model.PredecessorId = dto.PredecessorId;
model.RelationshipType = dto.RelationshipType;
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
[HttpDelete("{id:ulong}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DeleteAsync(ulong id)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareCompilation model = await context.SoftwareCompilations.FindAsync(id);
if(model is null) return NotFound();
context.SoftwareCompilations.Remove(model);
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
[HttpGet("{id:ulong}/releases")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<SoftwareReleaseDto>> GetReleasesAsync(ulong id) => context.SoftwareReleases
.Where(r => r.SoftwareCompilationId == id)
.OrderBy(r => r.ReleaseDate)
.Select(r => new SoftwareReleaseDto
{
Id = r.Id,
Title = r.Title,
SoftwareCompilationId = r.SoftwareCompilationId,
SoftwareCompilation = r.SoftwareCompilation.Name,
PlatformId = r.PlatformId,
Platform = r.Platform.Name,
PublisherId = r.PublisherId,
Publisher = r.Publisher.Name,
ReleaseDate = r.ReleaseDate,
ReleaseDatePrecision = r.ReleaseDatePrecision
})
.ToListAsync();
// --- Covers ---
[HttpGet("{id:ulong}/covers")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<SoftwareCoverDto>> GetCoversAsync(ulong id) => context.SoftwareCovers
.Where(c => c.SoftwareCompilationId == id)
.Select(c => new SoftwareCoverDto
{
Id = c.Id,
SoftwareId = c.SoftwareId,
SoftwareReleaseId = c.SoftwareReleaseId,
GroupId = c.GroupId,
Type = (int)c.Type,
Caption = c.Caption,
OriginalExtension = c.OriginalExtension
})
.ToListAsync();
// --- Included software / version junction endpoints ---
[HttpGet("{id:ulong}/software")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<SoftwareBySoftwareCompilationDto>> GetIncludedSoftwareAsync(ulong id) =>
context.SoftwareBySoftwareCompilation
.Where(x => x.SoftwareCompilationId == id)
.OrderBy(x => x.Software.Name)
.Select(x => new SoftwareBySoftwareCompilationDto
{
SoftwareCompilationId = x.SoftwareCompilationId,
SoftwareId = x.SoftwareId,
SoftwareName = x.Software.Name
})
.ToListAsync();
[HttpPost("{id:ulong}/software")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> AddIncludedSoftwareAsync(ulong id, [FromBody] SoftwareBySoftwareCompilationDto dto)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareCompilation compilation = await context.SoftwareCompilations.FindAsync(id);
if(compilation is null) return NotFound();
bool exists = await context.SoftwareBySoftwareCompilation
.AnyAsync(x => x.SoftwareCompilationId == id && x.SoftwareId == dto.SoftwareId);
if(exists) return Problem(detail: "This software is already included in the compilation.", statusCode: StatusCodes.Status400BadRequest);
bool softwareExists = await context.Softwares.AnyAsync(s => s.Id == dto.SoftwareId);
if(!softwareExists) return Problem(detail: "The specified software does not exist.", statusCode: StatusCodes.Status400BadRequest);
await context.SoftwareBySoftwareCompilation.AddAsync(new SoftwareBySoftwareCompilation
{
SoftwareCompilationId = id,
SoftwareId = dto.SoftwareId
});
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
[HttpDelete("{id:ulong}/software/{softwareId:ulong}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> RemoveIncludedSoftwareAsync(ulong id, ulong softwareId)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareBySoftwareCompilation entry =
await context.SoftwareBySoftwareCompilation
.FirstOrDefaultAsync(x => x.SoftwareCompilationId == id && x.SoftwareId == softwareId);
if(entry is null) return NotFound();
context.SoftwareBySoftwareCompilation.Remove(entry);
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
[HttpGet("{id:ulong}/versions")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<SoftwareVersionBySoftwareCompilationDto>> GetIncludedVersionsAsync(ulong id) =>
context.SoftwareVersionBySoftwareCompilation
.Where(x => x.SoftwareCompilationId == id)
.OrderBy(x => x.SoftwareVersion.Software.Name)
.ThenBy(x => x.SoftwareVersion.VersionString)
.Select(x => new SoftwareVersionBySoftwareCompilationDto
{
SoftwareCompilationId = x.SoftwareCompilationId,
SoftwareVersionId = x.SoftwareVersionId,
SoftwareVersion = x.SoftwareVersion.VersionString,
SoftwareName = x.SoftwareVersion.Software.Name
})
.ToListAsync();
[HttpPost("{id:ulong}/versions")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> AddIncludedVersionAsync(ulong id,
[FromBody] SoftwareVersionBySoftwareCompilationDto dto)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareCompilation compilation = await context.SoftwareCompilations.FindAsync(id);
if(compilation is null) return NotFound();
bool exists = await context.SoftwareVersionBySoftwareCompilation
.AnyAsync(x => x.SoftwareCompilationId == id &&
x.SoftwareVersionId == dto.SoftwareVersionId);
if(exists) return Problem(detail: "This version is already included in the compilation.", statusCode: StatusCodes.Status400BadRequest);
bool versionExists = await context.SoftwareVersions.AnyAsync(v => v.Id == dto.SoftwareVersionId);
if(!versionExists) return Problem(detail: "The specified software version does not exist.", statusCode: StatusCodes.Status400BadRequest);
await context.SoftwareVersionBySoftwareCompilation.AddAsync(new SoftwareVersionBySoftwareCompilation
{
SoftwareCompilationId = id,
SoftwareVersionId = dto.SoftwareVersionId
});
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
[HttpDelete("{id:ulong}/versions/{versionId:ulong}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> RemoveIncludedVersionAsync(ulong id, ulong versionId)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareVersionBySoftwareCompilation entry =
await context.SoftwareVersionBySoftwareCompilation
.FirstOrDefaultAsync(x => x.SoftwareCompilationId == id &&
x.SoftwareVersionId == versionId);
if(entry is null) return NotFound();
context.SoftwareVersionBySoftwareCompilation.Remove(entry);
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
// --- Nested-compilation junction endpoints (a child compilation can have multiple parents) ---
[HttpGet("{id:ulong}/compilations")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<SoftwareCompilationDto>> GetIncludedCompilationsAsync(ulong id) =>
context.SoftwareCompilationBySoftwareCompilation
.Where(x => x.ParentCompilationId == id)
.OrderBy(x => x.ChildCompilation.Name)
.Select(x => ProjectToDto(x.ChildCompilation))
.ToListAsync();
[HttpPost("{id:ulong}/compilations/{childId:ulong}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> AddIncludedCompilationAsync(ulong id, ulong childId)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
if(id == childId)
return Problem(detail: "A compilation cannot contain itself.", statusCode: StatusCodes.Status400BadRequest);
bool parentExists = await context.SoftwareCompilations.AnyAsync(c => c.Id == id);
bool childExists = await context.SoftwareCompilations.AnyAsync(c => c.Id == childId);
if(!parentExists || !childExists) return NotFound();
// Cycle guard: reject if `id` is already a descendant of `childId`, walking the
// containment junction breadth-first.
var toVisit = new Queue<ulong>([childId]);
var visited = new HashSet<ulong>();
while(toVisit.Count > 0)
{
ulong current = toVisit.Dequeue();
if(current == id)
return Problem(detail: "Adding this compilation would create a containment cycle.", statusCode: StatusCodes.Status400BadRequest);
if(!visited.Add(current)) continue;
List<ulong> children = await context.SoftwareCompilationBySoftwareCompilation
.Where(x => x.ParentCompilationId == current)
.Select(x => x.ChildCompilationId)
.ToListAsync();
foreach(ulong child in children) toVisit.Enqueue(child);
}
bool exists = await context.SoftwareCompilationBySoftwareCompilation
.AnyAsync(x => x.ParentCompilationId == id && x.ChildCompilationId == childId);
if(exists) return Problem(detail: "This compilation is already included.", statusCode: StatusCodes.Status400BadRequest);
await context.SoftwareCompilationBySoftwareCompilation.AddAsync(new SoftwareCompilationBySoftwareCompilation
{
ParentCompilationId = id,
ChildCompilationId = childId
});
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
[HttpDelete("{id:ulong}/compilations/{childId:ulong}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> RemoveIncludedCompilationAsync(ulong id, ulong childId)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareCompilationBySoftwareCompilation entry =
await context.SoftwareCompilationBySoftwareCompilation
.FirstOrDefaultAsync(x => x.ParentCompilationId == id && x.ChildCompilationId == childId);
if(entry is null) return NotFound();
context.SoftwareCompilationBySoftwareCompilation.Remove(entry);
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
}

View File

@@ -107,10 +107,10 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
.Distinct()
.ToList();
List<ulong> compilationReleaseIds = list.Where(d => d.IsCompilation)
.Select(d => d.Id)
.Distinct()
.ToList();
List<ulong> compilationIds = list.Where(d => d.IsCompilation)
.Select(d => d.Id)
.Distinct()
.ToList();
var softwareCovers = new Dictionary<ulong, Guid>();
@@ -136,22 +136,22 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
var compilationCovers = new Dictionary<ulong, Guid>();
if(compilationReleaseIds.Count > 0)
if(compilationIds.Count > 0)
{
// Compilation rows carry the SoftwareRelease.Id as their dto Id, so look up
// covers directly via SoftwareCovers.SoftwareReleaseId (single indexed column).
// Compilation rows carry the SoftwareCompilation.Id as their dto Id, so look up
// covers directly via SoftwareCovers.SoftwareCompilationId (single indexed column).
var rows = await context.SoftwareCovers
.Where(sc => sc.Type == SoftwareCoverType.Front &&
sc.SoftwareReleaseId.HasValue &&
compilationReleaseIds.Contains(sc.SoftwareReleaseId.Value))
sc.SoftwareCompilationId.HasValue &&
compilationIds.Contains(sc.SoftwareCompilationId.Value))
.Select(sc => new
{
ReleaseId = sc.SoftwareReleaseId.Value,
CoverId = sc.Id
CompilationId = sc.SoftwareCompilationId.Value,
CoverId = sc.Id
})
.ToListAsync(ct);
foreach(IGrouping<ulong, Guid> g in rows.GroupBy(x => x.ReleaseId, x => x.CoverId))
foreach(IGrouping<ulong, Guid> g in rows.GroupBy(x => x.CompilationId, x => x.CoverId))
compilationCovers[g.Key] = g.Min();
}
@@ -216,7 +216,7 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
int softwareTotal = await baseQuery.CountAsync();
int compilationsTotal = includeCompilations
? await context.SoftwareReleases.CountAsync(r => r.IsCompilation)
? await context.SoftwareCompilations.CountAsync()
: 0;
int total = softwareTotal + compilationsTotal;
@@ -234,8 +234,8 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
if(!includeCompilations) return softwareCount;
IQueryable<SoftwareRelease> compQuery =
context.SoftwareReleases.Where(r => r.IsCompilation && r.Title.Contains(search));
IQueryable<SoftwareCompilation> compQuery =
context.SoftwareCompilations.Where(c => c.Name.Contains(search));
int compilationCount = await compQuery.CountAsync();
@@ -345,12 +345,12 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
if(!ShouldIncludeCompilations(kind)) return softwareQuery;
IQueryable<SoftwareDto> compilationsQuery = context.SoftwareReleases
.Where(r => r.IsCompilation && EF.Functions.Like(r.Title, $"{c}%"))
.Select(r => new SoftwareDto
IQueryable<SoftwareDto> compilationsQuery = context.SoftwareCompilations
.Where(comp => EF.Functions.Like(comp.Name, $"{c}%"))
.Select(comp => new SoftwareDto
{
Id = r.Id,
Name = r.Title,
Id = comp.Id,
Name = comp.Name,
FamilyId = null,
Family = null,
Kind = default,
@@ -416,14 +416,12 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
if(!ShouldIncludeCompilations(kind)) return softwareQuery;
IQueryable<SoftwareDto> compilationsQuery = context.SoftwareReleases
.Where(r => r.IsCompilation &&
r.ReleaseDate != null &&
r.ReleaseDate.Value.Year == year)
.Select(r => new SoftwareDto
IQueryable<SoftwareDto> compilationsQuery = context.SoftwareCompilations
.Where(comp => comp.Releases.Any(r => r.ReleaseDate != null && r.ReleaseDate.Value.Year == year))
.Select(comp => new SoftwareDto
{
Id = r.Id,
Name = r.Title,
Id = comp.Id,
Name = comp.Name,
FamilyId = null,
Family = null,
Kind = default,
@@ -487,12 +485,12 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
if(!ShouldIncludeCompilations(kind)) return softwareQuery;
IQueryable<SoftwareDto> compilationsQuery = context.SoftwareReleases
.Where(r => r.IsCompilation && r.PlatformId == platformId)
.Select(r => new SoftwareDto
IQueryable<SoftwareDto> compilationsQuery = context.SoftwareCompilations
.Where(comp => comp.Releases.Any(r => r.PlatformId == platformId))
.Select(comp => new SoftwareDto
{
Id = r.Id,
Name = r.Title,
Id = comp.Id,
Name = comp.Name,
FamilyId = null,
Family = null,
Kind = default,
@@ -640,15 +638,15 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
if(!ShouldIncludeCompilations(kind)) return softwareQuery;
IQueryable<SoftwareRelease> baseCompQuery = context.SoftwareReleases.Where(r => r.IsCompilation);
IQueryable<SoftwareCompilation> baseCompQuery = context.SoftwareCompilations;
if(!string.IsNullOrWhiteSpace(search))
baseCompQuery = baseCompQuery.Where(r => r.Title.Contains(search));
baseCompQuery = baseCompQuery.Where(comp => comp.Name.Contains(search));
IQueryable<SoftwareDto> compilationsQuery = baseCompQuery.Select(r => new SoftwareDto
IQueryable<SoftwareDto> compilationsQuery = baseCompQuery.Select(comp => new SoftwareDto
{
Id = r.Id,
Name = r.Title,
Id = comp.Id,
Name = comp.Name,
FamilyId = null,
Family = null,
Kind = default,
@@ -1682,19 +1680,19 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
sourceCredits.Count(c => targetCreditKeys.Contains((c.PersonId, c.Role.ToLowerInvariant())));
// Count compilation references and duplicates
List<ulong> sourceCompilationReleaseIds = await context.SoftwareBySoftwareRelease
.Where(s => s.SoftwareId == sourceId)
.Select(s => s.ReleaseId)
.ToListAsync();
List<ulong> sourceCompilationIds = await context.SoftwareBySoftwareCompilation
.Where(s => s.SoftwareId == sourceId)
.Select(s => s.SoftwareCompilationId)
.ToListAsync();
HashSet<ulong> targetCompilationReleaseIds = (await context.SoftwareBySoftwareRelease
.Where(s => s.SoftwareId == targetId)
.Select(s => s.ReleaseId)
.ToListAsync())
HashSet<ulong> targetCompilationIds = (await context.SoftwareBySoftwareCompilation
.Where(s => s.SoftwareId == targetId)
.Select(s => s.SoftwareCompilationId)
.ToListAsync())
.ToHashSet();
int compilationDuplicates =
sourceCompilationReleaseIds.Count(rid => targetCompilationReleaseIds.Contains(rid));
sourceCompilationIds.Count(rid => targetCompilationIds.Contains(rid));
// Count promo art
int promoArtCount = await context.SoftwarePromoArt.CountAsync(p => p.SoftwareId == sourceId);
@@ -1733,7 +1731,7 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
GenresDuplicates = genresDuplicates,
CreditsTotal = sourceCredits.Count,
CreditsDuplicates = creditsDuplicates,
CompilationReferencesTotal = sourceCompilationReleaseIds.Count,
CompilationReferencesTotal = sourceCompilationIds.Count,
CompilationReferencesDuplicates = compilationDuplicates,
PromoArtCount = promoArtCount,
VideosTotal = sourceVideos.Count,
@@ -1922,29 +1920,29 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
}
}
// 8. Merge SoftwareBySoftwareRelease compilation references (composite PK — must remove+add)
List<SoftwareBySoftwareRelease> sourceCompilationRefs =
await context.SoftwareBySoftwareRelease.Where(s => s.SoftwareId == sourceId).ToListAsync();
// 8. Merge SoftwareBySoftwareCompilation compilation references (composite PK — must remove+add)
List<SoftwareBySoftwareCompilation> sourceCompilationRefs =
await context.SoftwareBySoftwareCompilation.Where(s => s.SoftwareId == sourceId).ToListAsync();
HashSet<ulong> targetCompilationReleaseIds = (await context.SoftwareBySoftwareRelease
.Where(s => s.SoftwareId == targetId)
.Select(s => s.ReleaseId)
.ToListAsync())
HashSet<ulong> targetCompilationIds = (await context.SoftwareBySoftwareCompilation
.Where(s => s.SoftwareId == targetId)
.Select(s => s.SoftwareCompilationId)
.ToListAsync())
.ToHashSet();
foreach(SoftwareBySoftwareRelease compRef in sourceCompilationRefs)
foreach(SoftwareBySoftwareCompilation compRef in sourceCompilationRefs)
{
context.SoftwareBySoftwareRelease.Remove(compRef);
context.SoftwareBySoftwareCompilation.Remove(compRef);
if(!targetCompilationReleaseIds.Contains(compRef.ReleaseId))
if(!targetCompilationIds.Contains(compRef.SoftwareCompilationId))
{
context.SoftwareBySoftwareRelease.Add(new SoftwareBySoftwareRelease
context.SoftwareBySoftwareCompilation.Add(new SoftwareBySoftwareCompilation
{
ReleaseId = compRef.ReleaseId,
SoftwareId = targetId
SoftwareCompilationId = compRef.SoftwareCompilationId,
SoftwareId = targetId
});
targetCompilationReleaseIds.Add(compRef.ReleaseId);
targetCompilationIds.Add(compRef.SoftwareCompilationId);
}
}
@@ -2331,14 +2329,13 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
if(!ShouldIncludeCompilations(kind)) return softwareQuery;
IQueryable<SoftwareDto> compilationsQuery = context.SoftwareReleases
.Where(r => r.IsCompilation &&
(r.IncludedSoftware.Any(s => s.Software.Genres.Any(g => g.GenreId == genreId)) ||
r.IncludedVersions.Any(v => v.SoftwareVersion.Software.Genres.Any(g => g.GenreId == genreId))))
.Select(r => new SoftwareDto
IQueryable<SoftwareDto> compilationsQuery = context.SoftwareCompilations
.Where(comp => comp.IncludedSoftware.Any(s => s.Software.Genres.Any(g => g.GenreId == genreId)) ||
comp.IncludedVersions.Any(v => v.SoftwareVersion.Software.Genres.Any(g => g.GenreId == genreId)))
.Select(comp => new SoftwareDto
{
Id = r.Id,
Name = r.Title,
Id = comp.Id,
Name = comp.Name,
FamilyId = null,
Family = null,
Kind = default,
@@ -2477,15 +2474,14 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use
if(!ShouldIncludeCompilations(kind)) return softwareQuery;
IQueryable<SoftwareDto> compilationsQuery = context.SoftwareReleases
.Where(r => r.IsCompilation &&
r.Attributes.Any(a => a.Category == "Spec" &&
a.Key == key &&
a.Value == value))
.Select(r => new SoftwareDto
IQueryable<SoftwareDto> compilationsQuery = context.SoftwareCompilations
.Where(comp => comp.Releases.Any(r => r.Attributes.Any(a => a.Category == "Spec" &&
a.Key == key &&
a.Value == value)))
.Select(comp => new SoftwareDto
{
Id = r.Id,
Name = r.Title,
Id = comp.Id,
Name = comp.Name,
FamilyId = null,
Family = null,
Kind = default,

View File

@@ -98,9 +98,10 @@ public class SoftwareReleasesController(MarechaiContext contex
{
Id = r.Id,
Title = r.Title,
IsCompilation = r.IsCompilation,
SoftwareId = r.SoftwareId,
Software = r.Software.Name,
SoftwareCompilationId = r.SoftwareCompilationId,
SoftwareCompilation = r.SoftwareCompilation.Name,
SoftwareVersionId = r.SoftwareVersionId,
SoftwareVersion = r.SoftwareVersion.VersionString,
PlatformId = r.PlatformId,
@@ -165,9 +166,10 @@ public class SoftwareReleasesController(MarechaiContext contex
{
Id = r.Id,
Title = r.Title,
IsCompilation = r.IsCompilation,
SoftwareId = r.SoftwareId,
Software = r.Software.Name,
SoftwareCompilationId = r.SoftwareCompilationId,
SoftwareCompilation = r.SoftwareCompilation.Name,
SoftwareVersionId = r.SoftwareVersionId,
SoftwareVersion = r.SoftwareVersion.VersionString,
PlatformId = r.PlatformId,
@@ -199,7 +201,7 @@ public class SoftwareReleasesController(MarechaiContext contex
public Task<int> GetReleasesCountBySoftwareAsync(ulong softwareId, [FromQuery] string search = null)
{
IQueryable<SoftwareRelease> query = context.SoftwareReleases
.Where(r => r.SoftwareId == softwareId && !r.IsCompilation);
.Where(r => r.SoftwareId == softwareId);
if(!string.IsNullOrWhiteSpace(search))
query = query.Where(r => (r.Title != null && r.Title.Contains(search)) ||
@@ -218,7 +220,7 @@ public class SoftwareReleasesController(MarechaiContext contex
[FromQuery] string search = null)
{
IQueryable<SoftwareRelease> query = context.SoftwareReleases
.Where(r => r.SoftwareId == softwareId && !r.IsCompilation);
.Where(r => r.SoftwareId == softwareId);
if(!string.IsNullOrWhiteSpace(search))
query = query.Where(r => (r.Title != null && r.Title.Contains(search)) ||
@@ -235,9 +237,10 @@ public class SoftwareReleasesController(MarechaiContext contex
{
Id = r.Id,
Title = r.Title,
IsCompilation = r.IsCompilation,
SoftwareId = r.SoftwareId,
Software = r.Software.Name,
SoftwareCompilationId = r.SoftwareCompilationId,
SoftwareCompilation = r.SoftwareCompilation.Name,
SoftwareVersionId = r.SoftwareVersionId,
SoftwareVersion = r.SoftwareVersion.VersionString,
PlatformId = r.PlatformId,
@@ -271,9 +274,10 @@ public class SoftwareReleasesController(MarechaiContext contex
{
Id = r.Id,
Title = r.Title,
IsCompilation = r.IsCompilation,
SoftwareId = r.SoftwareId,
Software = r.Software.Name,
SoftwareCompilationId = r.SoftwareCompilationId,
SoftwareCompilation = r.SoftwareCompilation.Name,
SoftwareVersionId = r.SoftwareVersionId,
SoftwareVersion = r.SoftwareVersion.VersionString,
PlatformId = r.PlatformId,
@@ -360,9 +364,10 @@ public class SoftwareReleasesController(MarechaiContext contex
if(model is null) return NotFound();
// Enforce mutual exclusivity: cannot change IsCompilation after creation
if(model.IsCompilation != dto.IsCompilation)
return Problem(detail: "Cannot change a release between single-release and compilation modes.", statusCode: StatusCodes.Status400BadRequest);
// Cannot move a release between Software, SoftwareCompilation, or a different
// SoftwareCompilation after creation
if(model.SoftwareCompilationId != dto.SoftwareCompilationId)
return Problem(detail: "Cannot change the compilation associated with a release.", statusCode: StatusCodes.Status400BadRequest);
// Cannot change SoftwareId after creation
if(model.SoftwareId != dto.SoftwareId)
@@ -404,9 +409,9 @@ public class SoftwareReleasesController(MarechaiContext contex
var model = new SoftwareRelease
{
Title = dto.Title,
IsCompilation = dto.IsCompilation,
SoftwareId = dto.IsCompilation ? null : dto.SoftwareId,
SoftwareVersionId = dto.IsCompilation ? null : dto.SoftwareVersionId,
SoftwareCompilationId = dto.SoftwareCompilationId,
SoftwareId = dto.SoftwareCompilationId.HasValue ? null : dto.SoftwareId,
SoftwareVersionId = dto.SoftwareCompilationId.HasValue ? null : dto.SoftwareVersionId,
PlatformId = dto.PlatformId,
PublisherId = dto.PublisherId,
ReleaseDate = dto.ReleaseDate,
@@ -460,120 +465,22 @@ public class SoftwareReleasesController(MarechaiContext contex
return Ok();
}
// --- Compilation junction endpoints ---
[HttpGet("{releaseId:ulong}/versions")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<SoftwareVersionBySoftwareReleaseDto>> GetIncludedVersionsAsync(ulong releaseId) =>
context.SoftwareVersionBySoftwareRelease
.Where(x => x.ReleaseId == releaseId)
.OrderBy(x => x.SoftwareVersion.Software.Name)
.ThenBy(x => x.SoftwareVersion.VersionString)
.Select(x => new SoftwareVersionBySoftwareReleaseDto
{
ReleaseId = x.ReleaseId,
SoftwareVersionId = x.SoftwareVersionId,
SoftwareVersion = x.SoftwareVersion.VersionString,
SoftwareName = x.SoftwareVersion.Software.Name
})
.ToListAsync();
[HttpPost("{releaseId:ulong}/versions")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> AddIncludedVersionAsync(ulong releaseId,
[FromBody] SoftwareVersionBySoftwareReleaseDto dto)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareRelease release = await context.SoftwareReleases.FindAsync(releaseId);
if(release is null) return NotFound();
if(!release.IsCompilation)
return Problem(detail: "Cannot add included versions to a non-compilation release.", statusCode: StatusCodes.Status400BadRequest);
// Ensure this is a versioned compilation (not a versionless one)
bool hasIncludedSoftware = await context.SoftwareBySoftwareRelease
.AnyAsync(x => x.ReleaseId == releaseId);
if(hasIncludedSoftware)
return Problem(detail: "Cannot add included versions to a versionless compilation. Use included software instead.", statusCode: StatusCodes.Status400BadRequest);
bool exists = await context.SoftwareVersionBySoftwareRelease
.AnyAsync(x => x.ReleaseId == releaseId
&& x.SoftwareVersionId == dto.SoftwareVersionId);
if(exists) return Problem(detail: "This version is already included in the release.", statusCode: StatusCodes.Status400BadRequest);
bool versionExists = await context.SoftwareVersions.AnyAsync(v => v.Id == dto.SoftwareVersionId);
if(!versionExists) return Problem(detail: "The specified software version does not exist.", statusCode: StatusCodes.Status400BadRequest);
await context.SoftwareVersionBySoftwareRelease.AddAsync(new SoftwareVersionBySoftwareRelease
{
ReleaseId = releaseId,
SoftwareVersionId = dto.SoftwareVersionId
});
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
[HttpDelete("{releaseId:ulong}/versions/{versionId:ulong}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> RemoveIncludedVersionAsync(ulong releaseId, ulong versionId)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareRelease release = await context.SoftwareReleases.FindAsync(releaseId);
if(release is null) return NotFound();
if(!release.IsCompilation)
return Problem(detail: "Cannot remove included versions from a non-compilation release.", statusCode: StatusCodes.Status400BadRequest);
SoftwareVersionBySoftwareRelease entry =
await context.SoftwareVersionBySoftwareRelease
.FirstOrDefaultAsync(x => x.ReleaseId == releaseId
&& x.SoftwareVersionId == versionId);
if(entry is null) return NotFound();
context.SoftwareVersionBySoftwareRelease.Remove(entry);
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
// --- Compilation listing endpoints ---
[HttpGet("compilations")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<SoftwareReleaseDto>> GetCompilationsAsync() => context.SoftwareReleases
.Where(r => r.IsCompilation)
.Where(r => r.SoftwareCompilationId != null)
.OrderBy(r => r.Title)
.Select(r => new SoftwareReleaseDto
{
Id = r.Id,
Title = r.Title,
IsCompilation = r.IsCompilation,
SoftwareId = r.SoftwareId,
Software = r.Software.Name,
SoftwareCompilationId = r.SoftwareCompilationId,
SoftwareCompilation = r.SoftwareCompilation.Name,
SoftwareVersionId = r.SoftwareVersionId,
PlatformId = r.PlatformId,
Platform = r.Platform.Name,
@@ -604,18 +511,22 @@ public class SoftwareReleasesController(MarechaiContext contex
// the projection's Where clause instead of materializing IDs first and then
// running a separate Contains(...) query.
context.SoftwareReleases
.Where(r => context.SoftwareVersionBySoftwareRelease
.Any(x => x.ReleaseId == r.Id && x.SoftwareVersion.SoftwareId == softwareId) ||
context.SoftwareBySoftwareRelease
.Any(x => x.ReleaseId == r.Id && x.SoftwareId == softwareId))
.Where(r => r.SoftwareCompilationId != null &&
(context.SoftwareVersionBySoftwareCompilation
.Any(x => x.SoftwareCompilationId == r.SoftwareCompilationId &&
x.SoftwareVersion.SoftwareId == softwareId) ||
context.SoftwareBySoftwareCompilation
.Any(x => x.SoftwareCompilationId == r.SoftwareCompilationId &&
x.SoftwareId == softwareId)))
.OrderBy(r => r.Title)
.Select(r => new SoftwareReleaseDto
{
Id = r.Id,
Title = r.Title,
IsCompilation = r.IsCompilation,
SoftwareId = r.SoftwareId,
Software = r.Software.Name,
SoftwareCompilationId = r.SoftwareCompilationId,
SoftwareCompilation = r.SoftwareCompilation.Name,
SoftwareVersionId = r.SoftwareVersionId,
PlatformId = r.PlatformId,
Platform = r.Platform.Name,
@@ -640,103 +551,6 @@ public class SoftwareReleasesController(MarechaiContext contex
})
.ToListAsync();
// --- Versionless compilation junction endpoints ---
[HttpGet("{releaseId:ulong}/software")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<SoftwareBySoftwareReleaseDto>> GetIncludedSoftwareAsync(ulong releaseId) =>
context.SoftwareBySoftwareRelease
.Where(x => x.ReleaseId == releaseId)
.OrderBy(x => x.Software.Name)
.Select(x => new SoftwareBySoftwareReleaseDto
{
ReleaseId = x.ReleaseId,
SoftwareId = x.SoftwareId,
SoftwareName = x.Software.Name
})
.ToListAsync();
[HttpPost("{releaseId:ulong}/software")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> AddIncludedSoftwareAsync(ulong releaseId,
[FromBody] SoftwareBySoftwareReleaseDto dto)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareRelease release = await context.SoftwareReleases.FindAsync(releaseId);
if(release is null) return NotFound();
if(!release.IsCompilation)
return Problem(detail: "Cannot add included software to a non-compilation release.", statusCode: StatusCodes.Status400BadRequest);
// Ensure this is a versionless compilation (not a versioned one)
bool hasIncludedVersions = await context.SoftwareVersionBySoftwareRelease
.AnyAsync(x => x.ReleaseId == releaseId);
if(hasIncludedVersions)
return Problem(detail: "Cannot add included software to a versioned compilation. Use included versions instead.", statusCode: StatusCodes.Status400BadRequest);
bool exists = await context.SoftwareBySoftwareRelease
.AnyAsync(x => x.ReleaseId == releaseId
&& x.SoftwareId == dto.SoftwareId);
if(exists) return Problem(detail: "This software is already included in the release.", statusCode: StatusCodes.Status400BadRequest);
bool softwareExists = await context.Softwares.AnyAsync(s => s.Id == dto.SoftwareId);
if(!softwareExists) return Problem(detail: "The specified software does not exist.", statusCode: StatusCodes.Status400BadRequest);
await context.SoftwareBySoftwareRelease.AddAsync(new SoftwareBySoftwareRelease
{
ReleaseId = releaseId,
SoftwareId = dto.SoftwareId
});
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
[HttpDelete("{releaseId:ulong}/software/{softwareId:ulong}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> RemoveIncludedSoftwareAsync(ulong releaseId, ulong softwareId)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareRelease release = await context.SoftwareReleases.FindAsync(releaseId);
if(release is null) return NotFound();
if(!release.IsCompilation)
return Problem(detail: "Cannot remove included software from a non-compilation release.", statusCode: StatusCodes.Status400BadRequest);
SoftwareBySoftwareRelease entry =
await context.SoftwareBySoftwareRelease
.FirstOrDefaultAsync(x => x.ReleaseId == releaseId
&& x.SoftwareId == softwareId);
if(entry is null) return NotFound();
context.SoftwareBySoftwareRelease.Remove(entry);
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
async Task<string> BuildSoftwareReleaseNewsNameAsync(SoftwareRelease model)
{
string baseName;
@@ -755,28 +569,17 @@ public class SoftwareReleasesController(MarechaiContext contex
baseName = "";
}
// Versionless single release: build from software name
else if(!model.IsCompilation && model.SoftwareId is not null)
else if(model.SoftwareCompilationId is null && model.SoftwareId is not null)
{
Software software = await context.Softwares.FindAsync(model.SoftwareId);
baseName = software?.Name ?? "";
}
// Compilation: build from included versions or software
// Compilation release: use the umbrella compilation's name
else
{
List<string> versionNames = await context.SoftwareVersionBySoftwareRelease
.Where(x => x.ReleaseId == model.Id)
.OrderBy(x => x.SoftwareVersion.Software.Name)
.Select(x => $"{x.SoftwareVersion.Software.Name} {x.SoftwareVersion.VersionString}")
.ToListAsync();
List<string> softwareNames = await context.SoftwareBySoftwareRelease
.Where(x => x.ReleaseId == model.Id)
.OrderBy(x => x.Software.Name)
.Select(x => x.Software.Name)
.ToListAsync();
List<string> allNames = versionNames.Concat(softwareNames).ToList();
baseName = allNames.Count > 0 ? string.Join(" + ", allNames) : "Compilation";
SoftwareCompilation compilation = await context.SoftwareCompilations
.FindAsync(model.SoftwareCompilationId);
baseName = compilation?.Name ?? "Compilation";
}
// The game's name always takes precedence; the release's own Title, if set, is a qualifier.

View File

@@ -237,11 +237,10 @@ internal static class SoftwareReleaseSuggestionApplier
var r = new SoftwareRelease
{
SoftwareId = softwareId,
PublisherId = publisherId.Value,
Title = title.Trim(),
PlatformId = platformId.Value,
IsCompilation = false
SoftwareId = softwareId,
PublisherId = publisherId.Value,
Title = title.Trim(),
PlatformId = platformId.Value
};
applied.Add(FieldSoftwareId);
applied.Add(FieldPublisherId);