Changes
This commit is contained in:
@@ -36,7 +36,7 @@ namespace Musify.Application.Albums
|
||||
|
||||
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
||||
|
||||
var albums = await database.UserAlbums
|
||||
var albums = await database.Albums
|
||||
.AsNoTracking()
|
||||
.Where(album => albumIds.Contains(album.Id))
|
||||
.Select(album => new
|
||||
|
||||
@@ -14,7 +14,9 @@ namespace Musify.Application.Albums.Responses
|
||||
string? LargeImageKeyName,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
IReadOnlyList<Guid> CoverTrackIds)
|
||||
IReadOnlyList<Guid> CoverTrackIds,
|
||||
string? YouTubeAlbumId = null,
|
||||
string? ThumbnailUrl = null)
|
||||
{
|
||||
public const int CoverTrackCount = 4;
|
||||
|
||||
@@ -37,5 +39,28 @@ namespace Musify.Application.Albums.Responses
|
||||
album.UpdatedAt,
|
||||
coverTrackIds ?? []);
|
||||
}
|
||||
|
||||
public static AlbumApplicationResponse FromEntity(
|
||||
Album album,
|
||||
int trackCount,
|
||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||
{
|
||||
var externalAlbum = album as ExternalAlbum;
|
||||
return new AlbumApplicationResponse(
|
||||
album.Id,
|
||||
album.Title,
|
||||
album.Description,
|
||||
album.ReleaseYear,
|
||||
album is UserAlbum userAlbum ? userAlbum.OwnerUserId : 0,
|
||||
trackCount,
|
||||
album.Pictures?.SmallName,
|
||||
album.Pictures?.MediumName,
|
||||
album.Pictures?.LargeName,
|
||||
album.CreatedAt,
|
||||
album.UpdatedAt,
|
||||
coverTrackIds ?? [],
|
||||
externalAlbum?.ExternalId,
|
||||
externalAlbum?.ThumbnailUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ namespace Musify.Application.Configuration
|
||||
|
||||
[Required]
|
||||
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes();
|
||||
|
||||
public bool SeedPresetPictures { get; set; } = true;
|
||||
|
||||
public bool OverwritePresetPictures { get; set; } = false;
|
||||
}
|
||||
|
||||
public class PlayListRoutes
|
||||
|
||||
@@ -13,6 +13,8 @@ namespace Musify.Application.Contracts
|
||||
|
||||
DbSet<UserAlbum> UserAlbums { get; }
|
||||
|
||||
DbSet<ExternalAlbum> ExternalAlbums { get; }
|
||||
|
||||
DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
||||
|
||||
DbSet<Track> Tracks { get; }
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using X.PagedList;
|
||||
using X.PagedList.EF;
|
||||
using Mediator;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
@@ -35,6 +36,7 @@ namespace Musify.Application.PlayLists
|
||||
|
||||
var pagedEntities = await playListsQuery
|
||||
.OrderBy(p => p.CreatedAt)
|
||||
.Where(p => p.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(p => new
|
||||
{
|
||||
PlayList = p,
|
||||
@@ -43,7 +45,7 @@ namespace Musify.Application.PlayLists
|
||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||
.Select(plt => plt.TrackId)
|
||||
.ToList()
|
||||
})
|
||||
} )
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
||||
|
||||
@@ -96,6 +96,108 @@ public sealed class YouTubeTrackProvisioner(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MaterializeAlbumAsync(string albumExternalId, YouTubeAlbumDetail detail, CancellationToken cancellationToken)
|
||||
{
|
||||
var validTracks = detail.Tracks
|
||||
.Where(track => !string.IsNullOrEmpty(track.VideoId))
|
||||
.ToList();
|
||||
if (validTracks.Count == 0)
|
||||
return;
|
||||
|
||||
var album = await GetOrCreateExternalAlbumAsync(albumExternalId, detail.Album.Title, detail.Album.ThumbnailUrl, cancellationToken);
|
||||
|
||||
var videoIds = validTracks.Select(track => track.VideoId).ToList();
|
||||
|
||||
var tracksByVideoId = await database.ExternalTracks
|
||||
.Where(track => track.Source == TrackSource.YouTube && videoIds.Contains(track.ExternalId))
|
||||
.ToDictionaryAsync(track => track.ExternalId, cancellationToken);
|
||||
|
||||
var linkedTrackIds = (await database.AlbumHasTracks
|
||||
.Where(albumTrack => albumTrack.AlbumId == album.Id)
|
||||
.Select(albumTrack => albumTrack.TrackId)
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToHashSet();
|
||||
|
||||
if (tracksByVideoId.Count == validTracks.Count && linkedTrackIds.Count >= validTracks.Count)
|
||||
return;
|
||||
|
||||
var artistRefs = detail.Album.Artists
|
||||
.GroupBy(artistRef => artistRef.Id ?? artistRef.Name.ToUpperInvariant())
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
|
||||
var albumArtists = new List<Artist>();
|
||||
foreach (var artistRef in artistRefs)
|
||||
albumArtists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken));
|
||||
|
||||
foreach (var albumTrack in validTracks)
|
||||
{
|
||||
if (!tracksByVideoId.TryGetValue(albumTrack.VideoId, out var track))
|
||||
{
|
||||
track = BuildStubTrack(albumTrack);
|
||||
await database.ExternalTracks.AddAsync(track, cancellationToken);
|
||||
|
||||
for (var position = 0; position < albumArtists.Count; position++)
|
||||
{
|
||||
await database.TrackArtists.AddAsync(new TrackArtist
|
||||
{
|
||||
TrackId = track.Id,
|
||||
ArtistId = albumArtists[position].Id,
|
||||
Position = position
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
tracksByVideoId[albumTrack.VideoId] = track;
|
||||
}
|
||||
|
||||
if (linkedTrackIds.Contains(track.Id))
|
||||
continue;
|
||||
|
||||
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
||||
{
|
||||
AlbumId = album.Id,
|
||||
TrackId = track.Id,
|
||||
TrackNumber = albumTrack.TrackNumber
|
||||
}, cancellationToken);
|
||||
linkedTrackIds.Add(track.Id);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Materialized YouTube album {AlbumId} ({TrackCount} tracks)", albumExternalId, validTracks.Count);
|
||||
}
|
||||
catch (DbUpdateException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Race while materializing YouTube album {AlbumId}", albumExternalId);
|
||||
}
|
||||
}
|
||||
|
||||
private ExternalTrack BuildStubTrack(YouTubeAlbumTrack albumTrack)
|
||||
{
|
||||
var title = Truncate(albumTrack.Title, 200);
|
||||
return new ExternalTrack
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
Source = TrackSource.YouTube,
|
||||
ExternalId = albumTrack.VideoId,
|
||||
DurationSeconds = albumTrack.DurationSeconds,
|
||||
Pictures = new TrackPictures
|
||||
{
|
||||
SmallName = trackConfiguration.Routes.PresetSmallPicture,
|
||||
MediumName = trackConfiguration.Routes.PresetMediumPicture,
|
||||
LargeName = trackConfiguration.Routes.PresetLargePicture,
|
||||
ProcessingStatus = ProcessingStatus.Pending
|
||||
},
|
||||
Audio = new TrackAudio
|
||||
{
|
||||
DownloadRequested = false,
|
||||
TranscodeStatus = ProcessingStatus.Pending
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<Artist> GetOrCreateArtistAsync(YouTubeArtistRef reference, CancellationToken cancellationToken)
|
||||
{
|
||||
var name = Truncate(reference.Name, 200);
|
||||
@@ -127,6 +229,37 @@ public sealed class YouTubeTrackProvisioner(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ExternalAlbum> GetOrCreateExternalAlbumAsync(string externalId, string name, string? thumbnailUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await database.ExternalAlbums
|
||||
.SingleOrDefaultAsync(album => album.Source == TrackSource.YouTube && album.ExternalId == externalId, cancellationToken);
|
||||
if (existing is not null)
|
||||
return existing;
|
||||
|
||||
var title = Truncate(name, 200);
|
||||
var album = new ExternalAlbum
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
Source = TrackSource.YouTube,
|
||||
ExternalId = externalId,
|
||||
ThumbnailUrl = string.IsNullOrEmpty(thumbnailUrl) ? null : Truncate(thumbnailUrl, 512)
|
||||
};
|
||||
|
||||
await database.ExternalAlbums.AddAsync(album, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
return album;
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
database.ExternalAlbums.Remove(album);
|
||||
return await database.ExternalAlbums.SingleAsync(a => a.Source == TrackSource.YouTube && a.ExternalId == externalId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength) =>
|
||||
value.Length <= maxLength ? value : value[..maxLength];
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
|
||||
namespace Musify.Application.YouTube
|
||||
{
|
||||
public record GetYouTubeAlbumQuery(string AlbumId)
|
||||
: IQuery<ErrorOr<YouTubeAlbumDetail>>;
|
||||
|
||||
public class GetYouTubeAlbumQueryHandler(IYouTubeMusicService youTubeMusicService)
|
||||
public class GetYouTubeAlbumQueryHandler(
|
||||
IYouTubeMusicService youTubeMusicService,
|
||||
YouTubeTrackProvisioner provisioner,
|
||||
ILogger<GetYouTubeAlbumQueryHandler> logger)
|
||||
: IQueryHandler<GetYouTubeAlbumQuery, ErrorOr<YouTubeAlbumDetail>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<YouTubeAlbumDetail>> Handle(GetYouTubeAlbumQuery request, CancellationToken cancellationToken)
|
||||
@@ -15,7 +20,20 @@ namespace Musify.Application.YouTube
|
||||
if (string.IsNullOrEmpty(request.AlbumId))
|
||||
return Error.Validation(description: "The album id must not be empty.");
|
||||
|
||||
return await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken);
|
||||
var result = await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken);
|
||||
if (result.IsError)
|
||||
return result;
|
||||
|
||||
try
|
||||
{
|
||||
await provisioner.MaterializeAlbumAsync(request.AlbumId, result.Value, cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to materialize YouTube album {AlbumId}", request.AlbumId);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user