@@ -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