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
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}";