diff --git a/backend/Core/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs index 8306484..fbc66a5 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs @@ -6,6 +6,11 @@ namespace Musify.Infrastructure.Configuration { public const string SectionName = "YouTube"; + /// Turns the whole YouTube Music source on or off: search, albums, streaming + /// and adding new YouTube tracks all fail with a clear error while this is false, + /// and mix generation silently skips YouTube candidates instead of failing. + public bool Enabled { get; set; } = true; + [Required] public string GeographicalLocation { get; set; } = "US"; diff --git a/backend/Core/Musify.Infrastructure/Services/DisabledYouTubeMusicService.cs b/backend/Core/Musify.Infrastructure/Services/DisabledYouTubeMusicService.cs new file mode 100644 index 0000000..783bc0d --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Services/DisabledYouTubeMusicService.cs @@ -0,0 +1,37 @@ +using ErrorOr; +using Musify.Application.Contracts; + +namespace Musify.Infrastructure.Services +{ + /// Stands in for when + /// is false, + /// so the source can be turned off without a client ever being constructed. Every read fails + /// with the same error; callers already treat a failed + /// YouTube call as "unavailable" (an HTTP error for direct requests, a skipped candidate for + /// mix generation), so nothing downstream needs to know the source is disabled specifically. + public sealed class DisabledYouTubeMusicService : IYouTubeMusicService + { + private static readonly Error DisabledError = + Error.Forbidden(description: "The YouTube Music source is disabled."); + + public Task> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken) => + Task.FromResult>(DisabledError); + + public Task> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken) => + Task.FromResult>(DisabledError); + + public Task> GetAlbumAsync(string albumId, CancellationToken cancellationToken) => + Task.FromResult>(DisabledError); + + public Task> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken) => + Task.FromResult>(DisabledError); + + public Task> GetSongAsync(string videoId, CancellationToken cancellationToken) => + Task.FromResult>(DisabledError); + + // Never reached in practice: every caller only reaches this after a successful search or + // song lookup above, neither of which can succeed while disabled. Passing the URL through + // unchanged is the safest thing to do if it's ever called anyway. + public string ResolveArtworkUrl(string thumbnailUrl) => thumbnailUrl; + } +} diff --git a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index 053b0cc..641b45d 100644 --- a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -1,8 +1,10 @@ using Amazon.S3; using Hangfire; using Hangfire.PostgreSql; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Musify.Application.Configuration; using Musify.Infrastructure.Configuration; @@ -102,7 +104,17 @@ namespace Musify.Infrastructure.Services serviceDescriptors.AddValidatedOptions(configuration, YouTubeConfiguration.SectionName); serviceDescriptors.AddMemoryCache(); - serviceDescriptors.AddSingleton(); + serviceDescriptors.AddSingleton(serviceProvider => + { + var youTubeConfiguration = serviceProvider.GetRequiredService(); + if (!youTubeConfiguration.Enabled) + return new DisabledYouTubeMusicService(); + + return new YouTubeMusicService( + youTubeConfiguration, + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService>()); + } ); return serviceDescriptors; } diff --git a/backend/Hosts/Musify.Api/AppSettings.json b/backend/Hosts/Musify.Api/AppSettings.json index ea63454..217bf14 100644 --- a/backend/Hosts/Musify.Api/AppSettings.json +++ b/backend/Hosts/Musify.Api/AppSettings.json @@ -49,6 +49,7 @@ "OtlpEndpoint": "" }, "YouTube": { + "Enabled": true, "GeographicalLocation": "US", "StreamUrlCacheSeconds": 1800, "SearchContinuationCacheSeconds": 600, diff --git a/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs b/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs index 3505fdc..04ea670 100644 --- a/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs +++ b/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs @@ -8,8 +8,7 @@ public sealed class CurrentUser : IBindableFromHttpContext private CurrentUser(ClaimsPrincipal principal) { Principal = principal; - var isAuthenticated = principal.Identity?.IsAuthenticated; - IsAuthenticated = isAuthenticated.HasValue && isAuthenticated.Value; + IsAuthenticated = principal.Identity?.IsAuthenticated ?? false; var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); if (long.TryParse(rawId, out var id) && id != 0) diff --git a/backend/Hosts/Musify.Worker/AppSettings.json b/backend/Hosts/Musify.Worker/AppSettings.json index 10d9bd3..ec01d0a 100644 --- a/backend/Hosts/Musify.Worker/AppSettings.json +++ b/backend/Hosts/Musify.Worker/AppSettings.json @@ -26,6 +26,9 @@ "ApplicationStorage": { "Bucket": "" }, + "YouTube": { + "Enabled": true + }, "Mix": { "ArtistMixCount": 3, "ItemsPerMix": 12, diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs index 32b5293..fe5c911 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -1,8 +1,10 @@ +using ErrorOr; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; @@ -72,4 +74,33 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase Assert.DoesNotContain(mixes, mix => mix.Title == "Stale Mix"); Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken)); } + + [Fact] + public async Task Handle_YouTubeSourceUnavailable_StillCreatesMixesFromLocalCandidatesOnly() + { + // Simulates IYouTubeMusicService.SearchSongsAsync as it behaves when the YouTube source is + // disabled (DisabledYouTubeMusicService, see Infrastructure): every call fails. Mix + // generation must degrade to local-only candidates instead of failing the whole command. + youTubeMusicService + .SearchSongsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Error.Forbidden(description: "The YouTube Music source is disabled.")); + + var user = TestEntities.User(); + var artist = TestEntities.Artist("Seed Artist"); + var listenedTrack = TestEntities.ExternalTrack(externalId: "listened", title: "Listened Track"); + var otherTrackByArtist = TestEntities.ExternalTrack(externalId: "not-listened", title: "Other Track"); + + await SeedAsync( + user, artist, listenedTrack, otherTrackByArtist, + new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 }, + new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 }, + TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); + + var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var items = await Database.MixItems.ToListAsync(TestContext.Current.CancellationToken); + Assert.NotEmpty(items); + Assert.All(items, item => Assert.Equal(MixItemSource.Musify, item.Source)); + } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs new file mode 100644 index 0000000..6ef3286 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs @@ -0,0 +1,58 @@ +using ErrorOr; +using Musify.Infrastructure.Services; +using Xunit; + +namespace Musify.Infrastructure.Tests.Services; + +public sealed class DisabledYouTubeMusicServiceTests +{ + private readonly DisabledYouTubeMusicService service = new(); + + [Fact] + public async Task SearchSongsAsync_ReturnsForbidden() + { + var result = await service.SearchSongsAsync("query", string.Empty, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task SearchAlbumsAsync_ReturnsForbidden() + { + var result = await service.SearchAlbumsAsync("query", string.Empty, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task GetAlbumAsync_ReturnsForbidden() + { + var result = await service.GetAlbumAsync("album-1", TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task GetAudioStreamAsync_ReturnsForbidden() + { + var result = await service.GetAudioStreamAsync("video-1", TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task GetSongAsync_ReturnsForbidden() + { + var result = await service.GetSongAsync("video-1", TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public void ResolveArtworkUrl_ReturnsTheUrlUnchanged() + { + var result = service.ResolveArtworkUrl("https://img.example/thumb.jpg"); + + Assert.Equal("https://img.example/thumb.jpg", result); + } +}