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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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 |
+4
-4
@@ -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>();
|
||||
|
||||
Generated
+1205
File diff suppressed because it is too large
Load Diff
+47
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1209
File diff suppressed because it is too large
Load Diff
+29
@@ -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}";
|
||||
|
||||
@@ -47,7 +47,9 @@
|
||||
"MediumPictureHeight": 256,
|
||||
"LargePictureWidth": 512,
|
||||
"LargePictureHeight": 512
|
||||
}
|
||||
},
|
||||
"SeedPresetPictures": true,
|
||||
"OverwritePresetPictures": false
|
||||
},
|
||||
"Track": {
|
||||
"Routes": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user