Skip to content

Commit befde73

Browse files
committed
multiversion part two
1 parent 181991f commit befde73

11 files changed

Lines changed: 324 additions & 57 deletions

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
### Breaking Changes & Migration
66

7+
- [#236](https://github.com/Phalcode/gamevault-backend/issues/236) Introduced support for multiple game versions -> **Existing game rows are migrated automatically.**
8+
9+
- Removed legacy top-level game version fields (`version`, `file_path`, `size`, `release_date`, `early_access`, `type`) in favor of the new `game_version` table. -> **If you rely on these fields, update your clients to use the new version structure before migrating.**
10+
11+
- Duplicate handling now merges files with the same title into one game entity more consistently (year-tagged files merge by matching release year, untagged files merge into a no-year bucket first). -> **If you previously relied on same-title duplicates as separate game entries, rename titles explicitly (e.g. with square brackets) to keep them separate.**
12+
713
### Changes
814

915
- Updated automatic Web UI version selection: if no compatible stable release is found, the backend now falls back to the nearest newer stable release before falling back to `unstable`.
@@ -1021,7 +1027,7 @@ Recommended Gamevault App Version: `v1.5.0.0`
10211027

10221028
### Breaking Changes
10231029

1024-
- When adding the same game multiple times to your GameVault server, [follow this documentation.](https://gamevau.lt/docs/server-docs/adding-games#adding-the-same-game-multiple-times)
1030+
- When adding the same game multiple times to your GameVault server, [follow this documentation.](https://gamevau.lt/docs/server-docs/adding-games#keeping-multiple-versions-of-the-same-game)
10251031

10261032
### Changes
10271033

src/logging.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,14 @@ const stream = {
6868
};
6969

7070
function logGamevaultGame(game: GamevaultGame) {
71+
const resolvedPath =
72+
game?.file_path ||
73+
game?.versions?.find((version) => !!version.file_path)?.file_path;
74+
7175
return {
7276
id: game?.id,
7377
title: game?.title,
74-
file_path: game?.file_path,
78+
file_path: resolvedPath,
7579
};
7680
}
7781

src/modules/games/files.service.spec.ts

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ describe("FilesService", () => {
181181
});
182182

183183
describe("deleteGameFile", () => {
184-
it("should reject deletion when game has no file path", async () => {
184+
it("should reject deletion when game has no available versions", async () => {
185185
gamesService.findOneByGameIdOrFail.mockResolvedValue({
186186
id: 1,
187187
file_path: null,
@@ -217,14 +217,118 @@ describe("FilesService", () => {
217217
);
218218
});
219219

220-
it("should remove game file from disk", async () => {
220+
it("should remove all game version files from disk when no version is provided", async () => {
221221
const game = { id: 1, file_path: "/tmp/test-files/My Game.zip" } as any;
222222
gamesService.findOneByGameIdOrFail.mockResolvedValue(game);
223+
gameVersionRepository.find.mockResolvedValueOnce([
224+
{
225+
file_path: "/tmp/test-files/My Game (v1.0.0).zip",
226+
version: "v1.0.0",
227+
size: 1000n,
228+
type: "WINDOWS_SETUP",
229+
early_access: false,
230+
indexed_at: new Date("2026-01-01"),
231+
},
232+
{
233+
file_path: "/tmp/test-files/My Game (v2.0.0).zip",
234+
version: "v2.0.0",
235+
size: 1000n,
236+
type: "WINDOWS_SETUP",
237+
early_access: false,
238+
indexed_at: new Date("2026-01-02"),
239+
},
240+
] as any);
241+
fsExtra.pathExists.mockResolvedValue(true);
242+
243+
await service.deleteGameFile(1);
244+
245+
expect(fsExtra.rm).toHaveBeenCalledTimes(2);
246+
expect(fsExtra.rm).toHaveBeenCalledWith(
247+
"/tmp/test-files/My Game (v1.0.0).zip",
248+
);
249+
expect(fsExtra.rm).toHaveBeenCalledWith(
250+
"/tmp/test-files/My Game (v2.0.0).zip",
251+
);
252+
});
253+
254+
it("should delete selected normalized version when legacy file path is missing", async () => {
255+
gamesService.findOneByGameIdOrFail.mockResolvedValue({
256+
id: 1,
257+
file_path: undefined,
258+
} as any);
259+
gameVersionRepository.find.mockResolvedValueOnce([
260+
{
261+
file_path: "/tmp/test-files/My Game (v2.0.0).zip",
262+
version: "v2.0.0",
263+
size: 1000n,
264+
type: "WINDOWS_SETUP",
265+
early_access: false,
266+
indexed_at: new Date("2026-01-02"),
267+
},
268+
] as any);
223269
fsExtra.pathExists.mockResolvedValueOnce(true);
224270

225271
await service.deleteGameFile(1);
226272

227-
expect(fsExtra.rm).toHaveBeenCalledWith(game.file_path);
273+
expect(fsExtra.rm).toHaveBeenCalledWith(
274+
"/tmp/test-files/My Game (v2.0.0).zip",
275+
);
276+
});
277+
278+
it("should delete explicitly requested version", async () => {
279+
gamesService.findOneByGameIdOrFail.mockResolvedValue({
280+
id: 1,
281+
file_path: "/tmp/test-files/My Game (v1.0.0).zip",
282+
} as any);
283+
gameVersionRepository.find.mockResolvedValueOnce([
284+
{
285+
file_path: "/tmp/test-files/My Game (v1.0.0).zip",
286+
version: "v1.0.0",
287+
size: 1000n,
288+
type: "WINDOWS_SETUP",
289+
early_access: false,
290+
indexed_at: new Date("2026-01-01"),
291+
},
292+
{
293+
file_path: "/tmp/test-files/My Game (v2.0.0).zip",
294+
version: "v2.0.0",
295+
size: 1000n,
296+
type: "WINDOWS_SETUP",
297+
early_access: false,
298+
indexed_at: new Date("2026-01-02"),
299+
},
300+
] as any);
301+
fsExtra.pathExists.mockResolvedValueOnce(true);
302+
303+
await service.deleteGameFile(1, "v1.0.0");
304+
305+
expect(fsExtra.rm).toHaveBeenCalledWith(
306+
"/tmp/test-files/My Game (v1.0.0).zip",
307+
);
308+
});
309+
310+
it("should reject deletion when requested version does not exist", async () => {
311+
fsExtra.rm.mockClear();
312+
313+
gamesService.findOneByGameIdOrFail.mockResolvedValue({
314+
id: 1,
315+
file_path: "/tmp/test-files/My Game (v1.0.0).zip",
316+
} as any);
317+
gameVersionRepository.find.mockResolvedValueOnce([
318+
{
319+
file_path: "/tmp/test-files/My Game (v1.0.0).zip",
320+
version: "v1.0.0",
321+
size: 1000n,
322+
type: "WINDOWS_SETUP",
323+
early_access: false,
324+
indexed_at: new Date("2026-01-01"),
325+
},
326+
] as any);
327+
328+
await expect(service.deleteGameFile(1, "v9.9.9")).rejects.toThrow(
329+
NotFoundException,
330+
);
331+
expect(fsExtra.rm).not.toHaveBeenCalled();
228332
});
229333
});
230334

src/modules/games/files.service.ts

Lines changed: 91 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -140,20 +140,65 @@ export class FilesService implements OnApplicationBootstrap {
140140
* The file indexer will automatically soft-delete the game from the database
141141
* once it detects the file is missing.
142142
*/
143-
public async deleteGameFile(gameId: number): Promise<void> {
143+
public async deleteGameFile(
144+
gameId: number,
145+
requestedVersion?: string,
146+
): Promise<void> {
144147
const game = await this.gamesService.findOneByGameIdOrFail(gameId, {
145148
loadDeletedEntities: false,
146149
});
147150

148-
if (!game.file_path) {
151+
const availableVersions = sortGameVersions(
152+
await this.listAvailableVersionsFromStorage(game),
153+
);
154+
155+
if (availableVersions.length === 0) {
149156
throw new NotFoundException(
150-
`Game with id ${gameId} has no file path associated.`,
157+
`Game with id ${gameId} has no downloadable versions associated.`,
158+
);
159+
}
160+
161+
let versionsToDelete: GameVersion[];
162+
if (requestedVersion) {
163+
const explicitVersion = availableVersions.find(
164+
(version) => version.version === requestedVersion,
151165
);
166+
167+
if (!explicitVersion) {
168+
throw new NotFoundException(
169+
`Version "${requestedVersion}" not found. Available versions: ${availableVersions
170+
.map((v) => v.version || "(unversioned)")
171+
.join(", ")}`,
172+
);
173+
}
174+
175+
versionsToDelete = [explicitVersion];
176+
} else {
177+
versionsToDelete = availableVersions;
152178
}
153179

154-
if (!(await pathExists(game.file_path))) {
180+
if (versionsToDelete.some((version) => !version.file_path)) {
155181
throw new NotFoundException(
156-
`Game file not found on disk at "${game.file_path}".`,
182+
`Game with id ${gameId} has no valid version file path associated.`,
183+
);
184+
}
185+
186+
const existingVersionPaths: string[] = [];
187+
for (const version of versionsToDelete) {
188+
if (await pathExists(version.file_path)) {
189+
existingVersionPaths.push(version.file_path);
190+
}
191+
}
192+
193+
if (existingVersionPaths.length === 0) {
194+
if (requestedVersion) {
195+
throw new NotFoundException(
196+
`Game file not found on disk for requested version "${requestedVersion}".`,
197+
);
198+
}
199+
200+
throw new NotFoundException(
201+
`No downloadable game files were found on disk for game id ${gameId}.`,
157202
);
158203
}
159204

@@ -166,11 +211,16 @@ export class FilesService implements OnApplicationBootstrap {
166211
);
167212
}
168213

169-
await rm(game.file_path);
214+
for (const filePath of existingVersionPaths) {
215+
await rm(filePath);
216+
}
217+
170218
this.logger.log({
171219
message: "Game file deleted from disk.",
172220
gameId,
173-
path: game.file_path,
221+
version: requestedVersion || "all",
222+
count: existingVersionPaths.length,
223+
paths: existingVersionPaths,
174224
});
175225
}
176226

@@ -344,10 +394,7 @@ export class FilesService implements OnApplicationBootstrap {
344394
const availableVersions =
345395
await this.listAvailableVersionsFromStorage(gameToUpdate);
346396

347-
const selectedVersion = selectDefaultGameVersion(
348-
availableVersions,
349-
gameToUpdate.file_path,
350-
);
397+
const selectedVersion = selectDefaultGameVersion(availableVersions);
351398

352399
this.applyVersionToGame(gameToUpdate, selectedVersion);
353400
gameToUpdate.title = indexedGame.title;
@@ -860,10 +907,7 @@ export class FilesService implements OnApplicationBootstrap {
860907
},
861908
);
862909

863-
const selectedVersion = selectDefaultGameVersion(
864-
existingVersions,
865-
gameToUpdate.file_path,
866-
);
910+
const selectedVersion = selectDefaultGameVersion(existingVersions);
867911
this.applyVersionToGame(gameToUpdate, selectedVersion);
868912
await this.gamesService.save(gameToUpdate);
869913
}
@@ -1008,7 +1052,38 @@ export class FilesService implements OnApplicationBootstrap {
10081052
return selectedVersion;
10091053
}
10101054

1011-
return selectDefaultGameVersion(availableVersions, game.file_path);
1055+
const selectedVersion = selectDefaultGameVersion(availableVersions);
1056+
1057+
if (configuration.TESTING.MOCK_FILES) {
1058+
return selectedVersion;
1059+
}
1060+
1061+
if (await pathExists(selectedVersion.file_path)) {
1062+
return selectedVersion;
1063+
}
1064+
1065+
const existingFallbackVersion =
1066+
await this.findFirstExistingVersion(availableVersions);
1067+
1068+
if (existingFallbackVersion) {
1069+
return existingFallbackVersion;
1070+
}
1071+
1072+
throw new NotFoundException(
1073+
`The game has no downloadable version files available on disk.`,
1074+
);
1075+
}
1076+
1077+
private async findFirstExistingVersion(
1078+
versions: GameVersion[],
1079+
): Promise<GameVersion | undefined> {
1080+
for (const version of versions) {
1081+
if (version.file_path && (await pathExists(version.file_path))) {
1082+
return version;
1083+
}
1084+
}
1085+
1086+
return undefined;
10121087
}
10131088

10141089
/** Schedules the deletion of a temporary file after a fixed timeout. */

src/modules/games/games.controller.spec.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,14 @@ describe("GamesController", () => {
9494
describe("deleteGame", () => {
9595
it("should delete a game file from disk", async () => {
9696
filesService.deleteGameFile.mockResolvedValue(undefined);
97-
await controller.deleteGame({ game_id: 42 });
98-
expect(filesService.deleteGameFile).toHaveBeenCalledWith(42);
97+
await controller.deleteGame({ game_id: 42 }, undefined);
98+
expect(filesService.deleteGameFile).toHaveBeenCalledWith(42, undefined);
99+
});
100+
101+
it("should delete a specific game version when provided", async () => {
102+
filesService.deleteGameFile.mockResolvedValue(undefined);
103+
await controller.deleteGame({ game_id: 42 }, "v2.0.0");
104+
expect(filesService.deleteGameFile).toHaveBeenCalledWith(42, "v2.0.0");
99105
});
100106
});
101107

src/modules/games/games.controller.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,19 @@ export class GamesController {
100100
"Permanently deletes the physical game file from the filesystem. The file indexer will automatically detect the missing file and soft-delete the game from the database. Only administrators can use this endpoint. The server must have write permissions on the files volume.",
101101
operationId: "deleteGame",
102102
})
103+
@ApiQuery({
104+
name: "version",
105+
required: false,
106+
description:
107+
"Optional game version string (e.g. v1.0.0). If omitted, all versions of the game are deleted.",
108+
})
103109
@MinimumRole(Role.ADMIN)
104110
@DisableApiIf(configuration.SERVER.DEMO_MODE_ENABLED)
105-
async deleteGame(@Param() params: GameIdDto): Promise<void> {
106-
return this.filesService.deleteGameFile(Number(params.game_id));
111+
async deleteGame(
112+
@Param() params: GameIdDto,
113+
@Query("version") version?: string,
114+
): Promise<void> {
115+
return this.filesService.deleteGameFile(Number(params.game_id), version);
107116
}
108117

109118
/** Upload a game file to the server. */

src/modules/games/games.service.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
import { Repository } from "typeorm";
66
import { GameMetadataService } from "../metadata/games/game.metadata.service";
77
import { MetadataService } from "../metadata/metadata.service";
8+
import { GameVersionEntity } from "./game-version.entity";
89
import { GamesService } from "./games.service";
910
import { GamevaultGame } from "./gamevault-game.entity";
1011
import { GameExistence } from "./models/game-existence.enum";
@@ -13,6 +14,7 @@ import { GameType } from "./models/game-type.enum";
1314
describe("GamesService", () => {
1415
let service: GamesService;
1516
let gamesRepository: jest.Mocked<Repository<GamevaultGame>>;
17+
let gameVersionRepository: jest.Mocked<Repository<GameVersionEntity>>;
1618
let metadataService: jest.Mocked<MetadataService>;
1719
let gameMetadataService: jest.Mocked<GameMetadataService>;
1820

@@ -55,8 +57,13 @@ describe("GamesService", () => {
5557
save: jest.fn(),
5658
} as any;
5759

60+
gameVersionRepository = {
61+
findOne: jest.fn(),
62+
} as any;
63+
5864
service = new GamesService(
5965
gamesRepository,
66+
gameVersionRepository,
6067
metadataService,
6168
gameMetadataService,
6269
);

0 commit comments

Comments
 (0)