This commit is contained in:
2026-07-25 01:46:59 +02:00
parent 81698cc039
commit 172a0bac61
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;
}
}
}
@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
using Musify.Domain.ValueObjects;
namespace Musify.Domain.Entities
{
public class ExternalAlbum : Album
{
[Required]
public required TrackSource Source { get; set; }
[Required]
[MaxLength(64)]
public required string ExternalId { get; set; }
[MaxLength(512)]
public string? ThumbnailUrl { get; set; }
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ namespace Musify.Domain.Entities
[Required]
public required long OwnerUserId { get; set; }
[ForeignKey(nameof(OwnerUserId))]
[ForeignKey(nameof(OwnerUserId)) ]
public User Owner { get; set; }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -1,6 +1,6 @@
namespace Musify.Infrastructure.Services
namespace Musify.Infrastructure.Helpers
{
public static class YouTubeThumbnail
public static class YouTubeThumbNailHelper
{
public static bool IsSquare(string url) =>
url.Contains("googleusercontent.com", StringComparison.OrdinalIgnoreCase);
@@ -38,7 +38,7 @@ namespace Musify.Infrastructure.Services
if (lastSlash < 0)
return path;
return $"{path[..lastSlash]}/{YtImgFileName(size)}";
return $"{path[..lastSlash] }/{YtImgFileName(size) }";
}
private static string YtImgFileName(int size) => size switch
@@ -50,6 +50,6 @@ namespace Musify.Infrastructure.Services
};
private static bool IsSizeSuffix(ReadOnlySpan<char> suffix) =>
suffix.Length > 1 && (suffix[0] is 'w' or 's' or 'h') && char.IsDigit(suffix[1]);
suffix.Length > 1 && (suffix[0] is 'w' or 's' or 'h') && char.IsDigit(suffix[1] );
}
}
@@ -43,4 +43,8 @@
<Folder Include="Persistence\Migrations\" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Assets\PlayListDefaultCover.png" LogicalName="Musify.Infrastructure.Assets.PlayListDefaultCover.png" />
</ItemGroup>
</Project>
@@ -117,6 +117,13 @@ namespace Musify.Infrastructure.Persistence
.HasForeignKey(album => album.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ExternalAlbum>()
.ToTable("ExternalAlbums");
modelBuilder.Entity<ExternalAlbum>()
.HasIndex(album => new { album.Source, album.ExternalId })
.IsUnique();
modelBuilder.Entity<AlbumHasTrack>()
.HasOne(albumTrack => albumTrack.Album)
.WithMany(album => album.AlbumTracks)
@@ -176,6 +183,8 @@ namespace Musify.Infrastructure.Persistence
public DbSet<UserAlbum> UserAlbums => Set<UserAlbum>();
public DbSet<ExternalAlbum> ExternalAlbums => Set<ExternalAlbum>();
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>();
@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddExternalAlbums : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExternalAlbums",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Source = table.Column<int>(type: "integer", nullable: false),
ExternalId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalAlbums", x => x.Id);
table.ForeignKey(
name: "FK_ExternalAlbums_Albums_Id",
column: x => x.Id,
principalTable: "Albums",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ExternalAlbums_Source_ExternalId",
table: "ExternalAlbums",
columns: new[] { "Source", "ExternalId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExternalAlbums");
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddExternalAlbumThumbnail : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ThumbnailUrl",
table: "ExternalAlbums",
type: "character varying(512)",
maxLength: 512,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ThumbnailUrl",
table: "ExternalAlbums");
}
}
}
@@ -720,6 +720,28 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("TrackProcessingState");
});
modelBuilder.Entity("Musify.Domain.Entities.ExternalAlbum", b =>
{
b.HasBaseType("Musify.Domain.Entities.Album");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasIndex("Source", "ExternalId")
.IsUnique();
b.ToTable("ExternalAlbums", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b =>
{
b.HasBaseType("Musify.Domain.Entities.Album");
@@ -1082,6 +1104,15 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.ExternalAlbum", b =>
{
b.HasOne("Musify.Domain.Entities.Album", null)
.WithOne()
.HasForeignKey("Musify.Domain.Entities.ExternalAlbum", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b =>
{
b.HasOne("Musify.Domain.Entities.Album", null)
@@ -0,0 +1,85 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
namespace Musify.Infrastructure.Services
{
public class PlayListPresetSeeder(IServiceScopeFactory scopeFactory, ILogger<PlayListPresetSeeder> logger)
: BackgroundService
{
private const string SourceResourceName = "Musify.Infrastructure.Assets.PlayListDefaultCover.png";
private const string WebpContentType = "image/webp";
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var scope = scopeFactory.CreateScope();
var services = scope.ServiceProvider;
var playListConfiguration = services.GetRequiredService<PlayListConfiguration>();
if (!playListConfiguration.SeedPresetPictures)
return;
try
{
await SeedAsync(services, playListConfiguration, stoppingToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to seed playlist preset pictures");
}
}
private async Task SeedAsync(IServiceProvider services, PlayListConfiguration configuration, CancellationToken cancellationToken)
{
var storage = services.GetRequiredService<IStorageService>();
var pictures = services.GetRequiredService<IPictureService>();
var bucket = services.GetRequiredService<ApplicationStorageConfiguration>().Bucket;
var source = LoadSourceBytes();
var routes = configuration.Routes;
var sizes = configuration.PicturesSizes;
var targets = new (string Key, int Width, int Height)[]
{
(routes.BuildOriginalPicturePath(routes.PresetOriginalPicture), sizes.LargePictureWidth, sizes.LargePictureHeight),
(routes.PresetSmallPicturePath, sizes.SmallPictureWidth, sizes.SmallPictureHeight),
(routes.PresetMediumPicturePath, sizes.MediumPictureWidth, sizes.MediumPictureHeight),
(routes.PresetLargePicturePath, sizes.LargePictureWidth, sizes.LargePictureHeight),
};
foreach (var target in targets)
{
if (!configuration.OverwritePresetPictures)
{
var existing = await storage.HeadObjectAsync(bucket, target.Key, cancellationToken);
if (existing is not null)
{
logger.LogDebug("Playlist preset picture already present at {Bucket}/{Key}", bucket, target.Key);
continue;
}
}
using var sourceStream = new MemoryStream(source, writable: false);
await using var resized = await pictures.ResizePictureAsWebpAsync(sourceStream, target.Width, target.Height, cancellationToken);
await storage.UploadFileAsync(resized, WebpContentType, bucket, target.Key, cancellationToken);
logger.LogInformation("Seeded playlist preset picture {Bucket}/{Key} ({Width}x{Height})", bucket, target.Key, target.Width, target.Height);
}
}
private static byte[] LoadSourceBytes()
{
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream(SourceResourceName)
?? throw new InvalidOperationException($"Embedded resource '{SourceResourceName}' was not found.");
using var memory = new MemoryStream();
stream.CopyTo(memory);
return memory.ToArray();
}
}
}
@@ -45,6 +45,12 @@ namespace Musify.Infrastructure.Services
return serviceDescriptors;
}
public static IServiceCollection AddPlayListPresetSeeder(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors.AddHostedService<PlayListPresetSeeder>();
return serviceDescriptors;
}
public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.Helpers;
using YouTubeMusicAPI.Client;
using YouTubeMusicAPI.Models.Search;
using YouTubeMusicAPI.Models.Streaming;
@@ -260,7 +261,7 @@ namespace Musify.Infrastructure.Services
videoId,
info.Name,
string.Join(", ", info.Artists.Select(artist => artist.Name)),
string.Empty,
info.Album?.Name ?? string.Empty,
(int)info.Duration.TotalSeconds,
PickThumbnail(info.Thumbnails),
info.IsExplicit,
@@ -278,13 +279,13 @@ namespace Musify.Infrastructure.Services
}
public string ResolveArtworkUrl(string thumbnailUrl) =>
YouTubeThumbnail.WithSize(thumbnailUrl, configuration.ArtworkSize);
YouTubeThumbNailHelper.WithSize(thumbnailUrl, configuration.ArtworkSize);
private string PickThumbnail(IEnumerable<YouTubeMusicAPI.Models.Thumbnail> thumbnails)
{
var ordered = thumbnails.OrderBy(thumbnail => thumbnail.Width).ToList();
var square = ordered.Where(thumbnail => YouTubeThumbnail.IsSquare(thumbnail.Url)).ToList();
var square = ordered.Where(thumbnail => YouTubeThumbNailHelper.IsSquare(thumbnail.Url)).ToList();
var candidates = square.Count > 0 ? square : ordered;
var chosen = candidates.FirstOrDefault(thumbnail => thumbnail.Width >= configuration.ThumbnailSize)
@@ -292,7 +293,7 @@ namespace Musify.Infrastructure.Services
return chosen is null
? string.Empty
: YouTubeThumbnail.WithSize(chosen.Url, configuration.ThumbnailSize);
: YouTubeThumbNailHelper.WithSize(chosen.Url, configuration.ThumbnailSize);
}
private static string SearchCacheKey(string token) => $"yt-search:{token}";
+3 -1
View File
@@ -47,7 +47,9 @@
"MediumPictureHeight": 256,
"LargePictureWidth": 512,
"LargePictureHeight": 512
}
},
"SeedPresetPictures": true,
"OverwritePresetPictures": false
},
"Track": {
"Routes": {
+1
View File
@@ -34,6 +34,7 @@ services.AddAudioTranscoder(configuration);
services.AddYouTubeDownloader(configuration);
services.AddHttpClient();
services.AddPictureService(configuration);
services.AddPlayListPresetSeeder(configuration);
services.AddDatabase(configuration);
services.AddMassTransitConsumers(configuration);
services.AddUploadIntentJobs(configuration);