diff --git a/backend/Musify.Api/DataTransferObjects/PlayLists/AddYouTubeTrackRequest.cs b/backend/Musify.Api/DataTransferObjects/PlayLists/AddYouTubeTrackRequest.cs index e69f190..4959ac0 100644 --- a/backend/Musify.Api/DataTransferObjects/PlayLists/AddYouTubeTrackRequest.cs +++ b/backend/Musify.Api/DataTransferObjects/PlayLists/AddYouTubeTrackRequest.cs @@ -1,8 +1,3 @@ namespace Musify.Api.DataTransferObjects.PlayLists; -public record AddYouTubeTrackRequest( - string VideoId, - string Title, - string Artist, - int DurationSeconds, - string ThumbnailUrl); +public record AddYouTubeTrackRequest(string VideoId); diff --git a/backend/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Musify.Api/Endpoints/PlayListEndpoints.cs index a8b8882..76d2958 100644 --- a/backend/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -254,11 +254,7 @@ public static class PlayListEndpoints new AddYouTubeTrackToPlayListCommand( currentUser.RequiredId, playlistId, - request.VideoId, - request.Title, - request.Artist, - request.DurationSeconds, - request.ThumbnailUrl), + request.VideoId), cancellationToken); return result.ToCreatedResult(track => $"/tracks/{track.Id}"); diff --git a/backend/Musify.Api/Endpoints/YouTubeEndpoints.cs b/backend/Musify.Api/Endpoints/YouTubeEndpoints.cs index 45a225d..5e84c0e 100644 --- a/backend/Musify.Api/Endpoints/YouTubeEndpoints.cs +++ b/backend/Musify.Api/Endpoints/YouTubeEndpoints.cs @@ -40,7 +40,7 @@ public static class YouTubeEndpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new GetYouTubeTrackStreamQuery(videoId, currentUser.RequiredId), + new ResolveYouTubeTrackStreamCommand(videoId, currentUser.RequiredId), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Musify.Api/Validators/PlayLists/AddYouTubeTrackRequestValidator.cs b/backend/Musify.Api/Validators/PlayLists/AddYouTubeTrackRequestValidator.cs index a9623f8..53d6a17 100644 --- a/backend/Musify.Api/Validators/PlayLists/AddYouTubeTrackRequestValidator.cs +++ b/backend/Musify.Api/Validators/PlayLists/AddYouTubeTrackRequestValidator.cs @@ -10,20 +10,5 @@ public sealed class AddYouTubeTrackRequestValidator : AbstractValidator x.VideoId) .NotEmpty() .Matches("^[A-Za-z0-9_-]{11}$"); - - RuleFor(x => x.Title) - .NotEmpty() - .MaximumLength(200); - - RuleFor(x => x.Artist) - .MaximumLength(400); - - RuleFor(x => x.DurationSeconds) - .GreaterThan(0); - - RuleFor(x => x.ThumbnailUrl) - .NotEmpty() - .Must(url => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps) - .WithMessage("ThumbnailUrl must be an absolute https URL."); } } diff --git a/backend/Musify.Application/ApplicationDependencyInjection.cs b/backend/Musify.Application/ApplicationDependencyInjection.cs index c5e7669..a214939 100644 --- a/backend/Musify.Application/ApplicationDependencyInjection.cs +++ b/backend/Musify.Application/ApplicationDependencyInjection.cs @@ -13,6 +13,7 @@ public static class ApplicationDependencyInjection services.AddMediator(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddValidatedOptions(configuration, ApplicationStorageConfiguration.SectionName); services.AddValidatedOptions(configuration, PlayListConfiguration.SectionName); diff --git a/backend/Musify.Application/Contracts/IYouTubeMusicService.cs b/backend/Musify.Application/Contracts/IYouTubeMusicService.cs index 6fa0af2..97e3dc8 100644 --- a/backend/Musify.Application/Contracts/IYouTubeMusicService.cs +++ b/backend/Musify.Application/Contracts/IYouTubeMusicService.cs @@ -2,6 +2,8 @@ using ErrorOr; namespace Musify.Application.Contracts { + public record YouTubeArtistRef(string? Id, string Name); + public record YouTubeSongResult( string VideoId, string Title, @@ -9,7 +11,8 @@ namespace Musify.Application.Contracts string Album, int DurationSeconds, string ThumbnailUrl, - bool IsExplicit); + bool IsExplicit, + IReadOnlyList Artists); public record YouTubeSearchResult( IReadOnlyList Items, diff --git a/backend/Musify.Application/PlayLists/AddYouTubeTrackToPlayList.cs b/backend/Musify.Application/PlayLists/AddYouTubeTrackToPlayList.cs index 192bc7e..75b1bd0 100644 --- a/backend/Musify.Application/PlayLists/AddYouTubeTrackToPlayList.cs +++ b/backend/Musify.Application/PlayLists/AddYouTubeTrackToPlayList.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Events; +using Musify.Application.Services; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; @@ -15,16 +16,14 @@ namespace Musify.Application.PlayLists public record AddYouTubeTrackToPlayListCommand( long UserId, Guid PlayListId, - string VideoId, - string Title, - string Artist, - int DurationSeconds, - string ThumbnailUrl) + string VideoId) : ICommand>; public class AddYouTubeTrackToPlayListCommandHandler( IDatabase database, IEventBus eventBus, + IYouTubeMusicService youTubeMusicService, + YouTubeTrackProvisioner provisioner, ApplicationStorageConfiguration storageConfiguration, TrackConfiguration trackConfiguration, ILogger logger) @@ -46,11 +45,34 @@ namespace Musify.Application.PlayLists return Error.Unauthorized(); } - var trackResult = await GetOrCreateTrackAsync(request, cancellationToken); - if (trackResult.IsError) - return trackResult.Errors; + var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken); + if (provisionResult.IsError) + return provisionResult.Errors; - var track = trackResult.Value; + var track = provisionResult.Value.Track; + + var failed = track.AudioTranscodeProcessingStatus == ProcessingStatus.Failed; + var neverQueued = !track.DownloadRequested && !track.IsAudioProcessed; + if (failed || neverQueued) + { + if (failed) + { + track.AudioTranscodeProcessingStatus = ProcessingStatus.Pending; + track.PicturesProcessingStatus = ProcessingStatus.Pending; + track.LifeCycleStatus = LifeCycleStatus.Active; + track.RetryCount += 1; + track.LastRetryAt = DateTime.UtcNow; + } + track.DownloadRequested = true; + + var thumbnailUrl = await ResolveThumbnailUrlAsync(request.VideoId, provisionResult.Value.Song, cancellationToken); + if (thumbnailUrl.IsError) + return thumbnailUrl.Errors; + + await PublishDownloadEventAsync(track.Id, request.VideoId, thumbnailUrl.Value, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Queued YouTube track {VideoId} for download", request.VideoId); + } var alreadyAdded = await database.PlayListHasTracks.AsNoTracking() .AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == track.Id, cancellationToken); @@ -83,77 +105,27 @@ namespace Musify.Application.PlayLists return TrackApplicationResponse.FromEntity(track, listensCount); } - private async Task> GetOrCreateTrackAsync(AddYouTubeTrackToPlayListCommand request, CancellationToken cancellationToken) + private async Task> ResolveThumbnailUrlAsync(string videoId, YouTubeSongResult? song, CancellationToken cancellationToken) { - var track = await database.Tracks - .SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == request.VideoId, cancellationToken); + if (!string.IsNullOrEmpty(song?.ThumbnailUrl)) + return song.ThumbnailUrl; - if (track is not null) - { - var failed = track.AudioTranscodeProcessingStatus == ProcessingStatus.Failed; - var neverQueued = !track.DownloadRequested && !track.IsAudioProcessed; - if (failed || neverQueued) - { - if (failed) - { - track.AudioTranscodeProcessingStatus = ProcessingStatus.Pending; - track.PicturesProcessingStatus = ProcessingStatus.Pending; - track.LifeCycleStatus = LifeCycleStatus.Active; - track.RetryCount += 1; - track.LastRetryAt = DateTime.UtcNow; - } - track.DownloadRequested = true; - await PublishDownloadEventAsync(track.Id, request, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Queued YouTube track {VideoId} for download", request.VideoId); - } - return track; - } + var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken); + if (songResult.IsError) + return songResult.Errors; - var title = Truncate(request.Title, 50); - track = new Track - { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - Artist = Truncate(request.Artist, 200), - Source = TrackSource.YouTube, - ExternalId = request.VideoId, - DownloadRequested = true, - Duration = request.DurationSeconds, - OriginalPictureName = null, - OriginalAudioName = null, - SmallPictureName = trackConfiguration.Routes.PresetSmallPicture, - MediumPictureName = trackConfiguration.Routes.PresetMediumPicture, - LargePictureName = trackConfiguration.Routes.PresetLargePicture, - PicturesProcessingStatus = ProcessingStatus.Pending, - AudioTranscodeProcessingStatus = ProcessingStatus.Pending - }; - - await database.Tracks.AddAsync(track, cancellationToken); - await PublishDownloadEventAsync(track.Id, request, cancellationToken); - - try - { - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Created YouTube track {VideoId} ({TrackId}) and queued its download", request.VideoId, track.Id); - return track; - } - catch (DbUpdateException) - { - logger.LogInformation("YouTube track {VideoId} was created concurrently", request.VideoId); - return Error.Conflict(description: "The track was just created by another request. Retry the operation."); - } + return songResult.Value.ThumbnailUrl; } - private async Task PublishDownloadEventAsync(Guid trackId, AddYouTubeTrackToPlayListCommand request, CancellationToken cancellationToken) + private async Task PublishDownloadEventAsync(Guid trackId, string videoId, string thumbnailUrl, CancellationToken cancellationToken) { var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); await eventBus.PublishAsync( new DownloadYouTubeTrackEvent( trackId, - request.VideoId, - request.ThumbnailUrl, + videoId, + thumbnailUrl, storageConfiguration.Bucket, audioProcessedFolderKey, new ImageSize( @@ -170,8 +142,5 @@ namespace Musify.Application.PlayLists trackConfiguration.PicturesSizes.LargePictureHeight)), cancellationToken); } - - private static string Truncate(string value, int maxLength) => - value.Length <= maxLength ? value : value[..maxLength]; } } diff --git a/backend/Musify.Application/Services/YouTubeTrackProvisioner.cs b/backend/Musify.Application/Services/YouTubeTrackProvisioner.cs new file mode 100644 index 0000000..c6dd1c0 --- /dev/null +++ b/backend/Musify.Application/Services/YouTubeTrackProvisioner.cs @@ -0,0 +1,133 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Services; + +public sealed record YouTubeTrackProvision(Track Track, bool Created, YouTubeSongResult? Song); + +public sealed class YouTubeTrackProvisioner( + IDatabase database, + IYouTubeMusicService youTubeMusicService, + TrackConfiguration trackConfiguration, + ILogger logger) +{ + public Task FindAsync(string videoId, CancellationToken cancellationToken) => + database.Tracks + .Include(track => track.TrackArtists) + .ThenInclude(trackArtist => trackArtist.Artist) + .SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == videoId, cancellationToken); + + public async Task> GetOrCreateAsync(string videoId, CancellationToken cancellationToken) + { + var existing = await FindAsync(videoId, cancellationToken); + if (existing is not null) + return new YouTubeTrackProvision(existing, Created: false, Song: null); + + var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken); + if (songResult.IsError) + return songResult.Errors; + + var song = songResult.Value; + + var artistRefs = song.Artists + .GroupBy(artistRef => artistRef.Id ?? artistRef.Name.ToUpperInvariant()) + .Select(group => group.First()) + .ToList(); + + var artists = new List(); + foreach (var artistRef in artistRefs) + artists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken)); + + var title = Truncate(song.Title, 50); + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + Artist = Truncate(song.Artist, 200), + Source = TrackSource.YouTube, + ExternalId = videoId, + DownloadRequested = false, + Duration = song.DurationSeconds, + OriginalPictureName = null, + OriginalAudioName = null, + SmallPictureName = trackConfiguration.Routes.PresetSmallPicture, + MediumPictureName = trackConfiguration.Routes.PresetMediumPicture, + LargePictureName = trackConfiguration.Routes.PresetLargePicture, + PicturesProcessingStatus = ProcessingStatus.Pending, + AudioTranscodeProcessingStatus = ProcessingStatus.Pending + }; + + await database.Tracks.AddAsync(track, cancellationToken); + + for (var position = 0; position < artists.Count; position++) + { + await database.TrackArtists.AddAsync(new TrackArtist + { + TrackId = track.Id, + ArtistId = artists[position].Id, + Position = position + }, cancellationToken); + } + + try + { + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Created YouTube track {VideoId} ({TrackId})", videoId, track.Id); + return new YouTubeTrackProvision(track, Created: true, Song: song); + } + catch (DbUpdateException) + { + database.Tracks.Remove(track); + var refetched = await FindAsync(videoId, cancellationToken); + if (refetched is null) + { + logger.LogWarning("YouTube track {VideoId} creation raced but could not be refetched", videoId); + return Error.Conflict(description: "The track was just created by another request. Retry the operation."); + } + return new YouTubeTrackProvision(refetched, Created: false, Song: null); + } + } + + private async Task GetOrCreateArtistAsync(YouTubeArtistRef reference, CancellationToken cancellationToken) + { + var name = Truncate(reference.Name, 200); + var normalized = name.ToUpperInvariant(); + var byExternalId = !string.IsNullOrEmpty(reference.Id); + + var existing = byExternalId + ? await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == reference.Id, cancellationToken) + : await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == null && a.UserId == null && a.NormalizedName == normalized, cancellationToken); + if (existing is not null) + return existing; + + var artist = new Artist + { + Name = name, + NormalizedName = normalized, + ExternalId = byExternalId ? reference.Id : null + }; + + await database.Artists.AddAsync(artist, cancellationToken); + + try + { + await database.SaveChangesAsync(cancellationToken); + return artist; + } + catch (DbUpdateException) + { + database.Artists.Remove(artist); + return byExternalId + ? await database.Artists.SingleAsync(a => a.ExternalId == reference.Id, cancellationToken) + : await database.Artists.SingleAsync(a => a.ExternalId == null && a.UserId == null && a.NormalizedName == normalized, cancellationToken); + } + } + + private static string Truncate(string value, int maxLength) => + value.Length <= maxLength ? value : value[..maxLength]; +} diff --git a/backend/Musify.Application/YouTube/GetYouTubeTrackStream.cs b/backend/Musify.Application/YouTube/GetYouTubeTrackStream.cs deleted file mode 100644 index d13b431..0000000 --- a/backend/Musify.Application/YouTube/GetYouTubeTrackStream.cs +++ /dev/null @@ -1,122 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Application.Services; -using Musify.Application.YouTube.Responses; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; - -namespace Musify.Application.YouTube -{ - public record GetYouTubeTrackStreamQuery(string VideoId, long UserId) - : IQuery>; - - public class GetYouTubeTrackStreamQueryHandler( - IDatabase database, - IYouTubeMusicService youTubeMusicService, - TrackStreamIssuer streamIssuer, - TrackConfiguration trackConfiguration, - ILogger logger) - : IQueryHandler> - { - public async ValueTask> Handle(GetYouTubeTrackStreamQuery request, CancellationToken cancellationToken) - { - var track = await database.Tracks - .SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == request.VideoId, cancellationToken); - - if (track is not null && track.IsAudioProcessed) - { - var serverStream = await streamIssuer.IssueAsync(track.Id, track.AudioFolderName, request.UserId, cancellationToken); - return new YouTubeStreamResponse( - YouTubeStreamMode.Server, - serverStream.ManifestUrl, - serverStream.Ticket, - serverStream.ExpiresInSeconds, - track.Id); - } - - var streamInfo = await youTubeMusicService.GetAudioStreamAsync(request.VideoId, cancellationToken); - if (streamInfo.IsError) - return streamInfo.Errors; - - track ??= await CreateTrackFromYouTubeAsync(request.VideoId, cancellationToken); - - if (track is not null) - { - await database.ListeningHistories.AddAsync(new ListeningHistory - { - UserId = request.UserId, - TrackId = track.Id, - }, cancellationToken); - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (DbUpdateException exception) - { - logger.LogWarning(exception, "Failed to record listening history for YouTube video {VideoId}", request.VideoId); - } - } - - return new YouTubeStreamResponse( - YouTubeStreamMode.YouTube, - streamInfo.Value.Url, - string.Empty, - streamInfo.Value.ExpiresInSeconds, - track?.Id ?? Guid.Empty); - } - - private async Task CreateTrackFromYouTubeAsync(string videoId, CancellationToken cancellationToken) - { - var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken); - if (songResult.IsError) - { - logger.LogWarning("Could not load YouTube metadata for {VideoId}: {Error}", videoId, songResult.FirstError.Description); - return null; - } - - var song = songResult.Value; - var title = Truncate(song.Title, 50); - - var track = new Track - { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - Artist = Truncate(song.Artist, 200), - Source = TrackSource.YouTube, - ExternalId = videoId, - DownloadRequested = false, - Duration = song.DurationSeconds, - OriginalPictureName = null, - OriginalAudioName = null, - SmallPictureName = trackConfiguration.Routes.PresetSmallPicture, - MediumPictureName = trackConfiguration.Routes.PresetMediumPicture, - LargePictureName = trackConfiguration.Routes.PresetLargePicture, - PicturesProcessingStatus = ProcessingStatus.Pending, - AudioTranscodeProcessingStatus = ProcessingStatus.Pending - }; - - await database.Tracks.AddAsync(track, cancellationToken); - - try - { - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Created YouTube track {VideoId} ({TrackId}) from playback", videoId, track.Id); - return track; - } - catch (DbUpdateException) - { - database.Tracks.Remove(track); - return await database.Tracks - .SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == videoId, cancellationToken); - } - } - - private static string Truncate(string value, int maxLength) => - value.Length <= maxLength ? value : value[..maxLength]; - } -} diff --git a/backend/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs b/backend/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs new file mode 100644 index 0000000..f3ee205 --- /dev/null +++ b/backend/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs @@ -0,0 +1,77 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Services; +using Musify.Application.YouTube.Responses; +using Musify.Domain.Entities; + +namespace Musify.Application.YouTube +{ + public record ResolveYouTubeTrackStreamCommand(string VideoId, long UserId) + : ICommand>; + + public class ResolveYouTubeTrackStreamCommandHandler( + IDatabase database, + IYouTubeMusicService youTubeMusicService, + YouTubeTrackProvisioner provisioner, + TrackStreamIssuer streamIssuer, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(ResolveYouTubeTrackStreamCommand request, CancellationToken cancellationToken) + { + var track = await provisioner.FindAsync(request.VideoId, cancellationToken); + + if (track is not null && track.IsAudioProcessed) + { + var serverStream = await streamIssuer.IssueAsync(track.Id, track.AudioFolderName, request.UserId, cancellationToken); + return new YouTubeStreamResponse( + YouTubeStreamMode.Server, + serverStream.ManifestUrl, + serverStream.Ticket, + serverStream.ExpiresInSeconds, + track.Id); + } + + var streamInfo = await youTubeMusicService.GetAudioStreamAsync(request.VideoId, cancellationToken); + if (streamInfo.IsError) + return streamInfo.Errors; + + if (track is null) + { + var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken); + if (!provisionResult.IsError) + track = provisionResult.Value.Track; + else + logger.LogWarning("Could not provision YouTube track {VideoId}: {Error}", request.VideoId, provisionResult.FirstError.Description); + } + + if (track is not null) + { + await database.ListeningHistories.AddAsync(new ListeningHistory + { + UserId = request.UserId, + TrackId = track.Id, + }, cancellationToken); + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException exception) + { + logger.LogWarning(exception, "Failed to record listening history for YouTube video {VideoId}", request.VideoId); + } + } + + return new YouTubeStreamResponse( + YouTubeStreamMode.YouTube, + streamInfo.Value.Url, + string.Empty, + streamInfo.Value.ExpiresInSeconds, + track?.Id ?? Guid.Empty); + } + } +} diff --git a/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs b/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs index 1e1d9d7..4217088 100644 --- a/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs +++ b/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs @@ -49,7 +49,12 @@ namespace Musify.Infrastructure.Services .OrderByDescending(thumbnail => thumbnail.Width) .Select(thumbnail => thumbnail.Url) .FirstOrDefault() ?? string.Empty, - song.IsExplicit)) + song.IsExplicit, + song.Artists + .Select(artist => new YouTubeArtistRef( + string.IsNullOrEmpty(artist.Id) ? null : artist.Id, + artist.Name)) + .ToList())) .ToList(); var nextToken = string.Empty; @@ -124,7 +129,12 @@ namespace Musify.Infrastructure.Services .OrderByDescending(thumbnail => thumbnail.Width) .Select(thumbnail => thumbnail.Url) .FirstOrDefault() ?? string.Empty, - info.IsExplicit); + info.IsExplicit, + info.Artists + .Select(artist => new YouTubeArtistRef( + string.IsNullOrEmpty(artist.Id) ? null : artist.Id, + artist.Name)) + .ToList()); } catch (Exception exception) {