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
@@ -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}";
@@ -1,55 +0,0 @@
namespace Musify.Infrastructure.Services
{
public static class YouTubeThumbnail
{
public static bool IsSquare(string url) =>
url.Contains("googleusercontent.com", StringComparison.OrdinalIgnoreCase);
public static string WithSize(string url, int size)
{
if (string.IsNullOrEmpty(url))
return string.Empty;
if (url.Contains("googleusercontent.com", StringComparison.OrdinalIgnoreCase))
return ResizeGoogleUserContent(url, size);
if (url.Contains("ytimg.com", StringComparison.OrdinalIgnoreCase))
return ResizeYtImg(url, size);
return url;
}
private static string ResizeGoogleUserContent(string url, int size)
{
var separator = url.LastIndexOf('=');
var baseUrl = separator > 0 && IsSizeSuffix(url.AsSpan(separator + 1))
? url[..separator]
: url;
return $"{baseUrl}=w{size}-h{size}-l90-rj";
}
private static string ResizeYtImg(string url, int size)
{
var queryStart = url.IndexOf('?');
var path = queryStart >= 0 ? url[..queryStart] : url;
var lastSlash = path.LastIndexOf('/');
if (lastSlash < 0)
return path;
return $"{path[..lastSlash]}/{YtImgFileName(size)}";
}
private static string YtImgFileName(int size) => size switch
{
<= 90 => "default.jpg",
<= 180 => "mqdefault.jpg",
<= 360 => "hqdefault.jpg",
_ => "maxresdefault.jpg"
};
private static bool IsSizeSuffix(ReadOnlySpan<char> suffix) =>
suffix.Length > 1 && (suffix[0] is 'w' or 's' or 'h') && char.IsDigit(suffix[1]);
}
}