From dd3150e3d4c15f2ca4608b290ec3180162101633 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 20 Jul 2026 17:29:28 +0200 Subject: [PATCH] perf(backend): cache YouTube searches and shrink the thumbnail URLs Repeated searches and song lookups now reuse a single-flight memory cache, so concurrent or debounced queries hit YouTube once instead of on every keystroke. Thumbnails are resolved at a configurable size instead of the largest variant, which cuts the image payload the browser pulls from Google. --- backend/Musify.Api/AppSettings.json | 5 +- .../Configuration/YouTubeConfiguration.cs | 9 ++++ .../Services/SingleFlightCache.cs | 41 +++++++++++++++ .../Services/YouTubeMusicService.cs | 46 +++++++++++++--- .../Services/YouTubeThumbnail.cs | 52 +++++++++++++++++++ 5 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 backend/Musify.Infrastructure/Services/SingleFlightCache.cs create mode 100644 backend/Musify.Infrastructure/Services/YouTubeThumbnail.cs diff --git a/backend/Musify.Api/AppSettings.json b/backend/Musify.Api/AppSettings.json index 5b7fe4b..93cef14 100644 --- a/backend/Musify.Api/AppSettings.json +++ b/backend/Musify.Api/AppSettings.json @@ -51,7 +51,10 @@ "YouTube": { "GeographicalLocation": "US", "StreamUrlCacheSeconds": 1800, - "SearchContinuationCacheSeconds": 600 + "SearchContinuationCacheSeconds": 600, + "SearchResultsCacheSeconds": 900, + "SongInfoCacheSeconds": 86400, + "ThumbnailSize": 240 }, "PlayList": { "Routes": { diff --git a/backend/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs b/backend/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs index bcff643..f2e81e3 100644 --- a/backend/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs +++ b/backend/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs @@ -14,5 +14,14 @@ namespace Musify.Infrastructure.Configuration [Range(60, 3600)] public int SearchContinuationCacheSeconds { get; set; } = 600; + + [Range(0, 86400)] + public int SearchResultsCacheSeconds { get; set; } = 900; + + [Range(0, 604800)] + public int SongInfoCacheSeconds { get; set; } = 86400; + + [Range(32, 720)] + public int ThumbnailSize { get; set; } = 240; } } diff --git a/backend/Musify.Infrastructure/Services/SingleFlightCache.cs b/backend/Musify.Infrastructure/Services/SingleFlightCache.cs new file mode 100644 index 0000000..bd34a1c --- /dev/null +++ b/backend/Musify.Infrastructure/Services/SingleFlightCache.cs @@ -0,0 +1,41 @@ +using System.Collections.Concurrent; +using ErrorOr; +using Microsoft.Extensions.Caching.Memory; + +namespace Musify.Infrastructure.Services +{ + public sealed class SingleFlightCache(IMemoryCache cache) + { + private readonly ConcurrentDictionary inFlight = new(); + + public async Task> GetOrCreateAsync( + string key, + TimeSpan duration, + Func>> factory) + { + if (duration <= TimeSpan.Zero) + return await factory(); + + if (cache.TryGetValue(key, out TValue? cached) && cached is not null) + return cached; + + var pending = (Lazy>>)inFlight.GetOrAdd( + key, + _ => new Lazy>>(factory)); + + try + { + var result = await pending.Value; + + if (!result.IsError) + cache.Set(key, result.Value, duration); + + return result; + } + finally + { + inFlight.TryRemove(key, out _); + } + } + } +} diff --git a/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs b/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs index caf4e08..d4b1628 100644 --- a/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs +++ b/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs @@ -13,10 +13,22 @@ namespace Musify.Infrastructure.Services public class YouTubeMusicService(YouTubeConfiguration configuration, IMemoryCache cache, ILogger logger) : IYouTubeMusicService { private readonly YouTubeMusicClient client = new(geographicalLocation: configuration.GeographicalLocation); + private readonly SingleFlightCache singleFlight = new(cache); private sealed record CachedStreamInfo(string Url, DateTime ExpiresAtUtc); public async Task> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(continuationToken)) + return await FetchSearchPageAsync(query, continuationToken, cancellationToken); + + return await singleFlight.GetOrCreateAsync( + SearchResultsCacheKey(query), + TimeSpan.FromSeconds(configuration.SearchResultsCacheSeconds), + () => FetchSearchPageAsync(query, continuationToken, cancellationToken)); + } + + private async Task> FetchSearchPageAsync(string query, string continuationToken, CancellationToken cancellationToken) { PaginatedAsyncEnumerable? paginator; if (string.IsNullOrEmpty(continuationToken)) @@ -47,10 +59,7 @@ namespace Musify.Infrastructure.Services string.Join(", ", song.Artists.Select(artist => artist.Name)), song.Album?.Name ?? string.Empty, (int)song.Duration.TotalSeconds, - song.Thumbnails - .OrderByDescending(thumbnail => thumbnail.Width) - .Select(thumbnail => thumbnail.Url) - .FirstOrDefault() ?? string.Empty, + PickThumbnail(song.Thumbnails), song.IsExplicit, song.Artists .Select(artist => new YouTubeArtistRef( @@ -117,6 +126,14 @@ namespace Musify.Infrastructure.Services } public async Task> GetSongAsync(string videoId, CancellationToken cancellationToken) + { + return await singleFlight.GetOrCreateAsync( + SongInfoCacheKey(videoId), + TimeSpan.FromSeconds(configuration.SongInfoCacheSeconds), + () => FetchSongAsync(videoId, cancellationToken)); + } + + private async Task> FetchSongAsync(string videoId, CancellationToken cancellationToken) { try { @@ -128,10 +145,7 @@ namespace Musify.Infrastructure.Services string.Join(", ", info.Artists.Select(artist => artist.Name)), string.Empty, (int)info.Duration.TotalSeconds, - info.Thumbnails - .OrderByDescending(thumbnail => thumbnail.Width) - .Select(thumbnail => thumbnail.Url) - .FirstOrDefault() ?? string.Empty, + PickThumbnail(info.Thumbnails), info.IsExplicit, info.Artists .Select(artist => new YouTubeArtistRef( @@ -146,8 +160,24 @@ namespace Musify.Infrastructure.Services } } + private string PickThumbnail(IEnumerable thumbnails) + { + var ordered = thumbnails.OrderBy(thumbnail => thumbnail.Width).ToList(); + + var chosen = ordered.FirstOrDefault(thumbnail => thumbnail.Width >= configuration.ThumbnailSize) + ?? ordered.LastOrDefault(); + + return chosen is null + ? string.Empty + : YouTubeThumbnail.WithSize(chosen.Url, configuration.ThumbnailSize); + } + private static string SearchCacheKey(string token) => $"yt-search:{token}"; + private static string SearchResultsCacheKey(string query) => $"yt-search-results:{query.Trim().ToLowerInvariant()}"; + + private static string SongInfoCacheKey(string videoId) => $"yt-song:{videoId}"; + private static string StreamCacheKey(string videoId) => $"yt-stream:{videoId}"; } } diff --git a/backend/Musify.Infrastructure/Services/YouTubeThumbnail.cs b/backend/Musify.Infrastructure/Services/YouTubeThumbnail.cs new file mode 100644 index 0000000..2eb94f7 --- /dev/null +++ b/backend/Musify.Infrastructure/Services/YouTubeThumbnail.cs @@ -0,0 +1,52 @@ +namespace Musify.Infrastructure.Services +{ + public static class YouTubeThumbnail + { + 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 + { + <= 120 => "default.jpg", + <= 320 => "mqdefault.jpg", + <= 480 => "hqdefault.jpg", + _ => "maxresdefault.jpg" + }; + + private static bool IsSizeSuffix(ReadOnlySpan suffix) => + suffix.Length > 1 && (suffix[0] is 'w' or 's' or 'h') && char.IsDigit(suffix[1]); + } +}