11using System . Collections . Concurrent ;
2+ using System . Diagnostics ;
23using System . Threading . Channels ;
34using Microsoft . EntityFrameworkCore ;
45using Microsoft . Extensions . DependencyInjection ;
@@ -114,6 +115,9 @@ public LibraryService(
114115 /// </summary>
115116 public event EventHandler < ArtistMetadataUpdatedEventArgs > ? ArtistMetadataUpdated ;
116117
118+ /// <inheritdoc />
119+ public event EventHandler < IEnumerable < ArtistMetadataUpdatedEventArgs > > ? ArtistMetadataBatchUpdated ;
120+
117121 /// <inheritdoc />
118122 public event EventHandler < PlaylistUpdatedEventArgs > ? PlaylistUpdated ;
119123
@@ -931,7 +935,8 @@ public async Task<bool> RefreshAllFoldersAsync(bool forceFullScan, IProgress<Sca
931935 {
932936 try
933937 {
934- await FetchAndUpdateArtistFromRemoteAsync ( context , artist , cancellationToken ) . ConfigureAwait ( false ) ;
938+ // For single artist details, we want immediate save and events
939+ await FetchAndUpdateArtistFromRemoteAsync ( context , artist , cancellationToken , saveChanges : true , suppressEvents : false ) . ConfigureAwait ( false ) ;
935940 }
936941 catch ( Exception ex )
937942 {
@@ -990,16 +995,40 @@ public async Task StartArtistMetadataBackgroundFetchAsync()
990995 using var scope = _serviceScopeFactory . CreateScope ( ) ;
991996 var scopedContextFactory =
992997 scope . ServiceProvider . GetRequiredService < IDbContextFactory < MusicDbContext > > ( ) ;
998+
999+ // We need a context to fetch and update entities
9931000 await using var batchContext = await scopedContextFactory . CreateDbContextAsync ( ) . ConfigureAwait ( false ) ;
9941001
9951002 var artistsInBatch = await batchContext . Artists . Where ( a => artistIdsToUpdate . Contains ( a . Id ) )
9961003 . ToListAsync ( token ) . ConfigureAwait ( false ) ;
1004+
1005+ var pendingUpdates = new List < ArtistMetadataUpdatedEventArgs > ( ) ;
1006+ var stopwatch = Stopwatch . StartNew ( ) ;
1007+
9971008 foreach ( var artist in artistsInBatch )
9981009 {
9991010 if ( token . IsCancellationRequested ) break ;
10001011 try
10011012 {
1002- await FetchAndUpdateArtistFromRemoteAsync ( batchContext , artist , token ) . ConfigureAwait ( false ) ;
1013+ // Pass saveChanges: false and suppressEvents: true to batch operations
1014+ var ( updated , newImagePath ) = await FetchAndUpdateArtistFromRemoteAsync (
1015+ batchContext , artist , token , saveChanges : false , suppressEvents : true ) . ConfigureAwait ( false ) ;
1016+
1017+ if ( updated )
1018+ {
1019+ pendingUpdates . Add ( new ArtistMetadataUpdatedEventArgs ( artist . Id , newImagePath ) ) ;
1020+ }
1021+
1022+ // Hybrid Flush Check: 10 items or 5 seconds
1023+ if ( pendingUpdates . Count >= 10 || ( pendingUpdates . Count > 0 && stopwatch . Elapsed . TotalSeconds >= 5 ) )
1024+ {
1025+ await batchContext . SaveChangesAsync ( token ) . ConfigureAwait ( false ) ;
1026+ _logger . LogInformation ( "Saved partial batch of {Count} artists metadata (Time: {Elapsed}s)." , pendingUpdates . Count , stopwatch . Elapsed . TotalSeconds . ToString ( "F1" ) ) ;
1027+
1028+ ArtistMetadataBatchUpdated ? . Invoke ( this , pendingUpdates . ToList ( ) ) ;
1029+ pendingUpdates . Clear ( ) ;
1030+ stopwatch . Restart ( ) ;
1031+ }
10031032 }
10041033 catch ( DbUpdateConcurrencyException )
10051034 {
@@ -1012,6 +1041,18 @@ public async Task StartArtistMetadataBackgroundFetchAsync()
10121041 _logger . LogError ( ex , "Failed to update artist {ArtistId} in background." , artist . Id ) ;
10131042 }
10141043 }
1044+
1045+ if ( token . IsCancellationRequested ) break ;
1046+
1047+ // Final flush for remaining items in the batch
1048+ if ( pendingUpdates . Count > 0 )
1049+ {
1050+ await batchContext . SaveChangesAsync ( token ) . ConfigureAwait ( false ) ;
1051+ _logger . LogInformation ( "Saved final batch of {Count} artists metadata." , pendingUpdates . Count ) ;
1052+
1053+ // Fire batch event
1054+ ArtistMetadataBatchUpdated ? . Invoke ( this , pendingUpdates ) ;
1055+ }
10151056 }
10161057 }
10171058 catch ( OperationCanceledException )
@@ -4195,7 +4236,15 @@ public async Task<bool> RemoveArtistImageAsync(Guid artistId)
41954236 }
41964237 }
41974238
4198- private async Task FetchAndUpdateArtistFromRemoteAsync ( MusicDbContext context , Artist artist , CancellationToken cancellationToken = default )
4239+ /// <summary>
4240+ /// Fetches metadata from remote sources and updates availability status.
4241+ /// </summary>
4242+ private async Task < ( bool Updated , string ? NewImagePath ) > FetchAndUpdateArtistFromRemoteAsync (
4243+ MusicDbContext context ,
4244+ Artist artist ,
4245+ CancellationToken cancellationToken = default ,
4246+ bool saveChanges = true ,
4247+ bool suppressEvents = false )
41994248 {
42004249 var wasMetadataFoundAndUpdated = false ;
42014250
@@ -4205,7 +4254,7 @@ private async Task FetchAndUpdateArtistFromRemoteAsync(MusicDbContext context, A
42054254 if ( enabledProviders . Count == 0 )
42064255 {
42074256 _logger . LogDebug ( "No metadata providers enabled. Skipping remote fetch for artist '{ArtistName}'." , artist . Name ) ;
4208- return ;
4257+ return ( false , artist . LocalImageCachePath ) ;
42094258 }
42104259
42114260 _logger . LogDebug ( "Using metadata providers for '{ArtistName}': {Providers}" ,
@@ -4355,11 +4404,17 @@ private async Task FetchAndUpdateArtistFromRemoteAsync(MusicDbContext context, A
43554404 }
43564405
43574406 artist . MetadataLastCheckedUtc = DateTime . UtcNow ;
4358- await context . SaveChangesAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
4407+ if ( saveChanges )
4408+ {
4409+ await context . SaveChangesAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
4410+ }
4411+
4412+ if ( ! suppressEvents && wasMetadataFoundAndUpdated )
4413+ {
4414+ ArtistMetadataUpdated ? . Invoke ( this , new ArtistMetadataUpdatedEventArgs ( artist . Id , artist . LocalImageCachePath ) ) ;
4415+ }
43594416
4360- if ( wasMetadataFoundAndUpdated )
4361- ArtistMetadataUpdated ? . Invoke ( this ,
4362- new ArtistMetadataUpdatedEventArgs ( artist . Id , artist . LocalImageCachePath ) ) ;
4417+ return ( wasMetadataFoundAndUpdated , artist . LocalImageCachePath ) ;
43634418 }
43644419
43654420 /// <summary>
0 commit comments