Skip to content

Commit f8f4467

Browse files
committed
refactor merge
1 parent 406b534 commit f8f4467

1 file changed

Lines changed: 171 additions & 132 deletions

File tree

src/modules/metadata/metadata.service.ts

Lines changed: 171 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -297,176 +297,215 @@ export class MetadataService {
297297
}
298298
}
299299

300+
/**
301+
* Merges provider and user metadata into a single effective metadata for a game.
302+
*
303+
* The merge follows this priority order (lowest to highest):
304+
* 1. Game file defaults (release_date, installer_parameters for WINDOWS_SETUP)
305+
* 2. Provider metadata (sorted by priority, lower priority applied first)
306+
* 3. File-derived metadata (early_access flag)
307+
* 4. User metadata (highest priority, user overrides everything)
308+
*
309+
* Safeguards:
310+
* - Skips merge if no source metadata exists (no providers and no user metadata)
311+
* - For provider-only updates, skips if no provider is newer than current merged metadata
312+
* - Always merges when user_metadata exists (user explicitly requested changes)
313+
*/
300314
async merge(gameId: number): Promise<GamevaultGame> {
301315
const game = await this.gamesService.findOneByGameIdOrFail(gameId, {
302316
loadDeletedEntities: false,
303317
loadRelations: ["metadata", "provider_metadata", "user_metadata"],
304318
});
305319

306-
// SAFEGUARD: If there is nothing to merge (no provider/user metadata), do not touch effective metadata.
320+
// SAFEGUARD: Nothing to merge
307321
if (!game.provider_metadata.length && !game.user_metadata) {
308322
this.logger.warn({
309-
message: "No metadata found to merge. Skipping merge.",
310-
game: gameId,
311-
provider_metadata: game.provider_metadata,
312-
user_metadata: game.user_metadata,
323+
message: "No metadata sources available. Skipping merge.",
324+
game: logGamevaultGame(game),
313325
});
314326
return game;
315327
}
316328

317-
// SAFEGUARD: Only merge when provider/user metadata is newer-or-equal than the currently merged metadata.
318-
// Timestamp selection is `updated_at` first and falls back to `created_at`.
319-
const ts = (metadata?: GameMetadata | null): Date | null =>
320-
metadata?.updated_at ?? metadata?.created_at ?? null;
321-
322-
const effectiveTs = ts(game.metadata);
323-
if (effectiveTs) {
324-
// Check if any provider metadata is newer-or-equal (only if providers exist)
325-
const providerIsNewerOrEqual =
326-
game.provider_metadata.length > 0 &&
327-
game.provider_metadata.some((provider_metadata) => {
328-
const providerTs = ts(provider_metadata);
329-
return providerTs != null && providerTs >= effectiveTs;
330-
});
331-
332-
// Check if user metadata is newer-or-equal
333-
const userTs = ts(game.user_metadata);
334-
const userIsNewerOrEqual = userTs != null && userTs >= effectiveTs;
329+
// SAFEGUARD: Skip merge for provider-only updates if nothing changed
330+
// Note: Always merge when user_metadata exists since user explicitly requested changes
331+
if (!game.user_metadata && this.isMetadataFresh(game)) {
332+
this.logger.debug({
333+
message: "Provider metadata unchanged. Skipping merge.",
334+
game: logGamevaultGame(game),
335+
});
336+
return game;
337+
}
335338

336-
// Skip merge only if BOTH checks fail AND there is at least one source that could have been newer
337-
// When no provider metadata exists, we only consider user metadata freshness
338-
const hasProviderMetadata = game.provider_metadata.length > 0;
339-
const skipMerge = hasProviderMetadata
340-
? !providerIsNewerOrEqual && !userIsNewerOrEqual
341-
: !userIsNewerOrEqual;
339+
// Build merged metadata from all sources
340+
let mergedMetadata = this.buildBaseMetadata(game);
341+
mergedMetadata = this.applyProviderMetadata(mergedMetadata, game);
342+
mergedMetadata = this.applyFileMetadata(mergedMetadata, game);
343+
mergedMetadata = this.applyUserMetadata(mergedMetadata, game);
344+
mergedMetadata = this.finalizeMetadata(mergedMetadata, game, gameId);
342345

343-
if (skipMerge) {
344-
this.logger.debug({
345-
message:
346-
"No metadata changes (provider/user older than merged metadata). Skipping merge.",
347-
game: logGamevaultGame(game),
348-
});
349-
return game;
350-
}
351-
}
346+
// Persist and return
347+
game.metadata = await this.gameMetadataService.save(mergedMetadata);
348+
const mergedGame = await this.gamesService.save(game);
352349

353-
// Sort the provider metadata by priority in ascending order
354-
const providerMetadata = game.provider_metadata.toSorted((a, b) => {
355-
return (
356-
(a.provider_priority ??
357-
this.getProviderBySlugOrFail(a.provider_slug).priority) -
358-
(b.provider_priority ??
359-
this.getProviderBySlugOrFail(b.provider_slug).priority)
360-
);
350+
this.logger.debug({
351+
message: "Metadata merged successfully.",
352+
game: logGamevaultGame(mergedGame),
361353
});
362354

363-
const userMetadata = JSON.parse(
364-
JSON.stringify(game.user_metadata),
365-
) as GameMetadata;
355+
return mergedGame;
356+
}
366357

367-
let mergedMetadata = new GameMetadata();
358+
/**
359+
* Checks if merged metadata is still fresh (no provider has newer data).
360+
*/
361+
private isMetadataFresh(game: GamevaultGame): boolean {
362+
const effectiveTs =
363+
game.metadata?.updated_at ?? game.metadata?.created_at ?? null;
364+
if (!effectiveTs) return false;
365+
366+
return !game.provider_metadata.some((provider) => {
367+
const providerTs = provider.updated_at ?? provider.created_at ?? null;
368+
return providerTs != null && providerTs > effectiveTs;
369+
});
370+
}
371+
372+
/**
373+
* Creates base metadata with game file defaults.
374+
*/
375+
private buildBaseMetadata(game: GamevaultGame): GameMetadata {
376+
const metadata = new GameMetadata();
377+
metadata.release_date = game.release_date;
368378

369-
// Set fallback data
370-
mergedMetadata.release_date = game.release_date;
371379
if (game.type === GameType.WINDOWS_SETUP) {
372-
mergedMetadata.installer_parameters =
380+
metadata.installer_parameters =
373381
'/D="%INSTALLDIR%" /S /DIR="%INSTALLDIR%" /SILENT /COMPONENTS=text';
374382
}
375383

376-
// Create New Effective Metadata by applying the priorotized metadata one by one
377-
for (const metadata of providerMetadata) {
378-
// Delete all empty fields of provider so only delta is overwritten
379-
for (const key of Object.keys(metadata)) {
380-
if (metadata[key] == null) {
381-
delete metadata[key];
382-
}
383-
if (Array.isArray(metadata[key]) && metadata[key].length === 0) {
384-
delete metadata[key];
385-
}
386-
}
384+
return metadata;
385+
}
387386

388-
mergedMetadata = {
389-
...mergedMetadata,
390-
...metadata,
387+
/**
388+
* Applies provider metadata in priority order (lowest first).
389+
*/
390+
private applyProviderMetadata(
391+
base: GameMetadata,
392+
game: GamevaultGame,
393+
): GameMetadata {
394+
const sortedProviders = game.provider_metadata.toSorted((a, b) => {
395+
const priorityA =
396+
a.provider_priority ??
397+
this.getProviderBySlugOrFail(a.provider_slug).priority;
398+
const priorityB =
399+
b.provider_priority ??
400+
this.getProviderBySlugOrFail(b.provider_slug).priority;
401+
return priorityA - priorityB;
402+
});
403+
404+
let result = base;
405+
for (const provider of sortedProviders) {
406+
result = {
407+
...result,
408+
...this.stripEmptyFields(provider),
391409
} as GameMetadata;
392410
}
393411

394-
// Apply file metadata on top (EA)
395-
mergedMetadata.early_access = game.early_access;
412+
return result;
413+
}
396414

397-
// Apply the users changes on top
398-
if (userMetadata) {
399-
// Delete all empty fields of dto.user_metadata so only delta is overwritten
400-
for (const key of Object.keys(userMetadata)) {
401-
if (userMetadata[key] == null) {
402-
delete userMetadata[key];
403-
}
404-
if (
405-
Array.isArray(userMetadata[key]) &&
406-
userMetadata[key].length === 0
407-
) {
408-
delete userMetadata[key];
409-
}
410-
}
415+
/**
416+
* Applies file-derived metadata (early access flag).
417+
*/
418+
private applyFileMetadata(
419+
base: GameMetadata,
420+
game: GamevaultGame,
421+
): GameMetadata {
422+
return {
423+
...base,
424+
early_access: game.early_access,
425+
} as GameMetadata;
426+
}
411427

412-
mergedMetadata = {
413-
...mergedMetadata,
414-
...userMetadata,
415-
} as GameMetadata;
416-
}
428+
/**
429+
* Applies user metadata (highest priority).
430+
*/
431+
private applyUserMetadata(
432+
base: GameMetadata,
433+
game: GamevaultGame,
434+
): GameMetadata {
435+
if (!game.user_metadata) return base;
436+
437+
const userMetadata = JSON.parse(
438+
JSON.stringify(game.user_metadata),
439+
) as GameMetadata;
417440

418-
// Apply the merged metadata to the game
419-
mergedMetadata = {
420-
...mergedMetadata,
421-
...{
422-
id: game.metadata?.id || undefined,
423-
provider_slug: "gamevault",
424-
provider_data_id: gameId.toString(),
425-
provider_priority: null,
426-
},
441+
return {
442+
...base,
443+
...this.stripEmptyFields(userMetadata),
427444
} as GameMetadata;
445+
}
428446

429-
if (mergedMetadata.genres?.length) {
430-
for (const genre of mergedMetadata.genres) {
431-
genre.id = undefined;
432-
genre.provider_slug = "gamevault";
433-
genre.provider_data_id = kebabCase(genre.name);
434-
}
435-
}
447+
/**
448+
* Finalizes metadata with gamevault identifiers and normalizes relations.
449+
*/
450+
private finalizeMetadata(
451+
base: GameMetadata,
452+
game: GamevaultGame,
453+
gameId: number,
454+
): GameMetadata {
455+
const result = {
456+
...base,
457+
id: game.metadata?.id ?? undefined,
458+
provider_slug: "gamevault",
459+
provider_data_id: gameId.toString(),
460+
provider_priority: null,
461+
} as GameMetadata;
436462

437-
if (mergedMetadata.tags?.length) {
438-
for (const tag of mergedMetadata.tags) {
439-
tag.id = undefined;
440-
tag.provider_slug = "gamevault";
441-
tag.provider_data_id = kebabCase(tag.name);
442-
}
443-
}
463+
// Normalize relation entities to use gamevault as provider
464+
this.normalizeRelations(result.genres, "gamevault");
465+
this.normalizeRelations(result.tags, "gamevault");
466+
this.normalizeRelations(result.developers, "gamevault");
467+
this.normalizeRelations(result.publishers, "gamevault");
444468

445-
if (mergedMetadata.developers?.length) {
446-
for (const developer of mergedMetadata.developers) {
447-
developer.id = undefined;
448-
developer.provider_slug = "gamevault";
449-
developer.provider_data_id = kebabCase(developer.name);
450-
}
451-
}
469+
return result;
470+
}
452471

453-
if (mergedMetadata.publishers?.length) {
454-
for (const publisher of mergedMetadata.publishers) {
455-
publisher.id = undefined;
456-
publisher.provider_slug = "gamevault";
457-
publisher.provider_data_id = kebabCase(publisher.name);
472+
/**
473+
* Removes null/undefined values and empty arrays from metadata.
474+
*/
475+
private stripEmptyFields(obj: GameMetadata): Partial<GameMetadata> {
476+
const result = { ...obj } as Record<string, unknown>;
477+
for (const key of Object.keys(result)) {
478+
const value = result[key];
479+
if (value == null) {
480+
delete result[key];
481+
} else if (Array.isArray(value) && value.length === 0) {
482+
delete result[key];
458483
}
459484
}
485+
return result as Partial<GameMetadata>;
486+
}
460487

461-
// Save the merged metadata
462-
game.metadata = await this.gameMetadataService.save(mergedMetadata);
463-
const mergedGame = await this.gamesService.save(game);
464-
this.logger.debug({
465-
message: "Merged metadata.",
466-
game: logGamevaultGame(mergedGame),
467-
details: mergedGame,
468-
});
469-
return mergedGame;
488+
/**
489+
* Normalizes relation entities (genres, tags, etc.) to use consistent provider info.
490+
*/
491+
private normalizeRelations(
492+
items:
493+
| Array<{
494+
id?: number;
495+
provider_slug?: string;
496+
provider_data_id?: string;
497+
name?: string;
498+
}>
499+
| undefined,
500+
providerSlug: string,
501+
): void {
502+
if (!items?.length) return;
503+
504+
for (const item of items) {
505+
item.id = undefined;
506+
item.provider_slug = providerSlug;
507+
item.provider_data_id = kebabCase(item.name);
508+
}
470509
}
471510

472511
/**

0 commit comments

Comments
 (0)