This commit is contained in:
2026-07-25 01:46:59 +02:00
parent a58464d523
commit f54ebec0eb
66 changed files with 3245 additions and 416 deletions
@@ -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;
}
}
}