From 31032bd8e876e1a970f9b3baa1c05c0421db44f5 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 15 Sep 2026 21:17:03 +0200 Subject: [PATCH] Remove YouTube integration from backend and deployment config Drops the YouTube-backed second catalog source entirely: the Local/External split on Track/Album collapses into single concrete entities, Artist/TrackArtist/ExternalTrack/ExternalAlbum and the YouTube service/download pipeline (yt-dlp, MassTransit consumer, routing slip) are deleted, and mixes now source discovery only from local listening history. Includes the RemoveYouTubeIntegration EF Core migration and matching Worker/deploy/compose cleanup (no more yt-dlp/python/deno in the Worker image, no thumbnail-cache/YtDlp config sections). --- README.md | 8 +- .../Albums/AddTrackToAlbum.cs | 13 +- .../Musify.Application/Albums/CreateAlbum.cs | 4 +- .../Musify.Application/Albums/DeleteAlbum.cs | 4 +- .../Musify.Application/Albums/GetAlbumById.cs | 2 +- .../Albums/GetAlbumTracks.cs | 5 +- .../Musify.Application/Albums/GetAlbums.cs | 65 +- .../Albums/GetAlbumsByUserId.cs | 2 +- .../Albums/RemoveTrackFromAlbum.cs | 2 +- .../Responses/AlbumApplicationResponse.cs | 24 +- .../Responses/AlbumSearchItemResponse.cs | 19 +- .../Albums/Responses/AlbumsSearchResponse.cs | 30 +- .../ExternalAlbumApplicationResponse.cs | 47 - .../Musify.Application/Albums/UpdateAlbum.cs | 4 +- .../ApplicationDependencyInjection.cs | 1 - .../Configuration/MixConfiguration.cs | 9 - .../Musify.Application/Contracts/IDatabase.cs | 12 - .../Contracts/IYouTubeDownloaderService.cs | 9 - .../Contracts/IYouTubeMusicService.cs | 64 - .../Events/DownloadYouTubeTrackEvent.cs | 14 - .../Likes/GetLikedTracks.cs | 5 +- .../Likes/ToggleTrackLike.cs | 51 +- .../Mixes/GenerateMixesForUser.cs | 276 +--- .../Musify.Application/Mixes/GetMixById.cs | 5 +- .../Mixes/GetMixesByUserId.cs | 26 +- .../Musify.Application/Mixes/MixItemMapper.cs | 36 + .../Mixes/Responses/MixApplicationResponse.cs | 13 - .../Responses/MixItemApplicationResponse.cs | 23 +- .../PlayLists/AddYouTubeTrackToPlayList.cs | 149 --- .../PlayLists/GetPlayListTracks.cs | 5 +- .../Services/YouTubeTrackProvisioner.cs | 265 ---- .../Musify.Application/Tracks/CreateTrack.cs | 4 +- .../Musify.Application/Tracks/DeleteTrack.cs | 2 +- .../Tracks/GetListeningHistory.cs | 5 +- .../Musify.Application/Tracks/GetTrackById.cs | 5 +- .../Musify.Application/Tracks/GetTracks.cs | 70 +- .../Tracks/GetTracksByUserId.cs | 5 +- .../ExternalTrackApplicationResponse.cs | 30 - .../LocalTrackApplicationResponse.cs | 32 - .../Responses/TrackApplicationResponse.cs | 48 +- .../Responses/TrackSearchItemResponse.cs | 19 +- .../Tracks/Responses/TracksSearchResponse.cs | 30 +- .../YouTube/GetYouTubeAlbum.cs | 36 - .../YouTube/ResolveYouTubeTrackStream.cs | 85 -- .../Responses/YouTubeStreamResponse.cs | 15 - backend/Core/Musify.Domain/Entities/Album.cs | 9 +- backend/Core/Musify.Domain/Entities/Artist.cs | 32 - .../Musify.Domain/Entities/ExternalAlbum.cs | 18 - .../Musify.Domain/Entities/ExternalTrack.cs | 17 - .../Core/Musify.Domain/Entities/LocalTrack.cs | 14 - .../Core/Musify.Domain/Entities/MixItem.cs | 26 +- backend/Core/Musify.Domain/Entities/Track.cs | 9 +- .../Musify.Domain/Entities/TrackArtist.cs | 25 - .../Core/Musify.Domain/Entities/UserAlbum.cs | 15 - .../ValueObjects/MixItemSource.cs | 8 - .../Musify.Domain/ValueObjects/TrackSource.cs | 8 - .../Configuration/YouTubeConfiguration.cs | 35 - .../Configuration/YtDlpConfiguration.cs | 20 - .../Helpers/YouTubeThumbNailHelper.cs | 55 - .../Infrastructure.csproj | 1 - .../Audio/DownloadYouTubeAudioActivity.cs | 94 -- .../DownloadYouTubeAudioArguments.cs | 7 - .../Consumers/DownloadYouTubeTrackConsumer.cs | 26 - ...TransitDependencyInjection.Registration.cs | 3 - .../Logs/DownloadYouTubeAudioLog.cs | 6 - .../MassTransit/RoutingSlip/ActivityNames.cs | 1 - .../YouTubeTrackRoutingSlipBuilder.cs | 166 --- .../Sagas/TrackProcessingStateMachine.cs | 26 +- .../Persistence/Database.cs | 56 +- ...65436_RemoveYouTubeIntegration.Designer.cs | 1133 +++++++++++++++++ ...20260915165436_RemoveYouTubeIntegration.cs | 381 ++++++ .../Migrations/DatabaseModelSnapshot.cs | 258 +--- .../Services/DisabledYouTubeMusicService.cs | 37 - .../Services/ServicesDependencyInjection.cs | 28 - .../Services/YouTubeDownloaderService.cs | 89 -- .../Services/YouTubeMusicService.cs | 310 ----- backend/Directory.Packages.props | 1 - .../Musify.Api/AppSettings.Development.json | 3 - backend/Hosts/Musify.Api/AppSettings.json | 15 +- .../Likes/ToggleLikeRequest.cs | 7 +- .../PlayLists/AddPlayListTrackRequest.cs | 7 +- .../Musify.Api/Endpoints/AlbumEndpoints.cs | 28 +- .../Musify.Api/Endpoints/LikeEndpoints.cs | 6 +- .../Musify.Api/Endpoints/PlayListEndpoints.cs | 28 +- .../Musify.Api/Endpoints/TrackEndpoints.cs | 32 +- .../Extensions/ServiceCollectionExtensions.cs | 1 - .../Likes/ToggleLikeRequestValidator.cs | 27 - .../AddPlayListTrackRequestValidator.cs | 27 - .../AppSettings.Development.json | 3 - backend/Hosts/Musify.Worker/AppSettings.json | 14 +- backend/Hosts/Musify.Worker/Program.cs | 4 - backend/Musify.Worker.Dockerfile | 15 +- .../TestSupport/ApiTestFixture.cs | 3 - .../AddTrackToAlbumCommandHandlerTests.cs | 26 +- .../Albums/CreateAlbumCommandHandlerTests.cs | 2 +- .../Albums/DeleteAlbumCommandHandlerTests.cs | 8 +- .../Albums/GetAlbumByIdQueryHandlerTests.cs | 4 +- .../Albums/GetAlbumCoverQueryHandlerTests.cs | 2 +- .../Albums/GetAlbumTracksQueryHandlerTests.cs | 6 +- .../GetAlbumsByUserIdQueryHandlerTests.cs | 2 +- .../Albums/GetAlbumsQueryHandlerTests.cs | 69 +- ...RecentlyListenedAlbumsQueryHandlerTests.cs | 12 +- ...RemoveTrackFromAlbumCommandHandlerTests.cs | 12 +- .../Albums/UpdateAlbumCommandHandlerTests.cs | 10 +- .../Likes/GetLikedTracksQueryHandlerTests.cs | 6 +- .../ToggleTrackLikeCommandHandlerTests.cs | 65 +- ...GenerateMixesForUserCommandHandlerTests.cs | 78 +- .../Mixes/GetMixByIdQueryHandlerTests.cs | 8 +- .../AddTrackToPlayListCommandHandlerTests.cs | 8 +- ...uTubeTrackToPlayListCommandHandlerTests.cs | 105 -- .../GetPlayListByIdQueryHandlerTests.cs | 2 +- .../GetPlayListTracksQueryHandlerTests.cs | 4 +- ...oveTrackFromPlayListCommandHandlerTests.cs | 2 +- .../Services/TrackStreamIssuerTests.cs | 10 +- .../Services/YouTubeTrackProvisionerTests.cs | 104 -- .../Shared/TrackApplicationResponseTests.cs | 35 +- .../TestSupport/TestDatabase.cs | 52 +- .../TestSupport/TestEntities.cs | 66 +- .../Tracks/CreateTrackCommandHandlerTests.cs | 4 +- .../Tracks/DeleteTrackCommandHandlerTests.cs | 17 +- .../GetListeningHistoryQueryHandlerTests.cs | 4 +- .../Tracks/GetTrackByIdQueryHandlerTests.cs | 4 +- .../Tracks/GetTrackCoverQueryHandlerTests.cs | 4 +- .../Tracks/GetTrackStreamQueryHandlerTests.cs | 8 +- .../GetTracksByUserIdQueryHandlerTests.cs | 6 +- .../Tracks/GetTracksQueryHandlerTests.cs | 80 +- .../GetListeningStatsQueryHandlerTests.cs | 10 +- .../GetYouTubeAlbumQueryHandlerTests.cs | 62 - ...veYouTubeTrackStreamCommandHandlerTests.cs | 125 -- .../Persistence/DatabaseTests.cs | 43 +- .../DisabledYouTubeMusicServiceTests.cs | 59 - deploy/.env.prod.example | 5 - deploy/README.md | 18 +- deploy/compose.prod.yml | 6 - deploy/compose.yml | 1 - deploy/nginx/templates/default.conf.template | 21 - docs/development.md | 15 +- 137 files changed, 1933 insertions(+), 3969 deletions(-) delete mode 100644 backend/Core/Musify.Application/Albums/Responses/ExternalAlbumApplicationResponse.cs delete mode 100644 backend/Core/Musify.Application/Contracts/IYouTubeDownloaderService.cs delete mode 100644 backend/Core/Musify.Application/Contracts/IYouTubeMusicService.cs delete mode 100644 backend/Core/Musify.Application/Events/DownloadYouTubeTrackEvent.cs create mode 100644 backend/Core/Musify.Application/Mixes/MixItemMapper.cs delete mode 100644 backend/Core/Musify.Application/PlayLists/AddYouTubeTrackToPlayList.cs delete mode 100644 backend/Core/Musify.Application/Services/YouTubeTrackProvisioner.cs delete mode 100644 backend/Core/Musify.Application/Tracks/Responses/ExternalTrackApplicationResponse.cs delete mode 100644 backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs delete mode 100644 backend/Core/Musify.Application/YouTube/GetYouTubeAlbum.cs delete mode 100644 backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs delete mode 100644 backend/Core/Musify.Application/YouTube/Responses/YouTubeStreamResponse.cs delete mode 100644 backend/Core/Musify.Domain/Entities/Artist.cs delete mode 100644 backend/Core/Musify.Domain/Entities/ExternalAlbum.cs delete mode 100644 backend/Core/Musify.Domain/Entities/ExternalTrack.cs delete mode 100644 backend/Core/Musify.Domain/Entities/LocalTrack.cs delete mode 100644 backend/Core/Musify.Domain/Entities/TrackArtist.cs delete mode 100644 backend/Core/Musify.Domain/Entities/UserAlbum.cs delete mode 100644 backend/Core/Musify.Domain/ValueObjects/MixItemSource.cs delete mode 100644 backend/Core/Musify.Domain/ValueObjects/TrackSource.cs delete mode 100644 backend/Core/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs delete mode 100644 backend/Core/Musify.Infrastructure/Configuration/YtDlpConfiguration.cs delete mode 100644 backend/Core/Musify.Infrastructure/Helpers/YouTubeThumbNailHelper.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/DownloadYouTubeAudioActivity.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadYouTubeAudioArguments.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Consumers/DownloadYouTubeTrackConsumer.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadYouTubeAudioLog.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/YouTubeTrackRoutingSlipBuilder.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.Designer.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.cs delete mode 100644 backend/Core/Musify.Infrastructure/Services/DisabledYouTubeMusicService.cs delete mode 100644 backend/Core/Musify.Infrastructure/Services/YouTubeDownloaderService.cs delete mode 100644 backend/Core/Musify.Infrastructure/Services/YouTubeMusicService.cs delete mode 100644 backend/Hosts/Musify.Api/Validators/Likes/ToggleLikeRequestValidator.cs delete mode 100644 backend/Hosts/Musify.Api/Validators/PlayLists/AddPlayListTrackRequestValidator.cs delete mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs delete mode 100644 backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs delete mode 100644 backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs delete mode 100644 backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs delete mode 100644 backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs diff --git a/README.md b/README.md index 1cd6ff8..9a319a7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Musify -Music streaming platform: upload your own tracks (or import them from YouTube), -process them asynchronously (audio transcoded to `.m4a`, thumbnails generated) -and stream them without the bytes ever going through the API. +Music streaming platform: upload your own tracks, process them asynchronously +(audio transcoded to `.m4a`, thumbnails generated) and stream them without the +bytes ever going through the API. ``` backend/ .NET 10 — Domain / Application / Infrastructure / Api / Worker / StreamingGateway / Tests @@ -46,7 +46,7 @@ production prerequisites. |---|---|---| | `Musify.Api` | 5111 | REST API, issues presigned upload URLs and stream tickets | | `Musify.StreamingGateway` | 8081 | YARP proxy that validates the stream ticket and serves the audio | -| `Musify.Worker` | — | MassTransit consumers: transcoding, thumbnails, YouTube downloads | +| `Musify.Worker` | — | MassTransit consumers: transcoding, thumbnails | | `web-player` | 5173 / 3000 | Web client (SvelteKit, server-side OIDC session) | | Zitadel | 8080 | Identity (OIDC/OAuth2) | | PostgreSQL / RabbitMQ / SeaweedFS / Jaeger | 59000 / 5672 / 8333 / 16686 | Metadata, messaging, object storage, traces | diff --git a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs index 340fe1b..ac4fceb 100644 --- a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs +++ b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs @@ -17,7 +17,7 @@ namespace Musify.Application.Albums { public async ValueTask> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken) { - var album = await database.UserAlbums + var album = await database.Albums .AsNoTracking() .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (album is null) @@ -32,19 +32,16 @@ namespace Musify.Application.Albums return Error.Unauthorized(); } - var trackExists = await database.Tracks + var track = await database.Tracks .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId, cancellationToken); - if (!trackExists) + .SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); + if (track is null) { logger.LogInformation("Track {TrackId} not found", request.TrackId); return Error.NotFound(description: $"Track {request.TrackId} not found"); } - var localTrack = await database.LocalTracks - .AsNoTracking() - .SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); - if (localTrack is not null && localTrack.OwnerUserId != request.UserId) + if (track.OwnerUserId != request.UserId) { logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId); return Error.Unauthorized(); diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index 81108a9..842f97c 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -50,7 +50,7 @@ namespace Musify.Application.Albums var intent = pictureIntent.Value; var title = request.Title.Trim(); - var album = new UserAlbum + var album = new Album { OwnerUserId = request.UserId, Title = title, @@ -66,7 +66,7 @@ namespace Musify.Application.Albums } }; - await database.UserAlbums.AddAsync(album, cancellationToken); + await database.Albums.AddAsync(album, cancellationToken); var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName); var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken); diff --git a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs index 3817398..fa8786b 100644 --- a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs +++ b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs @@ -16,7 +16,7 @@ namespace Musify.Application.Albums { public async ValueTask> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) { - var album = await database.UserAlbums + var album = await database.Albums .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (album is null) { @@ -30,7 +30,7 @@ namespace Musify.Application.Albums return Error.Unauthorized(); } - database.UserAlbums.Remove(album); + database.Albums.Remove(album); try { diff --git a/backend/Core/Musify.Application/Albums/GetAlbumById.cs b/backend/Core/Musify.Application/Albums/GetAlbumById.cs index ccf9e51..7f18ebd 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumById.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumById.cs @@ -14,7 +14,7 @@ namespace Musify.Application.Albums { public async ValueTask> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) { - var album = await database.UserAlbums + var album = await database.Albums .AsNoTracking() .Where(a => a.Id == request.Id) .Select(a => new diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs index f70c35e..cd315f5 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; using X.PagedList; using X.PagedList.EF; @@ -29,9 +28,7 @@ namespace Musify.Application.Albums var tracksQuery = database.AlbumHasTracks .AsNoTracking() - .Include(albumTrack => ((ExternalTrack)albumTrack.Track).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(albumTrack => ((LocalTrack)albumTrack.Track).Owner) + .Include(albumTrack => albumTrack.Track.Owner) .Where(albumTrack => albumTrack.AlbumId == request.AlbumId); var totalCount = await tracksQuery.CountAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Albums/GetAlbums.cs b/backend/Core/Musify.Application/Albums/GetAlbums.cs index 6e594c1..f149d35 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbums.cs @@ -1,29 +1,19 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; using X.PagedList; using X.PagedList.EF; namespace Musify.Application.Albums { - public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize, string? YoutubeContinuationToken = null) + public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) : IQuery>; - public class GetAlbumsQueryHandler( - IDatabase database, - IYouTubeMusicService youTubeMusicService, - ILogger logger) + public class GetAlbumsQueryHandler(IDatabase database) : IQueryHandler> { - // Mirrors GetTracksQueryHandler's threshold: a title shorter than this is too likely to - // still be mid-keystroke to justify a live call to the external search API. - private const int MinTitleLengthForYouTubeSearch = 2; - public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) { var albumsQuery = database.Albums @@ -52,62 +42,19 @@ namespace Musify.Application.Albums }) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - var localItems = pagedEntities + var items = pagedEntities .Select(entry => AlbumSearchItemResponse.FromAlbum( AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))) .ToList(); - var knownExternalIds = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var entry in pagedEntities) - { - if (entry.Album is ExternalAlbum { Source: TrackSource.YouTube } external) - knownExternalIds.Add(external.ExternalId); - } - - var youtubeItems = new List(); - string? nextContinuationToken = null; - var youtubeUnavailable = false; - - if (request.Title?.Trim().Length >= MinTitleLengthForYouTubeSearch) - (youtubeItems, nextContinuationToken, youtubeUnavailable) = - await SearchYouTubeAsync(request.Title, request.YoutubeContinuationToken, knownExternalIds, cancellationToken); - - return AlbumsSearchResponse.Create( - [.. localItems, .. youtubeItems], + return new AlbumsSearchResponse( + items, pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.PageCount, pagedEntities.TotalItemCount, pagedEntities.HasPreviousPage, - pagedEntities.HasNextPage, - nextContinuationToken, - youtubeUnavailable); - } - - private async Task<(List Items, string? NextContinuationToken, bool Unavailable)> SearchYouTubeAsync( - string title, - string? continuationToken, - HashSet knownExternalIds, - CancellationToken cancellationToken) - { - var searchResult = await youTubeMusicService.SearchAlbumsAsync(title, continuationToken ?? string.Empty, cancellationToken); - if (searchResult.IsError) - { - logger.LogInformation( - "YouTube album search unavailable while combining album results: {Error}", searchResult.FirstError.Description); - return ([], null, true); - } - - var items = searchResult.Value.Items - .Where(album => knownExternalIds.Add(album.AlbumId)) - .Select(AlbumSearchItemResponse.FromYouTubeAlbum) - .ToList(); - - var nextToken = string.IsNullOrEmpty(searchResult.Value.ContinuationToken) - ? null - : searchResult.Value.ContinuationToken; - - return (items, nextToken, false); + pagedEntities.HasNextPage); } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs index 068c996..8b7b5b2 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs @@ -23,7 +23,7 @@ namespace Musify.Application.Albums if (!userExists) return Error.NotFound(description: $"User {request.UserId} not found"); - var albumsQuery = database.UserAlbums + var albumsQuery = database.Albums .AsNoTracking() .Where(album => album.OwnerUserId == request.UserId); diff --git a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs index b35d505..b295e61 100644 --- a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs +++ b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs @@ -16,7 +16,7 @@ namespace Musify.Application.Albums { public async ValueTask> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) { - var album = await database.UserAlbums + var album = await database.Albums .AsNoTracking() .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (album is null) diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs index 1cd4fa9..ea03647 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -4,15 +4,6 @@ using Musify.Application.Serialization; namespace Musify.Application.Albums.Responses { - // [JsonPolymorphic]/[JsonDerivedType] are required here, not optional: without them - // System.Text.Json serializes strictly by the declared CLR type at each call site - // (AlbumApplicationResponse, both as a bare return type and nested inside - // AlbumSearchItemResponse.Album), so an ExternalAlbumApplicationResponse instance would - // silently lose ExternalId/ThumbnailUrl on the wire. This also gives every response a - // "source" discriminator field for free, matching AlbumSearchItemResponse.Source. - [JsonPolymorphic(TypeDiscriminatorPropertyName = "source")] - [JsonDerivedType(typeof(AlbumApplicationResponse), typeDiscriminator: "Local")] - [JsonDerivedType(typeof(ExternalAlbumApplicationResponse), typeDiscriminator: "YouTube")] public record AlbumApplicationResponse( Guid Id, string Title, @@ -30,7 +21,7 @@ namespace Musify.Application.Albums.Responses public const int CoverTrackCount = 4; public static AlbumApplicationResponse FromEntity( - UserAlbum album, + Album album, int trackCount, IReadOnlyList? coverTrackIds = null) { @@ -48,18 +39,5 @@ namespace Musify.Application.Albums.Responses album.UpdatedAt, coverTrackIds ?? []); } - - public static AlbumApplicationResponse FromEntity( - Album album, - int trackCount, - IReadOnlyList? coverTrackIds = null) - { - return album switch - { - ExternalAlbum externalAlbum => ExternalAlbumApplicationResponse.FromEntity(externalAlbum, trackCount, coverTrackIds), - UserAlbum userAlbum => FromEntity(userAlbum, trackCount, coverTrackIds), - _ => throw new NotSupportedException($"Unsupported album entity type '{album.GetType().Name}'.") - }; - } } } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs index 40acd5a..b587b80 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs @@ -1,22 +1,7 @@ -using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; - namespace Musify.Application.Albums.Responses { - /// One item of a combined local/external album listing. Exactly one of - /// or is populated: for - /// anything already in the catalog (locally created albums and external albums materialized - /// earlier), for a live external search hit that hasn't been - /// materialized yet, so it has no album id. - public record AlbumSearchItemResponse( - TrackSource Source, - AlbumApplicationResponse? Album, - YouTubeAlbumResult? YouTubeAlbum) + public record AlbumSearchItemResponse(AlbumApplicationResponse Album) { - public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => - new(album is ExternalAlbumApplicationResponse ? TrackSource.YouTube : TrackSource.Local, album, null); - - public static AlbumSearchItemResponse FromYouTubeAlbum(YouTubeAlbumResult album) => - new(TrackSource.YouTube, null, album); + public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album); } } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs index ce8f424..f6b6f66 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs @@ -1,11 +1,6 @@ namespace Musify.Application.Albums.Responses { - /// Result of a combined albums listing/search: a page of local catalog albums - /// (user-created and previously materialized external albums), plus, when a search term was - /// given, live external hits appended after them. Pagination metadata (, - /// , , ) describes only - /// the local page — the external source has no stable total, so its continuation is tracked - /// separately via . + /// A page of the user's catalog albums (their own). public record AlbumsSearchResponse( IReadOnlyList Items, int PageNumber, @@ -13,26 +8,5 @@ namespace Musify.Application.Albums.Responses int PageCount, int TotalItemCount, bool HasPreviousPage, - bool HasNextLocalPage, - string? NextYoutubeContinuationToken, - bool YoutubeUnavailable, - bool HasNextPage) - { - // HasNextPage is a real field (not a computed property) so it shows up as a required member - // of the generated OpenAPI schema, same as every other field here — a get-only property added - // outside the primary constructor gets reflected as optional instead, which is never true here. - public static AlbumsSearchResponse Create( - IReadOnlyList items, - int pageNumber, - int pageSize, - int pageCount, - int totalItemCount, - bool hasPreviousPage, - bool hasNextLocalPage, - string? nextYoutubeContinuationToken, - bool youtubeUnavailable) => - new(items, pageNumber, pageSize, pageCount, totalItemCount, hasPreviousPage, hasNextLocalPage, - nextYoutubeContinuationToken, youtubeUnavailable, - HasNextPage: hasNextLocalPage || !string.IsNullOrEmpty(nextYoutubeContinuationToken)); - } + bool HasNextPage); } diff --git a/backend/Core/Musify.Application/Albums/Responses/ExternalAlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/ExternalAlbumApplicationResponse.cs deleted file mode 100644 index a9f12a3..0000000 --- a/backend/Core/Musify.Application/Albums/Responses/ExternalAlbumApplicationResponse.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Musify.Domain.Entities; - -namespace Musify.Application.Albums.Responses -{ - public record ExternalAlbumApplicationResponse( - Guid Id, - string Title, - string? Description, - int? ReleaseYear, - long OwnerUserId, - int TrackCount, - string? SmallImageKeyName, - string? MediumImageKeyName, - string? LargeImageKeyName, - DateTime CreatedAt, - DateTime UpdatedAt, - IReadOnlyList CoverTrackIds, - string ExternalId, - string? ThumbnailUrl) - : AlbumApplicationResponse( - Id, Title, Description, ReleaseYear, OwnerUserId, TrackCount, - SmallImageKeyName, MediumImageKeyName, LargeImageKeyName, - CreatedAt, UpdatedAt, CoverTrackIds) - { - public static ExternalAlbumApplicationResponse FromEntity( - ExternalAlbum album, - int trackCount, - IReadOnlyList? coverTrackIds = null) - { - return new ExternalAlbumApplicationResponse( - album.Id, - album.Title, - album.Description, - album.ReleaseYear, - 0, - trackCount, - album.Pictures?.SmallName, - album.Pictures?.MediumName, - album.Pictures?.LargeName, - album.CreatedAt, - album.UpdatedAt, - coverTrackIds ?? [], - album.ExternalId, - album.ThumbnailUrl); - } - } -} diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index 458d7fb..4794ce6 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -34,7 +34,7 @@ namespace Musify.Application.Albums { public async ValueTask> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) { - var album = await database.UserAlbums + var album = await database.Albums .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (album is null) { @@ -77,7 +77,7 @@ namespace Musify.Application.Albums finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); } - database.UserAlbums.Update(album); + database.Albums.Update(album); if (pictureIntent != null && finalPictureKey != null) { diff --git a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs index 296432c..176157c 100644 --- a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs +++ b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs @@ -12,7 +12,6 @@ public static class ApplicationDependencyInjection services.AddMediator(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddValidatedOptions(configuration, ApplicationStorageConfiguration.SectionName); services.AddValidatedOptions(configuration, PlayListConfiguration.SectionName); diff --git a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs index 64d1dff..5c99baf 100644 --- a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs @@ -6,16 +6,7 @@ namespace Musify.Application.Configuration { public const string SectionName = "Mix"; - [Range(1, 20)] - public int ArtistMixCount { get; set; } = 3; - [Range(4, 100)] public int ItemsPerMix { get; set; } = 12; - - [Range(1, 10)] - public int SeedArtistCount { get; set; } = 3; - - [Range(1, 100)] - public int YouTubeResultsPerSeed { get; set; } = 12; } } diff --git a/backend/Core/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs index ef100ae..71bcda0 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -11,22 +11,10 @@ namespace Musify.Application.Contracts DbSet Albums { get; } - DbSet UserAlbums { get; } - - DbSet ExternalAlbums { get; } - DbSet AlbumHasTracks { get; } DbSet Tracks { get; } - DbSet LocalTracks { get; } - - DbSet ExternalTracks { get; } - - DbSet Artists { get; } - - DbSet TrackArtists { get; } - DbSet UserHasTracks { get; } DbSet PlayListHasTracks { get; } diff --git a/backend/Core/Musify.Application/Contracts/IYouTubeDownloaderService.cs b/backend/Core/Musify.Application/Contracts/IYouTubeDownloaderService.cs deleted file mode 100644 index 8d3a98d..0000000 --- a/backend/Core/Musify.Application/Contracts/IYouTubeDownloaderService.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Musify.Application.Contracts -{ - public record YouTubeDownloadResult(int ExitCode, string StandardError); - - public interface IYouTubeDownloaderService - { - Task DownloadAudioAsync(string videoId, string destinationFilePath, CancellationToken cancellationToken); - } -} diff --git a/backend/Core/Musify.Application/Contracts/IYouTubeMusicService.cs b/backend/Core/Musify.Application/Contracts/IYouTubeMusicService.cs deleted file mode 100644 index b01cf3f..0000000 --- a/backend/Core/Musify.Application/Contracts/IYouTubeMusicService.cs +++ /dev/null @@ -1,64 +0,0 @@ -using ErrorOr; - -namespace Musify.Application.Contracts -{ - public record YouTubeArtistRef(string? Id, string Name); - - public record YouTubeSongResult( - string VideoId, - string Title, - string Artist, - string Album, - int DurationSeconds, - string ThumbnailUrl, - bool IsExplicit, - IReadOnlyList Artists); - - public record YouTubeSearchResult( - IReadOnlyList Items, - string ContinuationToken); - - public record YouTubeAlbumResult( - string AlbumId, - string Title, - string Artist, - string ThumbnailUrl, - int? ReleaseYear, - bool IsSingle, - bool IsEp, - IReadOnlyList Artists); - - public record YouTubeAlbumSearchResult( - IReadOnlyList Items, - string ContinuationToken); - - public record YouTubeAlbumTrack( - string VideoId, - string Title, - int DurationSeconds, - int TrackNumber, - bool IsExplicit); - - public record YouTubeAlbumDetail( - YouTubeAlbumResult Album, - string Description, - int TotalDurationSeconds, - IReadOnlyList Tracks); - - public record YouTubeStreamInfo(string Url, int ExpiresInSeconds); - - public interface IYouTubeMusicService - { - Task> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken); - - Task> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken); - - Task> GetAlbumAsync(string albumId, CancellationToken cancellationToken); - - Task> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken); - - Task> GetSongAsync(string videoId, CancellationToken cancellationToken); - - string ResolveArtworkUrl(string thumbnailUrl); - } -} diff --git a/backend/Core/Musify.Application/Events/DownloadYouTubeTrackEvent.cs b/backend/Core/Musify.Application/Events/DownloadYouTubeTrackEvent.cs deleted file mode 100644 index dd880a6..0000000 --- a/backend/Core/Musify.Application/Events/DownloadYouTubeTrackEvent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Musify.Application.Shared; - -namespace Musify.Application.Events -{ - public record DownloadYouTubeTrackEvent( - Guid TrackId, - string VideoId, - string ThumbnailUrl, - string Bucket, - string AudioProcessedFolderKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} diff --git a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs index 10f5abb..4981c3a 100644 --- a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; using X.PagedList; using X.PagedList.EF; @@ -23,9 +22,7 @@ namespace Musify.Application.Likes { var likesQuery = database.TrackLikes .AsNoTracking() - .Include(like => ((ExternalTrack)like.Track).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(like => ((LocalTrack)like.Track).Owner) + .Include(like => like.Track.Owner) .Where(like => like.UserId == request.UserId); var totalCount = await likesQuery.CountAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs index 3c84f0c..a2ae08a 100644 --- a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs +++ b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs @@ -3,35 +3,31 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -using Musify.Application.Services; using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; namespace Musify.Application.Likes { - public record ToggleTrackLikeCommand( - long UserId, - Guid? TrackId, - TrackSource? Source, - string? ExternalId) + public record ToggleTrackLikeCommand(long UserId, Guid TrackId) : ICommand>; public class ToggleTrackLikeCommandHandler( IDatabase database, - YouTubeTrackProvisioner provisioner, ILogger logger) : ICommandHandler> { public async ValueTask> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) { - var trackIdResult = await ResolveTrackIdAsync(request, cancellationToken); - if (trackIdResult.IsError) - return trackIdResult.Errors; - - var trackId = trackIdResult.Value; + var trackExists = await database.Tracks + .AsNoTracking() + .AnyAsync(t => t.Id == request.TrackId, cancellationToken); + if (!trackExists) + { + logger.LogInformation("Track {TrackId} not found", request.TrackId); + return Error.NotFound(description: $"Track {request.TrackId} not found"); + } var existingLike = await database.TrackLikes - .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == trackId, cancellationToken); + .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken); bool nowLiked; if (existingLike is not null) @@ -44,7 +40,7 @@ namespace Musify.Application.Likes await database.TrackLikes.AddAsync(new TrackLike { UserId = request.UserId, - TrackId = trackId + TrackId = request.TrackId }, cancellationToken); nowLiked = true; } @@ -52,32 +48,9 @@ namespace Musify.Application.Likes await database.SaveChangesAsync(cancellationToken); logger.LogInformation("User {UserId} {Action} track {TrackId}", - request.UserId, nowLiked ? "liked" : "unliked", trackId); + request.UserId, nowLiked ? "liked" : "unliked", request.TrackId); return nowLiked; } - - private async ValueTask> ResolveTrackIdAsync(ToggleTrackLikeCommand request, CancellationToken cancellationToken) - { - if (request.TrackId.HasValue) - { - var trackExists = await database.Tracks - .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId.Value, cancellationToken); - if (!trackExists) - { - logger.LogInformation("Track {TrackId} not found", request.TrackId); - return Error.NotFound(description: $"Track {request.TrackId} not found"); - } - - return request.TrackId.Value; - } - - var provisionResult = await provisioner.GetOrCreateAsync(request.ExternalId!, cancellationToken); - if (provisionResult.IsError) - return provisionResult.Errors; - - return provisionResult.Value.Track.Id; - } } } diff --git a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs index 0977c53..bff28f7 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; -using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; @@ -13,28 +12,35 @@ namespace Musify.Application.Mixes public record GenerateMixesForUserCommand(long UserId) : ICommand>; + /// Builds each user's mixes purely from their own library: a "Descubrimiento" mix of + /// tracks they own but haven't listened to yet, and a "Tu mezcla diaria" mix blending recently + /// listened tracks with unheard ones. There's no artist/genre metadata for local uploads today, + /// so mixes can't be seeded by "similar artist" the way the old YouTube-backed discovery did — + /// once a tagging system exists, this is the place to seed mixes by tag instead. public class GenerateMixesForUserCommandHandler( IDatabase database, - IYouTubeMusicService youTubeMusicService, MixConfiguration mixConfiguration, ILogger logger) : ICommandHandler> { - private sealed record SeedArtist(Guid Id, string Name); - - private sealed record LocalCandidate(Guid TrackId, string Title, string? Artist, double DurationSeconds); - - private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList Items); - - private const int TitleLength = 300; - private const int ArtistLength = 300; - private const int ThumbnailUrlLength = 512; - private const int MixTitleLength = 100; + private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList TrackIds); public async ValueTask> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) { var userId = request.UserId; + var libraryTrackIds = await database.UserHasTracks + .AsNoTracking() + .Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(userTrack => userTrack.TrackId) + .ToListAsync(cancellationToken); + + if (libraryTrackIds.Count == 0) + { + logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId); + return new Success(); + } + var historyTrackIds = await database.ListeningHistories .AsNoTracking() .Where(history => history.UserId == userId) @@ -42,33 +48,29 @@ namespace Musify.Application.Mixes .Distinct() .ToListAsync(cancellationToken); - if (historyTrackIds.Count == 0) + var historySet = new HashSet(historyTrackIds); + var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList(); + + var drafts = new List(); + + var discovery = Take(Shuffle(unheardTrackIds)); + if (discovery.Count > 0) { - logger.LogInformation("User {UserId} has no listening history yet, skipping mix generation", userId); - return new Success(); + drafts.Add(new MixDraft( + "Descubrimiento", + "Canciones de tu biblioteca que todavía no has escuchado.", + discovery)); } - var seedArtists = await GetSeedArtistsAsync(historyTrackIds, cancellationToken); - - if (seedArtists.Count == 0) + var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds))); + if (daily.Count > 0) { - logger.LogInformation("User {UserId} has no artists to seed mixes from, skipping mix generation", userId); - return new Success(); + drafts.Add(new MixDraft( + "Tu mezcla diaria", + "Lo que más escuchas, con alguna sorpresa de tu biblioteca.", + daily)); } - var knownVideoIds = await database.ExternalTracks - .AsNoTracking() - .Where(track => track.Source == TrackSource.YouTube && historyTrackIds.Contains(track.Id)) - .Select(track => track.ExternalId) - .ToListAsync(cancellationToken); - - var excludedVideoIds = new HashSet(knownVideoIds, StringComparer.OrdinalIgnoreCase); - - var youTubeByArtist = await SearchYouTubeAsync(seedArtists, excludedVideoIds, cancellationToken); - var localByArtist = await GetLocalCandidatesAsync(seedArtists, historyTrackIds, cancellationToken); - - var drafts = BuildDrafts(seedArtists, localByArtist, youTubeByArtist); - if (drafts.Count == 0) { logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); @@ -91,197 +93,21 @@ namespace Musify.Application.Mixes return new Success(); } - private async Task> GetSeedArtistsAsync( - IReadOnlyList historyTrackIds, - CancellationToken cancellationToken) + private List Take(IEnumerable trackIds) => + [.. trackIds.Take(mixConfiguration.ItemsPerMix)]; + + private static List Shuffle(IEnumerable trackIds) => + [.. trackIds.OrderBy(_ => Guid.NewGuid())]; + + private static List Interleave(IReadOnlyList first, IReadOnlyList second) { - var ranked = await database.TrackArtists - .AsNoTracking() - .Where(trackArtist => historyTrackIds.Contains(trackArtist.TrackId)) - .GroupBy(trackArtist => trackArtist.ArtistId) - .Select(group => new { ArtistId = group.Key, Listens = group.Count() } ) - .OrderByDescending(entry => entry.Listens) - .Take(mixConfiguration.SeedArtistCount) - .ToListAsync(cancellationToken); - - var artistIds = ranked.Select(entry => entry.ArtistId).ToList(); - - var names = await database.Artists - .AsNoTracking() - .Where(artist => artistIds.Contains(artist.Id)) - .ToDictionaryAsync(artist => artist.Id, artist => artist.Name, cancellationToken); - - return [.. ranked - .Where(entry => names.ContainsKey(entry.ArtistId)) - .Select(entry => new SeedArtist(entry.ArtistId, names[entry.ArtistId])) ]; - } - - private async Task> > SearchYouTubeAsync( - IReadOnlyList seedArtists, - HashSet excludedVideoIds, - CancellationToken cancellationToken) - { - var byArtist = new Dictionary>(); - - foreach (var seed in seedArtists) - { - var searchResult = await youTubeMusicService.SearchSongsAsync(seed.Name, string.Empty, cancellationToken); - if (searchResult.IsError) - { - logger.LogWarning("YouTube Music search failed for seed artist {Artist}: {Error}", - seed.Name, searchResult.FirstError.Description); - byArtist[seed.Id] = []; - continue; - } - - var songs = new List(); - foreach (var song in searchResult.Value.Items.Take(mixConfiguration.YouTubeResultsPerSeed)) - { - if (!excludedVideoIds.Add(song.VideoId)) - continue; - - songs.Add(new MixItem - { - MixId = Guid.Empty, - Source = MixItemSource.YouTube, - VideoId = song.VideoId, - Title = Truncate(song.Title, TitleLength), - Artist = Truncate(song.Artist, ArtistLength), - ThumbnailUrl = Truncate(youTubeMusicService.ResolveArtworkUrl(song.ThumbnailUrl), ThumbnailUrlLength), - DurationSeconds = song.DurationSeconds, - IsExplicit = song.IsExplicit - } ); - } - - byArtist[seed.Id] = songs; - } - - return byArtist; - } - - private async Task> > GetLocalCandidatesAsync( - IReadOnlyList seedArtists, - IReadOnlyList historyTrackIds, - CancellationToken cancellationToken) - { - var artistIds = seedArtists.Select(seed => seed.Id).ToList(); - - var links = await database.TrackArtists - .AsNoTracking() - .Where(trackArtist => artistIds.Contains(trackArtist.ArtistId) && !historyTrackIds.Contains(trackArtist.TrackId)) - .Select(trackArtist => new { trackArtist.ArtistId, trackArtist.TrackId } ) - .ToListAsync(cancellationToken); - - var trackIds = links.Select(link => link.TrackId).Distinct().ToList(); - - var candidates = await LoadCandidatesAsync(trackIds, cancellationToken); - - var byArtist = new Dictionary>(); - foreach (var seed in seedArtists) - { - byArtist[seed.Id] = [.. links - .Where(link => link.ArtistId == seed.Id) - .Select(link => candidates.GetValueOrDefault(link.TrackId)) - .Where(candidate => candidate is not null) - .Select(candidate => ToMixItem(candidate!))]; - } - - return byArtist; - } - - private async Task> LoadCandidatesAsync( - IReadOnlyList trackIds, - CancellationToken cancellationToken) - { - if (trackIds.Count == 0) - return []; - - var tracks = await database.Tracks - .AsNoTracking() - .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) - .Include(track => ((ExternalTrack)track).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(track => ((LocalTrack)track).Owner) - .ToListAsync(cancellationToken); - - return tracks - .Select(track => TrackApplicationResponse.FromEntity(track, 0)) - .ToDictionary( - response => response.Id, - response => new LocalCandidate(response.Id, response.Title, response.Artist, response.Duration)); - } - - private static MixItem ToMixItem(LocalCandidate candidate) => new() - { - MixId = Guid.Empty, - Source = MixItemSource.Musify, - TrackId = candidate.TrackId, - Title = candidate.Title, - Artist = candidate.Artist, - DurationSeconds = candidate.DurationSeconds - }; - - private List BuildDrafts( - IReadOnlyList seedArtists, - IReadOnlyDictionary> localByArtist, - IReadOnlyDictionary> youTubeByArtist) - { - var drafts = new List(); - - var everyYouTube = seedArtists.SelectMany(seed => youTubeByArtist[seed.Id]).ToList(); - var everyLocal = seedArtists.SelectMany(seed => localByArtist[seed.Id]).ToList(); - - var discovery = Take(Shuffle(everyYouTube)); - if (discovery.Count > 0) - { - drafts.Add(new MixDraft( - "Descubrimiento", - "Canciones nuevas que todavía no has escuchado.", - discovery)); - } - - var daily = Take(Interleave(Shuffle(everyLocal), Shuffle(everyYouTube))); - if (daily.Count > 0) - { - drafts.Add(new MixDraft( - "Tu mezcla diaria", - "Lo que más escuchas, con alguna sorpresa nueva.", - daily)); - } - - foreach (var seed in seedArtists.Take(mixConfiguration.ArtistMixCount)) - { - var items = Take(Interleave(Shuffle(localByArtist[seed.Id]), Shuffle(youTubeByArtist[seed.Id]))); - if (items.Count == 0) - continue; - - drafts.Add(new MixDraft( - Truncate(seed.Name, MixTitleLength), - $"Canciones de {seed.Name} y de artistas parecidos.", - items)); - } - - return drafts; - } - - private List Take(IEnumerable items) => - [.. items.Take(mixConfiguration.ItemsPerMix)]; - - private static string Truncate(string value, int maxLength) => - value.Length <= maxLength ? value : value[..maxLength]; - - private static List Shuffle(IEnumerable items) => - [.. items.OrderBy(_ => Guid.NewGuid())]; - - private static List Interleave(IReadOnlyList first, IReadOnlyList second) - { - var merged = new List(first.Count + second.Count); + var merged = new List(first.Count + second.Count); for (var index = 0; index < Math.Max(first.Count, second.Count); index++) { if (index < first.Count) - merged.Add(first[index] ); + merged.Add(first[index]); if (index < second.Count) - merged.Add(second[index] ); + merged.Add(second[index]); } return merged; @@ -317,21 +143,13 @@ namespace Musify.Application.Mixes await database.Mixes.AddAsync(mix, cancellationToken); - for (var itemPosition = 0; itemPosition < draft.Items.Count; itemPosition++) + for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++) { - var item = draft.Items[itemPosition]; await database.MixItems.AddAsync(new MixItem { MixId = mix.Id, Position = itemPosition, - Source = item.Source, - TrackId = item.TrackId, - VideoId = item.VideoId, - Title = item.Title, - Artist = item.Artist, - ThumbnailUrl = item.ThumbnailUrl, - DurationSeconds = item.DurationSeconds, - IsExplicit = item.IsExplicit + TrackId = draft.TrackIds[itemPosition] }, cancellationToken); } } diff --git a/backend/Core/Musify.Application/Mixes/GetMixById.cs b/backend/Core/Musify.Application/Mixes/GetMixById.cs index a841fd5..e10c5c1 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixById.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixById.cs @@ -22,9 +22,10 @@ namespace Musify.Application.Mixes if (mix is null) return Error.NotFound(description: $"Mix {request.MixId} was not found"); - var items = mix.Items.OrderBy(item => item.Position).ToList(); + var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); + var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken); - return MixApplicationResponse.FromEntity(mix, items.Count, items); + return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items); } } } diff --git a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs index a2d1526..570aa6e 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs @@ -14,24 +14,24 @@ namespace Musify.Application.Mixes { public async ValueTask>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) { - var entries = await database.Mixes + var mixes = await database.Mixes .AsNoTracking() .Where(mix => mix.UserId == request.UserId) + .Include(mix => mix.Items) .OrderBy(mix => mix.Position) - .Select(mix => new - { - Mix = mix, - ItemCount = mix.Items.Count, - CoverItems = mix.Items - .OrderBy(item => item.Position) - .Take(MixApplicationResponse.CoverItemCount) - .ToList() - }) .ToListAsync(cancellationToken); - return entries - .Select(entry => MixApplicationResponse.FromEntity(entry.Mix, entry.ItemCount, entry.CoverItems)) - .ToList(); + var responses = new List(mixes.Count); + foreach (var mix in mixes) + { + var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); + var coverItems = await MixItemMapper.ToResponsesAsync( + database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken); + + responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems)); + } + + return responses; } } } diff --git a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs new file mode 100644 index 0000000..3017594 --- /dev/null +++ b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Mixes.Responses; +using Musify.Domain.Entities; + +namespace Musify.Application.Mixes +{ + /// Resolves MixItem rows to their MixItemApplicationResponse shape via a single batched + /// track lookup, instead of an EF Include chain deep enough (Mix -> Items -> Track -> Owner) to + /// force a correlated-subquery translation some providers (SQLite, notably) can't produce. + internal static class MixItemMapper + { + public static async Task> ToResponsesAsync( + IDatabase database, + IReadOnlyList orderedItems, + CancellationToken cancellationToken) + { + var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList(); + + var tracksById = await database.Tracks + .AsNoTracking() + .Include(track => track.Owner) + .Where(track => trackIds.Contains(track.Id)) + .ToDictionaryAsync(track => track.Id, cancellationToken); + + return orderedItems + .Where(item => tracksById.ContainsKey(item.TrackId)) + .Select(item => + { + var track = tracksById[item.TrackId]; + return new MixItemApplicationResponse(item.TrackId, track.Title, track.Owner.Name, track.DurationSeconds); + }) + .ToList(); + } + } +} diff --git a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs index 4f7da59..6af9494 100644 --- a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs +++ b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs @@ -1,5 +1,3 @@ -using Musify.Domain.Entities; - namespace Musify.Application.Mixes.Responses { public record MixApplicationResponse( @@ -10,16 +8,5 @@ namespace Musify.Application.Mixes.Responses IReadOnlyList Items) { public const int CoverItemCount = 4; - - public static MixApplicationResponse FromEntity( - Mix mix, - int itemCount, - IReadOnlyList items) => - new( - mix.Id, - mix.Title, - mix.Subtitle, - itemCount, - [.. items.Select(MixItemApplicationResponse.FromEntity)]); } } diff --git a/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs b/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs index 8ef4a24..409315a 100644 --- a/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs +++ b/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs @@ -1,27 +1,8 @@ -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; - namespace Musify.Application.Mixes.Responses { public record MixItemApplicationResponse( - MixItemSource Source, - Guid? TrackId, - string? VideoId, + Guid TrackId, string Title, string? Artist, - string? ThumbnailUrl, - double DurationSeconds, - bool IsExplicit) - { - public static MixItemApplicationResponse FromEntity(MixItem item) => - new( - item.Source, - item.TrackId, - item.VideoId, - item.Title ?? string.Empty, - item.Artist, - item.ThumbnailUrl, - item.DurationSeconds, - item.IsExplicit); - } + double DurationSeconds); } diff --git a/backend/Core/Musify.Application/PlayLists/AddYouTubeTrackToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddYouTubeTrackToPlayList.cs deleted file mode 100644 index 09b049d..0000000 --- a/backend/Core/Musify.Application/PlayLists/AddYouTubeTrackToPlayList.cs +++ /dev/null @@ -1,149 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Application.Events; -using Musify.Application.Services; -using Musify.Application.Shared; -using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; - -namespace Musify.Application.PlayLists -{ - public record AddYouTubeTrackToPlayListCommand( - long UserId, - Guid PlayListId, - string VideoId) - : ICommand>; - - public class AddYouTubeTrackToPlayListCommandHandler( - IDatabase database, - IEventBus eventBus, - IYouTubeMusicService youTubeMusicService, - YouTubeTrackProvisioner provisioner, - ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration, - ILogger logger) - : ICommandHandler> - { - public async ValueTask> Handle(AddYouTubeTrackToPlayListCommand request, CancellationToken cancellationToken) - { - var playList = await database.PlayLists.AsNoTracking() - .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playList is null) - { - logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } - - if (playList.UserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); - } - - var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken); - if (provisionResult.IsError) - return provisionResult.Errors; - - var track = provisionResult.Value.Track; - - var failed = track.Audio.IsFailed; - var neverQueued = !track.Audio.DownloadRequested && !track.Audio.IsProcessed; - if (failed || neverQueued) - { - if (failed) - { - track.Audio.TranscodeStatus = ProcessingStatus.Pending; - track.Pictures.ProcessingStatus = ProcessingStatus.Pending; - track.LifeCycleStatus = LifeCycleStatus.Active; - track.Audio.RetryCount += 1; - track.Audio.LastRetryAt = DateTime.UtcNow; - } - track.Audio.DownloadRequested = true; - - var thumbnailUrl = await ResolveThumbnailUrlAsync(request.VideoId, provisionResult.Value.Song, cancellationToken); - if (thumbnailUrl.IsError) - return thumbnailUrl.Errors; - - await PublishDownloadEventAsync(track.Id, request.VideoId, thumbnailUrl.Value, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Queued YouTube track {VideoId} for download", request.VideoId); - } - - var alreadyAdded = await database.PlayListHasTracks.AsNoTracking() - .AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == track.Id, cancellationToken); - if (alreadyAdded) - { - logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", track.Id, request.PlayListId); - return Error.Conflict(description: "Track is already in the playlist."); - } - - var playListTracksQuery = database.PlayListHasTracks - .Where(plt => plt.PlayListId == request.PlayListId); - - var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken); - var nextPosition = hasTracks - ? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1 - : 0; - - await database.PlayListHasTracks.AddAsync(new PlayListHasTrack - { - PlayListId = request.PlayListId, - TrackId = track.Id, - Position = nextPosition - }, cancellationToken); - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Added YouTube track {VideoId} ({TrackId}) to playlist {PlayListId} at position {Position}", - request.VideoId, track.Id, request.PlayListId, nextPosition); - - var listensCount = await database.ListeningHistories.AsNoTracking() - .CountAsync(lh => lh.TrackId == track.Id, cancellationToken); - - return TrackApplicationResponse.FromEntity(track, listensCount); - } - - private async Task> ResolveThumbnailUrlAsync(string videoId, YouTubeSongResult? song, CancellationToken cancellationToken) - { - if (!string.IsNullOrEmpty(song?.ThumbnailUrl)) - return youTubeMusicService.ResolveArtworkUrl(song.ThumbnailUrl); - - var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken); - if (songResult.IsError) - return songResult.Errors; - - return youTubeMusicService.ResolveArtworkUrl(songResult.Value.ThumbnailUrl); - } - - private async Task PublishDownloadEventAsync(Guid trackId, string videoId, string thumbnailUrl, CancellationToken cancellationToken) - { - var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); - - await eventBus.PublishAsync( - new DownloadYouTubeTrackEvent( - trackId, - videoId, - thumbnailUrl, - storageConfiguration.Bucket, - audioProcessedFolderKey, - new ImageSize( - trackConfiguration.Routes.SmallPicturesPath, - trackConfiguration.PicturesSizes.SmallPictureWidth, - trackConfiguration.PicturesSizes.SmallPictureHeight), - new ImageSize( - trackConfiguration.Routes.MediumPicturesPath, - trackConfiguration.PicturesSizes.MediumPictureWidth, - trackConfiguration.PicturesSizes.MediumPictureHeight), - new ImageSize( - trackConfiguration.Routes.LargePicturesPath, - trackConfiguration.PicturesSizes.LargePictureWidth, - trackConfiguration.PicturesSizes.LargePictureHeight)), - cancellationToken); - } - } -} diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index 147700a..6f04d1a 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; using X.PagedList; using X.PagedList.EF; @@ -29,9 +28,7 @@ namespace Musify.Application.PlayLists var tracksQuery = database.PlayListHasTracks .AsNoTracking() - .Include(plt => ((ExternalTrack)plt.Track).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(plt => ((LocalTrack)plt.Track).Owner) + .Include(plt => plt.Track.Owner) .Where(plt => plt.PlayListId == request.PlayListId); var totalCount = await tracksQuery.CountAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Services/YouTubeTrackProvisioner.cs b/backend/Core/Musify.Application/Services/YouTubeTrackProvisioner.cs deleted file mode 100644 index b29b09a..0000000 --- a/backend/Core/Musify.Application/Services/YouTubeTrackProvisioner.cs +++ /dev/null @@ -1,265 +0,0 @@ -using ErrorOr; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; - -namespace Musify.Application.Services; - -public sealed record YouTubeTrackProvision(ExternalTrack Track, YouTubeSongResult? Song); - -public sealed class YouTubeTrackProvisioner( - IDatabase database, - IYouTubeMusicService youTubeMusicService, - TrackConfiguration trackConfiguration, - ILogger logger) -{ - public Task FindAsync(string videoId, CancellationToken cancellationToken) => - database.ExternalTracks - .Include(track => track.TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == videoId, cancellationToken); - - public async Task> GetOrCreateAsync(string videoId, CancellationToken cancellationToken) - { - var existing = await FindAsync(videoId, cancellationToken); - if (existing is not null) - return new YouTubeTrackProvision(existing, Song: null); - - var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken); - if (songResult.IsError) - return songResult.Errors; - - var song = songResult.Value; - - var artistRefs = song.Artists - .GroupBy(artistRef => artistRef.Id ?? artistRef.Name.ToUpperInvariant()) - .Select(group => group.First()) - .ToList(); - - var artists = new List(); - foreach (var artistRef in artistRefs) - artists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken)); - - var title = Truncate(song.Title, 200); - var track = new ExternalTrack - { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - Source = TrackSource.YouTube, - ExternalId = videoId, - DurationSeconds = song.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 - } - }; - - await database.ExternalTracks.AddAsync(track, cancellationToken); - - for (var position = 0; position < artists.Count; position++) - { - await database.TrackArtists.AddAsync(new TrackArtist - { - TrackId = track.Id, - ArtistId = artists[position].Id, - Position = position - }, cancellationToken); - } - - try - { - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Created YouTube track {VideoId} ({TrackId})", videoId, track.Id); - return new YouTubeTrackProvision(track, Song: song); - } - catch (DbUpdateException) - { - database.ExternalTracks.Remove(track); - var refetched = await FindAsync(videoId, cancellationToken); - if (refetched is null) - { - logger.LogWarning("YouTube track {VideoId} creation raced but could not be refetched", videoId); - return Error.Conflict(description: "The track was just created by another request. Retry the operation."); - } - return new YouTubeTrackProvision(refetched, Song: null); - } - } - - 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(); - 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 GetOrCreateArtistAsync(YouTubeArtistRef reference, CancellationToken cancellationToken) - { - var name = Truncate(reference.Name, 200); - var normalized = name.ToUpperInvariant(); - var externalId = reference.Id ?? $"name:{normalized}"; - - var existing = await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == externalId, cancellationToken); - if (existing is not null) - return existing; - - var artist = new Artist - { - Name = name, - NormalizedName = normalized, - ExternalId = externalId - }; - - await database.Artists.AddAsync(artist, cancellationToken); - - try - { - await database.SaveChangesAsync(cancellationToken); - return artist; - } - catch (DbUpdateException) - { - database.Artists.Remove(artist); - return await database.Artists.SingleAsync(a => a.ExternalId == externalId, cancellationToken); - } - } - - private async Task 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]; -} diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index 87c0976..48db68b 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -59,7 +59,7 @@ namespace Musify.Application.Tracks var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName); var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); - var trackEntity = new LocalTrack + var trackEntity = new Track { Title = request.Title, NormalizedTitle = request.Title.ToUpperInvariant(), @@ -80,7 +80,7 @@ namespace Musify.Application.Tracks } }; - await database.LocalTracks.AddAsync(trackEntity, cancellationToken); + await database.Tracks.AddAsync(trackEntity, cancellationToken); await database.UserHasTracks.AddAsync(new UserHasTrack { diff --git a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs index 8e00bda..3a712d3 100644 --- a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs +++ b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs @@ -29,7 +29,7 @@ namespace Musify.Application.Tracks return Error.NotFound(); } - if (track is not LocalTrack localTrack || localTrack.OwnerUserId != request.UserId) + if (track.OwnerUserId != request.UserId) { logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId); return Error.Unauthorized(); diff --git a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs index 5b660f9..080b9db 100644 --- a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs +++ b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs @@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; namespace Musify.Application.Tracks { @@ -25,9 +24,7 @@ namespace Musify.Application.Tracks .ToListAsync(cancellationToken); var tracksById = await database.Tracks - .Include(t => ((ExternalTrack)t).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(t => ((LocalTrack)t).Owner) + .Include(t => t.Owner) .Where(t => recentTrackIds.Contains(t.Id)) .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count } ) .ToDictionaryAsync(t => t.Id, cancellationToken); diff --git a/backend/Core/Musify.Application/Tracks/GetTrackById.cs b/backend/Core/Musify.Application/Tracks/GetTrackById.cs index 565628b..9c4c10a 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackById.cs @@ -3,7 +3,6 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; namespace Musify.Application.Tracks { @@ -16,9 +15,7 @@ namespace Musify.Application.Tracks public async ValueTask> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) { var entity = await database.Tracks.AsNoTracking() - .Include(t => ((ExternalTrack)t).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(t => ((LocalTrack)t).Owner) + .Include(t => t.Owner) .Where(t => t.Id == request.TrackId) .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count }) .SingleOrDefaultAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index 7add78c..9233a70 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -1,37 +1,24 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; using Musify.Application.Contracts; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks { - public record GetTracksQuery(string? Name, int PageNumber, int PageSize, string? YoutubeContinuationToken = null) + public record GetTracksQuery(string? Name, int PageNumber, int PageSize) : IQuery>; - public class GetTracksQueryHandler( - IDatabase database, - IYouTubeMusicService youTubeMusicService, - ILogger logger) + public class GetTracksQueryHandler(IDatabase database) : IQueryHandler> { - // Mirrors the web client's search-box debounce threshold: a query shorter than this is too - // likely to still be mid-keystroke to justify a live call to the YouTube Music API for every - // client that hits this endpoint. - private const int MinNameLengthForYouTubeSearch = 2; - public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) { var tracksQuery = database.UserHasTracks .AsNoTracking() - .Include(ut => ((ExternalTrack)ut.Track).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(ut => ((LocalTrack)ut.Track).Owner) + .Include(ut => ut.Track.Owner) .AsQueryable(); if (!string.IsNullOrEmpty(request.Name)) @@ -48,61 +35,18 @@ namespace Musify.Application.Tracks .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count }) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - var localItems = pagedEntities + var items = pagedEntities .Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount))) .ToList(); - var knownVideoIds = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var entry in pagedEntities) - { - if (entry.Track is ExternalTrack { Source: TrackSource.YouTube } external) - knownVideoIds.Add(external.ExternalId); - } - - var youtubeItems = new List(); - string? nextContinuationToken = null; - var youtubeUnavailable = false; - - if (request.Name?.Trim().Length >= MinNameLengthForYouTubeSearch) - (youtubeItems, nextContinuationToken, youtubeUnavailable) = - await SearchYouTubeAsync(request.Name, request.YoutubeContinuationToken, knownVideoIds, cancellationToken); - - return TracksSearchResponse.Create( - [.. localItems, .. youtubeItems], + return new TracksSearchResponse( + items, pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.PageCount, pagedEntities.TotalItemCount, pagedEntities.HasPreviousPage, - pagedEntities.HasNextPage, - nextContinuationToken, - youtubeUnavailable); - } - - private async Task<(List Items, string? NextContinuationToken, bool Unavailable)> SearchYouTubeAsync( - string name, - string? continuationToken, - HashSet knownVideoIds, - CancellationToken cancellationToken) - { - var searchResult = await youTubeMusicService.SearchSongsAsync(name, continuationToken ?? string.Empty, cancellationToken); - if (searchResult.IsError) - { - logger.LogInformation( - "YouTube search unavailable while combining track results: {Error}", searchResult.FirstError.Description); - return ([], null, true); - } - - var items = searchResult.Value.Items - .Where(song => knownVideoIds.Add(song.VideoId)) - .Select(TrackSearchItemResponse.FromYouTubeSong) - .ToList(); - - var nextToken = string.IsNullOrEmpty(searchResult.Value.ContinuationToken) - ? null - : searchResult.Value.ContinuationToken; - - return (items, nextToken, false); + pagedEntities.HasNextPage); } } } diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index 2e38b10..9babb03 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -6,7 +6,6 @@ using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; using Musify.Application.Contracts; -using Musify.Domain.Entities; namespace Musify.Application.Tracks { @@ -27,9 +26,7 @@ namespace Musify.Application.Tracks var tracksQuery = database.UserHasTracks .AsNoTracking() - .Include(ut => ((ExternalTrack)ut.Track).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(ut => ((LocalTrack)ut.Track).Owner) + .Include(ut => ut.Track.Owner) .Where(p => p.UserId == request.UserId); if (!string.IsNullOrEmpty(request.Name)) diff --git a/backend/Core/Musify.Application/Tracks/Responses/ExternalTrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/ExternalTrackApplicationResponse.cs deleted file mode 100644 index 9e9e850..0000000 --- a/backend/Core/Musify.Application/Tracks/Responses/ExternalTrackApplicationResponse.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; - -namespace Musify.Application.Tracks.Responses -{ - public record ExternalTrackApplicationResponse( - Guid Id, - string Title, - string? Artist, - ProcessingStatus AudioStatus, - double Duration, - int ListensCount, - DateTime CreatedAt, - DateTime UpdatedAt, - string ExternalId) - : TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt) - { - public static ExternalTrackApplicationResponse FromEntity(ExternalTrack track, int listensCount) => - new( - track.Id, - track.Title, - FormatArtist(track), - track.Audio.TranscodeStatus, - track.DurationSeconds, - listensCount, - track.CreatedAt, - track.UpdatedAt, - track.ExternalId); - } -} diff --git a/backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs deleted file mode 100644 index 09580d3..0000000 --- a/backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; - -namespace Musify.Application.Tracks.Responses -{ - public record LocalTrackApplicationResponse( - Guid Id, - string Title, - string? Artist, - ProcessingStatus AudioStatus, - double Duration, - int ListensCount, - DateTime CreatedAt, - DateTime UpdatedAt, - [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId) - : TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt) - { - public static LocalTrackApplicationResponse FromEntity(LocalTrack track, int listensCount) => - new( - track.Id, - track.Title, - FormatArtist(track), - track.Audio.TranscodeStatus, - track.DurationSeconds, - listensCount, - track.CreatedAt, - track.UpdatedAt, - track.OwnerUserId); - } -} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index a8b5a53..8895b63 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -1,24 +1,11 @@ -using System.Text.Json.Serialization; +using Musify.Application.Serialization; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; +using System.Text.Json.Serialization; namespace Musify.Application.Tracks.Responses { - // See AlbumApplicationResponse for why [JsonPolymorphic]/[JsonDerivedType] are required - // (not optional) whenever a base type carries subtype-only data across the wire. Track is a - // cleaner case than Album: both OwnerUserId (local-only) and ExternalId (external-only) are - // genuinely exclusive to one side, so the base has no "meaningless sentinel" field at all — - // every real value is one of the two derived types, hence base is abstract. - // - // No explicit Source property here (unlike the domain entities): STJ's polymorphism support - // already injects a "source" discriminator property with the same "Local"/"YouTube" values a - // hand-written TrackSource field would have, and a real property with that exact name would - // collide with it ("contains property 'source' that conflicts with an existing metadata - // property name"). Use the discriminator (or a type check) instead of a Source field. - [JsonPolymorphic(TypeDiscriminatorPropertyName = "source")] - [JsonDerivedType(typeof(LocalTrackApplicationResponse), typeDiscriminator: "Local")] - [JsonDerivedType(typeof(ExternalTrackApplicationResponse), typeDiscriminator: "YouTube")] - public abstract record TrackApplicationResponse( + public record TrackApplicationResponse( Guid Id, string Title, string? Artist, @@ -26,24 +13,19 @@ namespace Musify.Application.Tracks.Responses double Duration, int ListensCount, DateTime CreatedAt, - DateTime UpdatedAt) + DateTime UpdatedAt, + [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId) { public static TrackApplicationResponse FromEntity(Track track, int listensCount) => - track switch - { - LocalTrack local => LocalTrackApplicationResponse.FromEntity(local, listensCount), - ExternalTrack external => ExternalTrackApplicationResponse.FromEntity(external, listensCount), - _ => throw new NotSupportedException($"Unsupported track entity type '{track.GetType().Name}'.") - }; - - protected static string? FormatArtist(Track track) => track switch - { - LocalTrack local => local.Owner.Name, - ExternalTrack { TrackArtists.Count: > 0 } external => - string.Join(", ", external.TrackArtists - .OrderBy(trackArtist => trackArtist.Position) - .Select(trackArtist => trackArtist.Artist.Name)), - _ => null - }; + new( + track.Id, + track.Title, + track.Owner.Name, + track.Audio.TranscodeStatus, + track.DurationSeconds, + listensCount, + track.CreatedAt, + track.UpdatedAt, + track.OwnerUserId); } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs index 793fc85..1477ec4 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs @@ -1,22 +1,7 @@ -using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; - namespace Musify.Application.Tracks.Responses { - /// One item of a combined local/YouTube track listing. Exactly one of - /// or is populated: for - /// anything already in the catalog (local uploads and YouTube tracks provisioned earlier), - /// for a live YouTube Music search hit that has not been - /// provisioned yet, so it has no track id. - public record TrackSearchItemResponse( - TrackSource Source, - TrackApplicationResponse? Track, - YouTubeSongResult? YouTubeSong) + public record TrackSearchItemResponse(TrackApplicationResponse Track) { - public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => - new(track is ExternalTrackApplicationResponse ? TrackSource.YouTube : TrackSource.Local, track, null); - - public static TrackSearchItemResponse FromYouTubeSong(YouTubeSongResult song) => - new(TrackSource.YouTube, null, song); + public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track); } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs index 2861bc1..caee538 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs @@ -1,11 +1,6 @@ namespace Musify.Application.Tracks.Responses { - /// Result of a combined tracks listing/search: a page of local catalog tracks - /// (uploads and previously provisioned YouTube tracks), plus, when a search term was given, - /// live YouTube Music hits appended after them. Pagination metadata (, - /// , , ) describes only - /// the local page — YouTube has no stable total, so its continuation is tracked separately via - /// . + /// A page of the user's catalog tracks (their own uploads). public record TracksSearchResponse( IReadOnlyList Items, int PageNumber, @@ -13,26 +8,5 @@ namespace Musify.Application.Tracks.Responses int PageCount, int TotalItemCount, bool HasPreviousPage, - bool HasNextLocalPage, - string? NextYoutubeContinuationToken, - bool YoutubeUnavailable, - bool HasNextPage) - { - // HasNextPage is a real field (not a computed property) so it shows up as a required member - // of the generated OpenAPI schema, same as every other field here — a get-only property added - // outside the primary constructor gets reflected as optional instead, which is never true here. - public static TracksSearchResponse Create( - IReadOnlyList items, - int pageNumber, - int pageSize, - int pageCount, - int totalItemCount, - bool hasPreviousPage, - bool hasNextLocalPage, - string? nextYoutubeContinuationToken, - bool youtubeUnavailable) => - new(items, pageNumber, pageSize, pageCount, totalItemCount, hasPreviousPage, hasNextLocalPage, - nextYoutubeContinuationToken, youtubeUnavailable, - HasNextPage: hasNextLocalPage || !string.IsNullOrEmpty(nextYoutubeContinuationToken)); - } + bool HasNextPage); } diff --git a/backend/Core/Musify.Application/YouTube/GetYouTubeAlbum.cs b/backend/Core/Musify.Application/YouTube/GetYouTubeAlbum.cs deleted file mode 100644 index f92b1fd..0000000 --- a/backend/Core/Musify.Application/YouTube/GetYouTubeAlbum.cs +++ /dev/null @@ -1,36 +0,0 @@ -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>; - - public class GetYouTubeAlbumQueryHandler( - IYouTubeMusicService youTubeMusicService, - YouTubeTrackProvisioner provisioner, - ILogger logger) - : IQueryHandler> - { - public async ValueTask> Handle(GetYouTubeAlbumQuery request, CancellationToken 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; - } - } -} diff --git a/backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs b/backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs deleted file mode 100644 index 0af4f4e..0000000 --- a/backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs +++ /dev/null @@ -1,85 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Application.Services; -using Musify.Application.YouTube.Responses; -using Musify.Domain.Entities; - -namespace Musify.Application.YouTube -{ - public record ResolveYouTubeTrackStreamCommand(string VideoId, long? UserId) - : ICommand>; - - public class ResolveYouTubeTrackStreamCommandHandler( - IDatabase database, - IYouTubeMusicService youTubeMusicService, - YouTubeTrackProvisioner provisioner, - TrackStreamIssuer streamIssuer, - PlaybackConfiguration playbackConfiguration, - ILogger logger) - : ICommandHandler> - { - public async ValueTask> Handle(ResolveYouTubeTrackStreamCommand request, CancellationToken cancellationToken) - { - var isAnonymous = !request.UserId.HasValue; - if (isAnonymous && !playbackConfiguration.AllowAnonymousListening) - return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening."); - - var track = await provisioner.FindAsync(request.VideoId, cancellationToken); - - if (track != null && track.Audio.IsProcessed) - { - var serverStream = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken); - return new YouTubeStreamResponse( - track.Id, - YouTubeStreamMode.Server, - serverStream.ManifestUrl, - serverStream.Ticket, - serverStream.ExpiresInSeconds); - } - - if (isAnonymous && playbackConfiguration.AnonymousFragmentSeconds > 0) - return Error.Conflict(description: "This track isn't available for anonymous preview yet. Try again in a moment."); - - var streamInfo = await youTubeMusicService.GetAudioStreamAsync(request.VideoId, cancellationToken); - if (streamInfo.IsError) - return streamInfo.Errors; - - if (track == null) - { - var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken); - if (!provisionResult.IsError) - track = provisionResult.Value.Track; - else - logger.LogWarning("Could not provision YouTube track {VideoId}: {Error}", request.VideoId, provisionResult.FirstError.Description); - } - - if (track != null&& request.UserId != null) - { - await database.ListeningHistories.AddAsync(new ListeningHistory - { - UserId = request.UserId.Value, - TrackId = track.Id, - }, cancellationToken); - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (DbUpdateException exception) - { - logger.LogWarning(exception, "Failed to record listening history for YouTube video {VideoId}", request.VideoId); - } - } - - return new YouTubeStreamResponse( - track?.Id, - YouTubeStreamMode.YouTube, - streamInfo.Value.Url, null, - streamInfo.Value.ExpiresInSeconds); - } - } -} diff --git a/backend/Core/Musify.Application/YouTube/Responses/YouTubeStreamResponse.cs b/backend/Core/Musify.Application/YouTube/Responses/YouTubeStreamResponse.cs deleted file mode 100644 index 2f32a8a..0000000 --- a/backend/Core/Musify.Application/YouTube/Responses/YouTubeStreamResponse.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Musify.Application.YouTube.Responses -{ - public enum YouTubeStreamMode - { - Server = 0, - YouTube = 1 - } - - public record YouTubeStreamResponse( - Guid? TrackId, - YouTubeStreamMode Mode, - string StreamUrl, - string? Ticket, - int ExpiresInSeconds); -} diff --git a/backend/Core/Musify.Domain/Entities/Album.cs b/backend/Core/Musify.Domain/Entities/Album.cs index 8a63ab8..bf3f866 100644 --- a/backend/Core/Musify.Domain/Entities/Album.cs +++ b/backend/Core/Musify.Domain/Entities/Album.cs @@ -1,11 +1,12 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; namespace Musify.Domain.Entities { #pragma warning disable CS8618 - public abstract class Album : IAuditable + public class Album : IAuditable { [Key] public Guid Id { get; set; } = Guid.NewGuid(); @@ -25,6 +26,12 @@ namespace Musify.Domain.Entities public AlbumPictures? Pictures { get; set; } + [Required] + public required long OwnerUserId { get; set; } + + [ForeignKey(nameof(OwnerUserId))] + public User Owner { get; set; } + [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; diff --git a/backend/Core/Musify.Domain/Entities/Artist.cs b/backend/Core/Musify.Domain/Entities/Artist.cs deleted file mode 100644 index bca0f98..0000000 --- a/backend/Core/Musify.Domain/Entities/Artist.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Musify.Domain.Abstractions; - -namespace Musify.Domain.Entities -{ -#pragma warning disable CS8618 - public class Artist : IAuditable - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); - - [Required] - [MaxLength(200)] - public required string Name { get; set; } - - [Required] - [MaxLength(200)] - public required string NormalizedName { get; set; } - - [Required] - [MaxLength(64)] - public required string ExternalId { get; set; } - - [Required] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - [Required] - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - - public ICollection TrackArtists { get; set; } = new List(); - } -} diff --git a/backend/Core/Musify.Domain/Entities/ExternalAlbum.cs b/backend/Core/Musify.Domain/Entities/ExternalAlbum.cs deleted file mode 100644 index 9609154..0000000 --- a/backend/Core/Musify.Domain/Entities/ExternalAlbum.cs +++ /dev/null @@ -1,18 +0,0 @@ -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; } - } -} diff --git a/backend/Core/Musify.Domain/Entities/ExternalTrack.cs b/backend/Core/Musify.Domain/Entities/ExternalTrack.cs deleted file mode 100644 index 623d05b..0000000 --- a/backend/Core/Musify.Domain/Entities/ExternalTrack.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Musify.Domain.ValueObjects; - -namespace Musify.Domain.Entities -{ - public class ExternalTrack : Track - { - [Required] - public required TrackSource Source { get; set; } - - [Required] - [MaxLength(64)] - public required string ExternalId { get; set; } - - public ICollection TrackArtists { get; set; } = new List(); - } -} diff --git a/backend/Core/Musify.Domain/Entities/LocalTrack.cs b/backend/Core/Musify.Domain/Entities/LocalTrack.cs deleted file mode 100644 index 9ae6847..0000000 --- a/backend/Core/Musify.Domain/Entities/LocalTrack.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Musify.Domain.Entities -{ - public class LocalTrack : Track - { - [Required] - public required long OwnerUserId { get; set; } - - [ForeignKey(nameof(OwnerUserId)) ] - public required User Owner { get; set; } - } -} diff --git a/backend/Core/Musify.Domain/Entities/MixItem.cs b/backend/Core/Musify.Domain/Entities/MixItem.cs index 6a151c2..2269e2d 100644 --- a/backend/Core/Musify.Domain/Entities/MixItem.cs +++ b/backend/Core/Musify.Domain/Entities/MixItem.cs @@ -1,8 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Musify.Domain.ValueObjects; - namespace Musify.Domain.Entities { #pragma warning disable CS8618 @@ -19,32 +17,12 @@ namespace Musify.Domain.Entities public int Position { get; set; } = 0; [Required] - public required MixItemSource Source { get; set; } - - public Guid? TrackId { get; set; } - - [MaxLength(64)] - public string? VideoId { get; set; } - - [MaxLength(300)] - public string? Title { get; set; } - - [MaxLength(300)] - public string? Artist { get; set; } - - [MaxLength(512)] - public string? ThumbnailUrl { get; set; } - - [Required] - public double DurationSeconds { get; set; } - - [Required] - public bool IsExplicit { get; set; } + public required Guid TrackId { get; set; } [ForeignKey(nameof(MixId))] public Mix Mix { get; set; } [ForeignKey(nameof(TrackId))] - public Track? Track { get; set; } + public Track Track { get; set; } } } diff --git a/backend/Core/Musify.Domain/Entities/Track.cs b/backend/Core/Musify.Domain/Entities/Track.cs index a7953a4..4d6a6d1 100644 --- a/backend/Core/Musify.Domain/Entities/Track.cs +++ b/backend/Core/Musify.Domain/Entities/Track.cs @@ -1,11 +1,12 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; namespace Musify.Domain.Entities { #pragma warning disable CS8618 - public abstract class Track : IAuditable + public class Track : IAuditable { [Key] public Guid Id { get; set; } = Guid.NewGuid(); @@ -21,6 +22,12 @@ namespace Musify.Domain.Entities [Required] public double DurationSeconds { get; set; } + [Required] + public required long OwnerUserId { get; set; } + + [ForeignKey(nameof(OwnerUserId))] + public required User Owner { get; set; } + [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; diff --git a/backend/Core/Musify.Domain/Entities/TrackArtist.cs b/backend/Core/Musify.Domain/Entities/TrackArtist.cs deleted file mode 100644 index c637f84..0000000 --- a/backend/Core/Musify.Domain/Entities/TrackArtist.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Musify.Domain.Entities -{ -#pragma warning disable CS8618 - [Table("TrackArtist")] - public class TrackArtist - { - [Required] - public required Guid TrackId { get; set; } - - [Required] - public required Guid ArtistId { get; set; } - - [Required] - public int Position { get; set; } = 0; - - [ForeignKey(nameof(TrackId)) ] - public ExternalTrack Track { get; set; } - - [ForeignKey(nameof(ArtistId)) ] - public Artist Artist { get; set; } - } -} diff --git a/backend/Core/Musify.Domain/Entities/UserAlbum.cs b/backend/Core/Musify.Domain/Entities/UserAlbum.cs deleted file mode 100644 index dbd5132..0000000 --- a/backend/Core/Musify.Domain/Entities/UserAlbum.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Musify.Domain.Entities -{ -#pragma warning disable CS8618 - public class UserAlbum : Album - { - [Required] - public required long OwnerUserId { get; set; } - - [ForeignKey(nameof(OwnerUserId)) ] - public User Owner { get; set; } - } -} diff --git a/backend/Core/Musify.Domain/ValueObjects/MixItemSource.cs b/backend/Core/Musify.Domain/ValueObjects/MixItemSource.cs deleted file mode 100644 index b143c8d..0000000 --- a/backend/Core/Musify.Domain/ValueObjects/MixItemSource.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Musify.Domain.ValueObjects -{ - public enum MixItemSource - { - Musify = 0, - YouTube = 1 - } -} diff --git a/backend/Core/Musify.Domain/ValueObjects/TrackSource.cs b/backend/Core/Musify.Domain/ValueObjects/TrackSource.cs deleted file mode 100644 index 1cafc94..0000000 --- a/backend/Core/Musify.Domain/ValueObjects/TrackSource.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Musify.Domain.ValueObjects -{ - public enum TrackSource - { - Local = 0, - YouTube = 1 - } -} diff --git a/backend/Core/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs deleted file mode 100644 index fbc66a5..0000000 --- a/backend/Core/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Musify.Infrastructure.Configuration -{ - public class YouTubeConfiguration - { - 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"; - - [Range(60, 21600)] - public int StreamUrlCacheSeconds { get; set; } = 1800; - - [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; - - [Range(120, 1080)] - public int ArtworkSize { get; set; } = 544; - } -} diff --git a/backend/Core/Musify.Infrastructure/Configuration/YtDlpConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/YtDlpConfiguration.cs deleted file mode 100644 index 2f9c597..0000000 --- a/backend/Core/Musify.Infrastructure/Configuration/YtDlpConfiguration.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Musify.Infrastructure.Configuration -{ - public class YtDlpConfiguration - { - public const string SectionName = "YtDlp"; - - [Required] - public string ExecutableName { get; set; } = "yt-dlp"; - - [Required] - public string Format { get; set; } = "bestaudio"; - - public string AdditionalArguments { get; set; } = string.Empty; - - [Required] - public TimeSpan DownloadTimeout { get; set; } = TimeSpan.FromMinutes(5); - } -} diff --git a/backend/Core/Musify.Infrastructure/Helpers/YouTubeThumbNailHelper.cs b/backend/Core/Musify.Infrastructure/Helpers/YouTubeThumbNailHelper.cs deleted file mode 100644 index 8203545..0000000 --- a/backend/Core/Musify.Infrastructure/Helpers/YouTubeThumbNailHelper.cs +++ /dev/null @@ -1,55 +0,0 @@ -namespace Musify.Infrastructure.Helpers -{ - public static class YouTubeThumbNailHelper - { - 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 suffix) => - suffix.Length > 1 && (suffix[0] is 'w' or 's' or 'h') && char.IsDigit(suffix[1] ); - } -} diff --git a/backend/Core/Musify.Infrastructure/Infrastructure.csproj b/backend/Core/Musify.Infrastructure/Infrastructure.csproj index f5e3b78..5b85a9b 100644 --- a/backend/Core/Musify.Infrastructure/Infrastructure.csproj +++ b/backend/Core/Musify.Infrastructure/Infrastructure.csproj @@ -33,7 +33,6 @@ - diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/DownloadYouTubeAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/DownloadYouTubeAudioActivity.cs deleted file mode 100644 index 2badb33..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/DownloadYouTubeAudioActivity.cs +++ /dev/null @@ -1,94 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Logs; - -namespace Musify.Infrastructure.MassTransit.Activities.Audio -{ - internal class DownloadYouTubeAudioActivity( - IDatabase database, - IYouTubeDownloaderService youTubeDownloader, - ILogger logger) - : IActivity - { - public const string ExecuteEndpointName = "download-you-tube-audio"; - - public async Task Execute(ExecuteContext executeContext) - { - var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); - ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath)); - - Track track; - try - { - var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - track = getTrack ?? throw new InvalidOperationException($"Track with Id {executeContext.Arguments.TrackId} not found"); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to retrieve track with Id {TrackId} from database", executeContext.Arguments.TrackId); - throw; - } - - try - { - var downloadResult = await youTubeDownloader.DownloadAudioAsync( - executeContext.Arguments.VideoId, - destinationPath, - executeContext.CancellationToken); - - if (downloadResult.ExitCode != 0) - throw new InvalidOperationException( - $"YouTube audio download failed: {downloadResult.StandardError}"); - - if (!File.Exists(destinationPath)) - throw new InvalidOperationException($"YouTube audio download did not produce the expected file {destinationPath}."); - - logger.LogInformation("Downloaded YouTube audio for video {VideoId} to {DestinationPath}", - executeContext.Arguments.VideoId, destinationPath); - - return executeContext.Completed(new DownloadYouTubeAudioLog( - executeContext.Arguments.VideoId, - destinationPath)); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to download YouTube audio for video {VideoId}", - executeContext.Arguments.VideoId); - - try - { - track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(executeContext.CancellationToken); - } - catch (Exception dbException) - { - logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", - executeContext.Arguments.TrackId); - } - - throw; - } - } - - public Task Compensate(CompensateContext compensateContext) - { - try - { - if (File.Exists(compensateContext.Log.DestinationFilePath)) - File.Delete(compensateContext.Log.DestinationFilePath); - return Task.FromResult(compensateContext.Compensated()); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to compensate downloaded YouTube audio {DestinationFilePath}", - compensateContext.Log.DestinationFilePath); - return Task.FromResult(compensateContext.Failed(exception)); - } - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadYouTubeAudioArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadYouTubeAudioArguments.cs deleted file mode 100644 index abe62bb..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadYouTubeAudioArguments.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record DownloadYouTubeAudioArguments( - Guid TrackId, - string VideoId, - string DestinationFilePathVariable); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DownloadYouTubeTrackConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DownloadYouTubeTrackConsumer.cs deleted file mode 100644 index f724e7c..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DownloadYouTubeTrackConsumer.cs +++ /dev/null @@ -1,26 +0,0 @@ -using MassTransit; -using Musify.Application.Events; -using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -namespace Musify.Infrastructure.MassTransit.Consumers -{ - public class DownloadYouTubeTrackConsumer( - IBus bus, - YouTubeTrackRoutingSlipBuilder routingSlipBuilder) - : IConsumer - { - public async Task Consume(ConsumeContext consumeContext) - { - var audioRoutingSlip = routingSlipBuilder - .BuildAudioWorkflow(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - var pictureRoutingSlip = routingSlipBuilder - .BuildPictureWorkflow(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(audioRoutingSlip); - await bus.Execute(pictureRoutingSlip); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs index 3ec84ad..cae468b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -25,7 +25,6 @@ namespace Musify.Infrastructure.MassTransit services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); } static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options) @@ -42,7 +41,6 @@ namespace Musify.Infrastructure.MassTransit options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); - options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); @@ -73,7 +71,6 @@ namespace Musify.Infrastructure.MassTransit options.AddActivity(); options.AddActivity(); options.AddActivity(); - options.AddActivity(); options.AddActivity(); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadYouTubeAudioLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadYouTubeAudioLog.cs deleted file mode 100644 index 2623e8b..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadYouTubeAudioLog.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Logs -{ - public record DownloadYouTubeAudioLog( - string VideoId, - string DestinationFilePath); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs index 58ec272..544388c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs @@ -14,7 +14,6 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; internal const string DownloadFile = "DownloadFile"; - internal const string DownloadYouTubeAudio = "DownloadYouTubeAudio"; internal const string DownloadThumbnail = "DownloadThumbnail"; internal const string TranscodeAudio = "TranscodeAudio"; diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/YouTubeTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/YouTubeTrackRoutingSlipBuilder.cs deleted file mode 100644 index 91c0f0c..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/YouTubeTrackRoutingSlipBuilder.cs +++ /dev/null @@ -1,166 +0,0 @@ -using MassTransit; -using MassTransit.Courier.Contracts; -using Musify.Application.Events; -using Musify.Application.Shared; -using Musify.Infrastructure.Configuration; -using Musify.Infrastructure.MassTransit.Activities.Audio; -using Musify.Infrastructure.MassTransit.Activities.Files; -using Musify.Infrastructure.MassTransit.Activities.Pictures; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Consumers; - -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders -{ - public class YouTubeTrackRoutingSlipBuilder(WorkerConfiguration workerConfiguration) - { - public RoutingSlipBuilder BuildAudioWorkflow(DownloadYouTubeTrackEvent message, Guid? correlationId) - { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); - - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - - routingSlipBuilder.AddActivity( - ActivityNames.GenerateAudioWorkflowPaths, - EndpointHelper.BuildExecuteActivityUri(GenerateAudioWorkflowPathsActivity.ExecuteEndpointName), - new GenerateAudioWorkflowPathsArguments( - message.TrackId, - workerConfiguration.Routes.TemporaryFilesDirectory, - $"{message.VideoId}.m4a")); - - routingSlipBuilder.AddActivity( - ActivityNames.DownloadYouTubeAudio, - EndpointHelper.BuildExecuteActivityUri(DownloadYouTubeAudioActivity.ExecuteEndpointName), - new DownloadYouTubeAudioArguments( - message.TrackId, - message.VideoId, - RoutingSlipVariableNames.Audio.SourceFilePath)); - - routingSlipBuilder.AddActivity( - ActivityNames.TranscodeAudio, - EndpointHelper.BuildExecuteActivityUri(TranscodeAudioActivity.ExecuteEndpointName), - new TranscodeAudioArguments( - message.TrackId, - RoutingSlipVariableNames.Audio.SourceFilePath, - RoutingSlipVariableNames.Workflow.TemporalDirectory)); - - routingSlipBuilder.AddActivity( - ActivityNames.TransferFiles, - EndpointHelper.BuildExecuteActivityUri(TransferFilesToBucketActivity.ExecuteEndpointName), - new TransferFilesToBucketArguments( - message.Bucket, - RoutingSlipVariableNames.Audio.TranscodedDirectory, - message.AudioProcessedFolderKey)); - - routingSlipBuilder.AddActivity( - ActivityNames.UpdateTrackAudio, - EndpointHelper.BuildExecuteActivityUri(UpdateTrackAudioActivity.ExecuteEndpointName), - new UpdateTrackAudioArguments( - message.TrackId, - message.AudioProcessedFolderKey, - RoutingSlipVariableNames.Audio.DurationSeconds)); - - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackAudio); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); - - return routingSlipBuilder; - } - - public RoutingSlipBuilder BuildPictureWorkflow(DownloadYouTubeTrackEvent message, Guid? correlationId) - { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); - - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - - routingSlipBuilder.AddActivity( - ActivityNames.GeneratePictureWorkflowPaths, - EndpointHelper.BuildExecuteActivityUri(GeneratePictureWorkflowPathsActivity.ExecuteEndpointName), - new GeneratePictureWorkflowPathsArguments( - workerConfiguration.Routes.TemporaryFilesDirectory, - $"{message.VideoId}.jpg")); - - routingSlipBuilder.AddActivity( - ActivityNames.DownloadThumbnail, - EndpointHelper.BuildExecuteActivityUri(DownloadFileFromUrlActivity.ExecuteEndpointName), - new DownloadFileFromUrlArguments( - message.ThumbnailUrl, - RoutingSlipVariableNames.Picture.OriginalFilePath)); - - AddResizeActivity( - routingSlipBuilder, - ActivityNames.ResizeSmall, - ActivityNames.UploadSmall, - message.Bucket, - RoutingSlipVariableNames.Picture.SmallResizedFilePath, - message.Small); - - AddResizeActivity( - routingSlipBuilder, - ActivityNames.ResizeMedium, - ActivityNames.UploadMedium, - message.Bucket, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - message.Medium); - - AddResizeActivity( - routingSlipBuilder, - ActivityNames.ResizeLarge, - ActivityNames.UploadLarge, - message.Bucket, - RoutingSlipVariableNames.Picture.LargeResizedFilePath, - message.Large); - - routingSlipBuilder.AddActivity( - ActivityNames.UpdateTrackPicture, - EndpointHelper.BuildExecuteActivityUri(UpdateTrackPictureActivity.ExecuteEndpointName), - new UpdateTrackPictureArguments( - message.TrackId, - string.Empty, - RoutingSlipVariableNames.Picture.SmallResizedFilePath, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - RoutingSlipVariableNames.Picture.LargeResizedFilePath)); - - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackPicture); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); - - return routingSlipBuilder; - } - - static void AddResizeActivity( - RoutingSlipBuilder routingSlipBuilder, - string resizeActivityName, - string uploadActivityName, - string bucketName, - string destinationFilePathVariableName, - ImageSize imageSize) - { - routingSlipBuilder.AddActivity( - resizeActivityName, - EndpointHelper.BuildExecuteActivityUri(ResizePictureActivity.ExecuteEndpointName), - new ResizePictureLocalArguments( - RoutingSlipVariableNames.Picture.OriginalFilePath, - destinationFilePathVariableName, - imageSize.Width, - imageSize.Height)); - - routingSlipBuilder.AddActivity( - uploadActivityName, - EndpointHelper.BuildExecuteActivityUri(UploadFileToBucketActivity.ExecuteEndpointName), - new UploadFileToBucketArguments( - destinationFilePathVariableName, - bucketName, - imageSize.SavePath)); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs index 7cd8c47..9b157fb 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs @@ -9,7 +9,6 @@ namespace Musify.Infrastructure.MassTransit.Sagas public State Failed { get; private set; } = null!; public Event ProcessingStarted { get; private set; } = null!; - public Event YouTubeDownloadStarted { get; private set; } = null!; public Event PictureProcessed { get; private set; } = null!; public Event AudioProcessed { get; private set; } = null!; public Event PictureFailed { get; private set; } = null!; @@ -20,7 +19,6 @@ namespace Musify.Infrastructure.MassTransit.Sagas InstanceState(state => state.CurrentState); Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.TrackId)); - Event(() => YouTubeDownloadStarted, config => config.CorrelateById(context => context.Message.TrackId)); Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.TrackId)); Event(() => AudioProcessed, config => config.CorrelateById(context => context.Message.TrackId)); Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.TrackId)); @@ -36,20 +34,9 @@ namespace Musify.Infrastructure.MassTransit.Sagas context.Saga.PictureKey = context.Message.PictureDestinationKey; context.Saga.AudioKey = context.Message.AudioDestinationKey; } ) - .TransitionTo(Processing), - When(YouTubeDownloadStarted) - .Then(context => - { - context.Saga.CreatedAt = DateTime.UtcNow; - context.Saga.UpdatedAt = DateTime.UtcNow; - context.Saga.Bucket = context.Message.Bucket; - context.Saga.PictureKey = string.Empty; - context.Saga.AudioKey = string.Empty; - } ) .TransitionTo(Processing)); During(Processing, - Ignore(YouTubeDownloadStarted), When(PictureProcessed) .Then(context => { @@ -85,18 +72,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas Ignore(PictureProcessed), Ignore(AudioProcessed), Ignore(PictureFailed), - Ignore(AudioFailed), - When(YouTubeDownloadStarted) - .Then(context => - { - context.Saga.UpdatedAt = DateTime.UtcNow; - context.Saga.PictureProcessed = false; - context.Saga.AudioProcessed = false; - context.Saga.Bucket = context.Message.Bucket; - context.Saga.PictureKey = string.Empty; - context.Saga.AudioKey = string.Empty; - } ) - .TransitionTo(Processing)); + Ignore(AudioFailed)); SetCompletedWhenFinalized(); } diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 607bb59..229e156 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -54,41 +54,12 @@ namespace Musify.Infrastructure.Persistence }); modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); - modelBuilder.Entity() - .ToTable("LocalTracks"); - - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(track => track.Owner) .WithMany() .HasForeignKey(track => track.OwnerUserId) .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .ToTable("ExternalTracks"); - - modelBuilder.Entity() - .HasIndex(track => new { track.Source, track.ExternalId }) - .IsUnique(); - - modelBuilder.Entity() - .HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId }); - - modelBuilder.Entity() - .HasOne(trackArtist => trackArtist.Track) - .WithMany(track => track.TrackArtists) - .HasForeignKey(trackArtist => trackArtist.TrackId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(trackArtist => trackArtist.Artist) - .WithMany(artist => artist.TrackArtists) - .HasForeignKey(trackArtist => trackArtist.ArtistId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasIndex(artist => artist.ExternalId) - .IsUnique(); - modelBuilder.Entity() .Property(playList => playList.Visibility) .HasDefaultValue(PlaylistVisibility.Private); @@ -113,22 +84,12 @@ namespace Musify.Infrastructure.Persistence }); modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); - modelBuilder.Entity() - .ToTable("UserAlbums"); - - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(album => album.Owner) .WithMany() .HasForeignKey(album => album.OwnerUserId) .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .ToTable("ExternalAlbums"); - - modelBuilder.Entity() - .HasIndex(album => new { album.Source, album.ExternalId }) - .IsUnique(); - modelBuilder.Entity() .HasOne(albumTrack => albumTrack.Album) .WithMany(album => album.AlbumTracks) @@ -158,7 +119,6 @@ namespace Musify.Infrastructure.Persistence .HasOne(item => item.Track) .WithMany() .HasForeignKey(item => item.TrackId) - .IsRequired(false) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() @@ -199,22 +159,10 @@ namespace Musify.Infrastructure.Persistence public DbSet Tracks => Set(); - public DbSet LocalTracks => Set(); - - public DbSet ExternalTracks => Set(); - - public DbSet Artists => Set(); - - public DbSet TrackArtists => Set(); - public DbSet PlayLists => Set(); public DbSet Albums => Set(); - public DbSet UserAlbums => Set(); - - public DbSet ExternalAlbums => Set(); - public DbSet AlbumHasTracks => Set(); public DbSet UserHasTracks => Set(); diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.Designer.cs new file mode 100644 index 0000000..7fb80d2 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.Designer.cs @@ -0,0 +1,1133 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260915165436_RemoveYouTubeIntegration")] + partial class RemoveYouTubeIntegration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("DownloadRequested") + .HasColumnType("boolean") + .HasColumnName("DownloadRequested"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("LastRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastRetryAt"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("RetryCount"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.cs new file mode 100644 index 0000000..e5c3c54 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.cs @@ -0,0 +1,381 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class RemoveYouTubeIntegration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Data cleanup first, while the External*/Local*/UserAlbums tables (and the loose + // MixItem columns) still exist to query/cascade from. Deleting the base Tracks/Albums + // rows for anything that only ever existed as an ExternalTrack/ExternalAlbum cascades + // (FKs are ON DELETE CASCADE) to their own ExternalTracks/ExternalAlbums row, TrackArtist, + // PlayListHasTrack, AlbumHasTrack, TrackLike, ListeningHistory, UserHasTrack and MixItems + // rows that referenced them — this is deliberately lossy: those rows only existed to back + // the YouTube integration being removed here. + migrationBuilder.Sql("""DELETE FROM "Tracks" WHERE "Id" IN (SELECT "Id" FROM "ExternalTracks");"""); + migrationBuilder.Sql("""DELETE FROM "Albums" WHERE "Id" IN (SELECT "Id" FROM "ExternalAlbums");"""); + + // Live YouTube search hits that were seeded into a mix but never materialized into a + // real track (MixItemSource.YouTube, no TrackId) — MixItem.TrackId is about to become + // required, so these rows can't be kept. + migrationBuilder.Sql("""DELETE FROM "MixItems" WHERE "TrackId" IS NULL;"""); + + migrationBuilder.AddColumn( + name: "OwnerUserId", + table: "Tracks", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.Sql(""" + UPDATE "Tracks" t SET "OwnerUserId" = lt."OwnerUserId" + FROM "LocalTracks" lt WHERE lt."Id" = t."Id"; + """); + + migrationBuilder.AddColumn( + name: "OwnerUserId", + table: "Albums", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.Sql(""" + UPDATE "Albums" a SET "OwnerUserId" = ua."OwnerUserId" + FROM "UserAlbums" ua WHERE ua."Id" = a."Id"; + """); + + migrationBuilder.AlterColumn( + name: "TrackId", + table: "MixItems", + type: "uuid", + nullable: false, + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.DropColumn( + name: "Artist", + table: "MixItems"); + + migrationBuilder.DropColumn( + name: "DurationSeconds", + table: "MixItems"); + + migrationBuilder.DropColumn( + name: "IsExplicit", + table: "MixItems"); + + migrationBuilder.DropColumn( + name: "Source", + table: "MixItems"); + + migrationBuilder.DropColumn( + name: "ThumbnailUrl", + table: "MixItems"); + + migrationBuilder.DropColumn( + name: "Title", + table: "MixItems"); + + migrationBuilder.DropColumn( + name: "VideoId", + table: "MixItems"); + + migrationBuilder.DropTable( + name: "TrackArtist"); + + migrationBuilder.DropTable( + name: "ExternalTracks"); + + migrationBuilder.DropTable( + name: "LocalTracks"); + + migrationBuilder.DropTable( + name: "ExternalAlbums"); + + migrationBuilder.DropTable( + name: "UserAlbums"); + + migrationBuilder.DropTable( + name: "Artists"); + + migrationBuilder.CreateIndex( + name: "IX_Tracks_OwnerUserId", + table: "Tracks", + column: "OwnerUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Albums_OwnerUserId", + table: "Albums", + column: "OwnerUserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Albums_User_OwnerUserId", + table: "Albums", + column: "OwnerUserId", + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Tracks_User_OwnerUserId", + table: "Tracks", + column: "OwnerUserId", + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Albums_User_OwnerUserId", + table: "Albums"); + + migrationBuilder.DropForeignKey( + name: "FK_Tracks_User_OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropIndex( + name: "IX_Tracks_OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropIndex( + name: "IX_Albums_OwnerUserId", + table: "Albums"); + + migrationBuilder.DropColumn( + name: "OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "OwnerUserId", + table: "Albums"); + + migrationBuilder.AlterColumn( + name: "TrackId", + table: "MixItems", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AddColumn( + name: "Artist", + table: "MixItems", + type: "character varying(300)", + maxLength: 300, + nullable: true); + + migrationBuilder.AddColumn( + name: "DurationSeconds", + table: "MixItems", + type: "double precision", + nullable: false, + defaultValue: 0.0); + + migrationBuilder.AddColumn( + name: "IsExplicit", + table: "MixItems", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Source", + table: "MixItems", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ThumbnailUrl", + table: "MixItems", + type: "character varying(512)", + maxLength: 512, + nullable: true); + + migrationBuilder.AddColumn( + name: "Title", + table: "MixItems", + type: "character varying(300)", + maxLength: 300, + nullable: true); + + migrationBuilder.AddColumn( + name: "VideoId", + table: "MixItems", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.CreateTable( + name: "Artists", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExternalId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + NormalizedName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Artists", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ExternalAlbums", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ExternalId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Source = table.Column(type: "integer", nullable: false), + ThumbnailUrl = table.Column(type: "character varying(512)", maxLength: 512, nullable: true) + }, + 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.CreateTable( + name: "ExternalTracks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ExternalId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Source = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExternalTracks", x => x.Id); + table.ForeignKey( + name: "FK_ExternalTracks_Tracks_Id", + column: x => x.Id, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "LocalTracks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OwnerUserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LocalTracks", x => x.Id); + table.ForeignKey( + name: "FK_LocalTracks_Tracks_Id", + column: x => x.Id, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_LocalTracks_User_OwnerUserId", + column: x => x.OwnerUserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "UserAlbums", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OwnerUserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserAlbums", x => x.Id); + table.ForeignKey( + name: "FK_UserAlbums_Albums_Id", + column: x => x.Id, + principalTable: "Albums", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserAlbums_User_OwnerUserId", + column: x => x.OwnerUserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "TrackArtist", + columns: table => new + { + TrackId = table.Column(type: "uuid", nullable: false), + ArtistId = table.Column(type: "uuid", nullable: false), + Position = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrackArtist", x => new { x.TrackId, x.ArtistId }); + table.ForeignKey( + name: "FK_TrackArtist_Artists_ArtistId", + column: x => x.ArtistId, + principalTable: "Artists", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TrackArtist_ExternalTracks_TrackId", + column: x => x.TrackId, + principalTable: "ExternalTracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Artists_ExternalId", + table: "Artists", + column: "ExternalId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ExternalAlbums_Source_ExternalId", + table: "ExternalAlbums", + columns: new[] { "Source", "ExternalId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ExternalTracks_Source_ExternalId", + table: "ExternalTracks", + columns: new[] { "Source", "ExternalId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_LocalTracks_OwnerUserId", + table: "LocalTracks", + column: "OwnerUserId"); + + migrationBuilder.CreateIndex( + name: "IX_TrackArtist_ArtistId", + table: "TrackArtist", + column: "ArtistId"); + + migrationBuilder.CreateIndex( + name: "IX_UserAlbums_OwnerUserId", + table: "UserAlbums", + column: "OwnerUserId"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 001debd..9c9c158 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -211,6 +211,9 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("OwnerUserId") + .HasColumnType("bigint"); + b.Property("ReleaseYear") .HasColumnType("integer"); @@ -224,9 +227,9 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("Id"); - b.ToTable("Albums"); + b.HasIndex("OwnerUserId"); - b.UseTptMappingStrategy(); + b.ToTable("Albums"); }); modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => @@ -257,41 +260,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("AlbumHasTrack"); }); - modelBuilder.Entity("Musify.Domain.Entities.Artist", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExternalId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("NormalizedName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("ExternalId") - .IsUnique(); - - b.ToTable("Artists"); - }); - modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => { b.Property("Id") @@ -356,40 +324,15 @@ namespace Musify.Infrastructure.Persistence.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("Artist") - .HasMaxLength(300) - .HasColumnType("character varying(300)"); - - b.Property("DurationSeconds") - .HasColumnType("double precision"); - - b.Property("IsExplicit") - .HasColumnType("boolean"); - b.Property("MixId") .HasColumnType("uuid"); b.Property("Position") .HasColumnType("integer"); - b.Property("Source") - .HasColumnType("integer"); - - b.Property("ThumbnailUrl") - .HasMaxLength(512) - .HasColumnType("character varying(512)"); - - b.Property("Title") - .HasMaxLength(300) - .HasColumnType("character varying(300)"); - - b.Property("TrackId") + b.Property("TrackId") .HasColumnType("uuid"); - b.Property("VideoId") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - b.HasKey("Id"); b.HasIndex("TrackId"); @@ -490,6 +433,9 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("OwnerUserId") + .HasColumnType("bigint"); + b.Property("Title") .IsRequired() .HasMaxLength(200) @@ -500,27 +446,9 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("Id"); + b.HasIndex("OwnerUserId"); + b.ToTable("Tracks"); - - b.UseTptMappingStrategy(); - }); - - modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => - { - b.Property("TrackId") - .HasColumnType("uuid"); - - b.Property("ArtistId") - .HasColumnType("uuid"); - - b.Property("Position") - .HasColumnType("integer"); - - b.HasKey("TrackId", "ArtistId"); - - b.HasIndex("ArtistId"); - - b.ToTable("TrackArtist"); }); modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => @@ -802,70 +730,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("TrackProcessingState"); }); - modelBuilder.Entity("Musify.Domain.Entities.ExternalAlbum", b => - { - b.HasBaseType("Musify.Domain.Entities.Album"); - - b.Property("ExternalId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("Source") - .HasColumnType("integer"); - - b.Property("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"); - - b.Property("OwnerUserId") - .HasColumnType("bigint"); - - b.HasIndex("OwnerUserId"); - - b.ToTable("UserAlbums", (string)null); - }); - - modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => - { - b.HasBaseType("Musify.Domain.Entities.Track"); - - b.Property("ExternalId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("Source") - .HasColumnType("integer"); - - b.HasIndex("Source", "ExternalId") - .IsUnique(); - - b.ToTable("ExternalTracks", (string)null); - }); - - modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => - { - b.HasBaseType("Musify.Domain.Entities.Track"); - - b.Property("OwnerUserId") - .HasColumnType("bigint"); - - b.HasIndex("OwnerUserId"); - - b.ToTable("LocalTracks", (string)null); - }); - modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => { b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) @@ -880,6 +744,12 @@ namespace Musify.Infrastructure.Persistence.Migrations modelBuilder.Entity("Musify.Domain.Entities.Album", b => { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => { b1.Property("AlbumId") @@ -917,6 +787,8 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasForeignKey("AlbumId"); }); + b.Navigation("Owner"); + b.Navigation("Pictures"); }); @@ -980,7 +852,8 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasOne("Musify.Domain.Entities.Track", "Track") .WithMany() .HasForeignKey("TrackId") - .OnDelete(DeleteBehavior.Cascade); + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); b.Navigation("Mix"); @@ -1058,6 +931,12 @@ namespace Musify.Infrastructure.Persistence.Migrations modelBuilder.Entity("Musify.Domain.Entities.Track", b => { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => { b1.Property("TrackId") @@ -1137,29 +1016,12 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("Audio") .IsRequired(); + b.Navigation("Owner"); + b.Navigation("Pictures") .IsRequired(); }); - modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => - { - b.HasOne("Musify.Domain.Entities.Artist", "Artist") - .WithMany("TrackArtists") - .HasForeignKey("ArtistId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Musify.Domain.Entities.ExternalTrack", "Track") - .WithMany("TrackArtists") - .HasForeignKey("TrackId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Artist"); - - b.Navigation("Track"); - }); - modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => { b.HasOne("Musify.Domain.Entities.Track", "Track") @@ -1228,68 +1090,11 @@ 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) - .WithOne() - .HasForeignKey("Musify.Domain.Entities.UserAlbum", "Id") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Musify.Domain.Entities.User", "Owner") - .WithMany() - .HasForeignKey("OwnerUserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Owner"); - }); - - modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => - { - b.HasOne("Musify.Domain.Entities.Track", null) - .WithOne() - .HasForeignKey("Musify.Domain.Entities.ExternalTrack", "Id") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => - { - b.HasOne("Musify.Domain.Entities.Track", null) - .WithOne() - .HasForeignKey("Musify.Domain.Entities.LocalTrack", "Id") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Musify.Domain.Entities.User", "Owner") - .WithMany() - .HasForeignKey("OwnerUserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Owner"); - }); - modelBuilder.Entity("Musify.Domain.Entities.Album", b => { b.Navigation("AlbumTracks"); }); - modelBuilder.Entity("Musify.Domain.Entities.Artist", b => - { - b.Navigation("TrackArtists"); - }); - modelBuilder.Entity("Musify.Domain.Entities.Mix", b => { b.Navigation("Items"); @@ -1319,11 +1124,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("UserTracks"); }); - - modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => - { - b.Navigation("TrackArtists"); - }); #pragma warning restore 612, 618 } } diff --git a/backend/Core/Musify.Infrastructure/Services/DisabledYouTubeMusicService.cs b/backend/Core/Musify.Infrastructure/Services/DisabledYouTubeMusicService.cs deleted file mode 100644 index 783bc0d..0000000 --- a/backend/Core/Musify.Infrastructure/Services/DisabledYouTubeMusicService.cs +++ /dev/null @@ -1,37 +0,0 @@ -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 641b45d..6151b16 100644 --- a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -1,7 +1,6 @@ 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; @@ -99,33 +98,6 @@ namespace Musify.Infrastructure.Services return serviceDescriptors; } - public static IServiceCollection AddYouTubeMusicService(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors.AddValidatedOptions(configuration, YouTubeConfiguration.SectionName); - - serviceDescriptors.AddMemoryCache(); - serviceDescriptors.AddSingleton(serviceProvider => - { - var youTubeConfiguration = serviceProvider.GetRequiredService(); - if (!youTubeConfiguration.Enabled) - return new DisabledYouTubeMusicService(); - - return new YouTubeMusicService( - youTubeConfiguration, - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService>()); - } ); - return serviceDescriptors; - } - - public static IServiceCollection AddYouTubeDownloader(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors.AddValidatedOptions(configuration, YtDlpConfiguration.SectionName); - - serviceDescriptors.AddScoped(); - return serviceDescriptors; - } - public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration) { serviceDescriptors diff --git a/backend/Core/Musify.Infrastructure/Services/YouTubeDownloaderService.cs b/backend/Core/Musify.Infrastructure/Services/YouTubeDownloaderService.cs deleted file mode 100644 index ea1c70f..0000000 --- a/backend/Core/Musify.Infrastructure/Services/YouTubeDownloaderService.cs +++ /dev/null @@ -1,89 +0,0 @@ -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Infrastructure.Configuration; -using System.Diagnostics; - -namespace Musify.Infrastructure.Services -{ - public class YouTubeDownloaderService( - ILogger logger, - YtDlpConfiguration configuration) - : IYouTubeDownloaderService - { - public async Task DownloadAudioAsync(string videoId, string destinationFilePath, CancellationToken cancellationToken) - { - var destinationDirectory = Path.GetDirectoryName(destinationFilePath); - if (!string.IsNullOrWhiteSpace(destinationDirectory)) - Directory.CreateDirectory(destinationDirectory); - - var additionalArguments = string.IsNullOrWhiteSpace(configuration.AdditionalArguments) - ? string.Empty - : $" {configuration.AdditionalArguments.Trim()}"; - - var arguments = - $"-f {configuration.Format} " + - "--no-playlist " + - $"-o \"{destinationFilePath}\"" + - additionalArguments + - $" -- {videoId}"; - - using var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = configuration.ExecutableName, - WorkingDirectory = destinationDirectory ?? Environment.CurrentDirectory, - Arguments = arguments, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - } - }; - - process.Start(); - - logger.LogInformation("yt-dlp started (PID: {ProcessId} ) for video {VideoId}", process.Id, videoId); - - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - linkedCancellationTokens.CancelAfter(configuration.DownloadTimeout); - - try - { - await process.WaitForExitAsync(linkedCancellationTokens.Token); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - logger.LogError("yt-dlp timed out after {Timeout} for video {VideoId} (PID: {ProcessId} )", - configuration.DownloadTimeout, videoId, process.Id); - - TryKillProcess(process); - throw new TimeoutException($"yt-dlp execution exceeded timeout {configuration.DownloadTimeout}."); - } - - var standardError = await errorTask; - var standardOutput = await outputTask; - - logger.LogDebug("yt-dlp finished (PID: {ProcessId} ). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B", - process.Id, process.ExitCode, standardOutput.Length, standardError.Length); - - return new YouTubeDownloadResult(process.ExitCode, standardError); - } - - void TryKillProcess(Process process) - { - try - { - if (!process.HasExited) - process.Kill(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to kill yt-dlp process (PID: {ProcessId} )", process.Id); - } - } - } -} diff --git a/backend/Core/Musify.Infrastructure/Services/YouTubeMusicService.cs b/backend/Core/Musify.Infrastructure/Services/YouTubeMusicService.cs deleted file mode 100644 index 3444a7f..0000000 --- a/backend/Core/Musify.Infrastructure/Services/YouTubeMusicService.cs +++ /dev/null @@ -1,310 +0,0 @@ -using ErrorOr; -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; -using YouTubeMusicAPI.Pagination; - -namespace Musify.Infrastructure.Services -{ - public class YouTubeMusicService(YouTubeConfiguration configuration, IMemoryCache cache, ILogger logger) : IYouTubeMusicService - { - private readonly YouTubeMusicClient client = new YouTubeMusicClient(geographicalLocation: configuration.GeographicalLocation); - private readonly SingleFlightCache singleFlight = new SingleFlightCache(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 sealed record SearchPage(IReadOnlyList Results, string NextToken); - - private async Task> FetchPageAsync( - string query, - SearchCategory category, - string continuationToken, - CancellationToken cancellationToken) - { - PaginatedAsyncEnumerable? paginator; - if (string.IsNullOrEmpty(continuationToken)) - { - paginator = client.SearchAsync(query, category); - } - else if (!cache.TryGetValue(SearchCacheKey(continuationToken), out paginator) || paginator is null) - { - return Error.NotFound(description: "The search continuation has expired."); - } - - IReadOnlyList results; - try - { - results = await paginator.FetchNextPageAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "YouTube Music {Category} search failed for query '{Query}'", category, query); - return Error.Failure(description: $"YouTube Music search failed: {exception.Message}"); - } - - var nextToken = string.Empty; - if (paginator.HasMore) - { - nextToken = Guid.NewGuid().ToString("N"); - cache.Set( - SearchCacheKey(nextToken), - paginator, - TimeSpan.FromSeconds(configuration.SearchContinuationCacheSeconds)); - } - - return new SearchPage(results, nextToken); - } - - private async Task> FetchSearchPageAsync(string query, string continuationToken, CancellationToken cancellationToken) - { - var page = await FetchPageAsync(query, SearchCategory.Songs, continuationToken, cancellationToken); - if (page.IsError) - return page.Errors; - - var items = page.Value.Results - .OfType() - .Select(song => new YouTubeSongResult( - song.Id, - song.Name, - string.Join(", ", song.Artists.Select(artist => artist.Name)), - song.Album?.Name ?? string.Empty, - (int)song.Duration.TotalSeconds, - PickThumbnail(song.Thumbnails), - song.IsExplicit, - song.Artists - .Select(artist => new YouTubeArtistRef( - string.IsNullOrEmpty(artist.Id)? null: artist.Id, - artist.Name)) - .ToList())) - .ToList(); - - return new YouTubeSearchResult(items, page.Value.NextToken); - } - - public async Task> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken) - { - if (!string.IsNullOrEmpty(continuationToken)) - return await FetchAlbumSearchPageAsync(query, continuationToken, cancellationToken); - - return await singleFlight.GetOrCreateAsync( - AlbumSearchResultsCacheKey(query), - TimeSpan.FromSeconds(configuration.SearchResultsCacheSeconds), - () => FetchAlbumSearchPageAsync(query, continuationToken, cancellationToken)); - } - - private async Task> FetchAlbumSearchPageAsync(string query, string continuationToken, CancellationToken cancellationToken) - { - var page = await FetchPageAsync(query, SearchCategory.Albums, continuationToken, cancellationToken); - if (page.IsError) - return page.Errors; - - var items = page.Value.Results - .OfType() - .Select(ToAlbumResult) - .ToList(); - - return new YouTubeAlbumSearchResult(items, page.Value.NextToken); - } - - public async Task> GetAlbumAsync(string albumId, CancellationToken cancellationToken) - { - return await singleFlight.GetOrCreateAsync( - AlbumCacheKey(albumId), - TimeSpan.FromSeconds(configuration.SongInfoCacheSeconds), - () => FetchAlbumAsync(albumId, cancellationToken)); - } - - private async Task> FetchAlbumAsync(string albumId, CancellationToken cancellationToken) - { - try - { - var browseId = await client.GetAlbumBrowseIdAsync(albumId, cancellationToken); - var info = await client.GetAlbumInfoAsync(browseId, cancellationToken); - - var album = new YouTubeAlbumResult( - albumId, - info.Name, - string.Join(", ", info.Artists.Select(artist => artist.Name)), - PickThumbnail(info.Thumbnails), - info.ReleaseYear, - info.IsSingle, - info.IsEp, - info.Artists - .Select(artist => new YouTubeArtistRef( - string.IsNullOrEmpty(artist.Id)? null: artist.Id, - artist.Name)) - .ToList()); - - var tracks = info.Songs - .Select((song, index) => new YouTubeAlbumTrack( - song.Id ?? string.Empty, - song.Name, - (int)song.Duration.TotalSeconds, - song.SongNumber ?? index + 1, - song.IsExplicit)) - .Where(track => !string.IsNullOrEmpty(track.VideoId)) - .ToList(); - - return new YouTubeAlbumDetail( - album, - StripWikipediaAttribution(info.Description), - (int)info.Duration.TotalSeconds, - tracks); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to load YouTube album '{AlbumId}'", albumId); - return Error.Failure(description: $"Failed to load YouTube album '{albumId}': {exception.Message}"); - } - } - - private static string StripWikipediaAttribution(string? description) - { - if (string.IsNullOrEmpty(description)) - return string.Empty; - - var markerIndex = description.IndexOf("From Wikipedia", StringComparison.OrdinalIgnoreCase); - var cleaned = markerIndex >= 0 ? description[..markerIndex] : description; - return cleaned.TrimEnd(); - } - - private YouTubeAlbumResult ToAlbumResult(AlbumSearchResult album) => - new( - album.Id, - album.Name, - string.Join(", ", album.Artists.Select(artist => artist.Name)), - PickThumbnail(album.Thumbnails), - album.ReleaseYear, - album.IsSingle, - album.IsEp, - album.Artists - .Select(artist => new YouTubeArtistRef( - string.IsNullOrEmpty(artist.Id) ? null : artist.Id, - artist.Name)) - .ToList()); - - public async Task> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken) - { - var now = DateTime.UtcNow; - if (cache.TryGetValue(StreamCacheKey(videoId), out CachedStreamInfo? cached) - && cached != null - && cached.ExpiresAtUtc > now) - { - return new YouTubeStreamInfo(cached.Url, (int)(cached.ExpiresAtUtc - now).TotalSeconds); - } - - StreamingData streamingData; - try - { - streamingData = await client.GetStreamingDataAsync(videoId, cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to resolve YouTube stream for '{VideoId}'", videoId); - return Error.Failure(description: $"Failed to resolve YouTube stream for '{videoId}': {exception.Message}"); - } - - var audioStream = streamingData.StreamInfo - .OfType() - .OrderByDescending(stream => stream.Bitrate) - .FirstOrDefault(); - - if (audioStream is null || string.IsNullOrEmpty(audioStream.Url)) - return Error.NotFound(description: $"No audio stream available for '{videoId}'."); - - var expiresInSeconds = Math.Min( - configuration.StreamUrlCacheSeconds, - (int)streamingData.ExpiresIn.TotalSeconds); - - if (expiresInSeconds > 0) - { - cache.Set( - StreamCacheKey(videoId), - new CachedStreamInfo(audioStream.Url, now.AddSeconds(expiresInSeconds)), - TimeSpan.FromSeconds(expiresInSeconds)); - } - - return new YouTubeStreamInfo(audioStream.Url, expiresInSeconds); - } - - 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 - { - var info = await client.GetSongVideoInfoAsync(videoId, cancellationToken); - - return new YouTubeSongResult( - videoId, - info.Name, - string.Join(", ", info.Artists.Select(artist => artist.Name)), - info.Album?.Name ?? string.Empty, - (int)info.Duration.TotalSeconds, - PickThumbnail(info.Thumbnails), - info.IsExplicit, - info.Artists - .Select(artist => new YouTubeArtistRef( - string.IsNullOrEmpty(artist.Id)? null: artist.Id, - artist.Name)) - .ToList()); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to load YouTube song info for '{VideoId}'", videoId); - return Error.Failure(description: $"Failed to load YouTube song info for '{videoId}': {exception.Message}"); - } - } - - public string ResolveArtworkUrl(string thumbnailUrl) => - YouTubeThumbNailHelper.WithSize(thumbnailUrl, configuration.ArtworkSize); - - private string PickThumbnail(IEnumerable thumbnails) - { - var ordered = thumbnails.OrderBy(thumbnail => thumbnail.Width).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) - ?? candidates.LastOrDefault(); - - return chosen ==null? string.Empty - : YouTubeThumbNailHelper.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 AlbumSearchResultsCacheKey(string query) => $"yt-album-search:{query.Trim().ToLowerInvariant()}"; - - private static string AlbumCacheKey(string albumId) => $"yt-album:{albumId}"; - - private static string SongInfoCacheKey(string videoId) => $"yt-song:{videoId}"; - - private static string StreamCacheKey(string videoId) => $"yt-stream:{videoId}"; - } -} diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 50b1fe8..55b7a8b 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -49,7 +49,6 @@ - diff --git a/backend/Hosts/Musify.Api/AppSettings.Development.json b/backend/Hosts/Musify.Api/AppSettings.Development.json index e81392b..9ddf45d 100644 --- a/backend/Hosts/Musify.Api/AppSettings.Development.json +++ b/backend/Hosts/Musify.Api/AppSettings.Development.json @@ -36,9 +36,6 @@ "OpenTelemetry": { "OtlpEndpoint": "http://localhost:4317" }, - "YouTube": { - "GeographicalLocation": "ES" - }, "Workers": { "Routes": { "TemporaryFilesDirectory": "../../../deploy/.tmp" diff --git a/backend/Hosts/Musify.Api/AppSettings.json b/backend/Hosts/Musify.Api/AppSettings.json index b26e236..4c0c5aa 100644 --- a/backend/Hosts/Musify.Api/AppSettings.json +++ b/backend/Hosts/Musify.Api/AppSettings.json @@ -53,21 +53,8 @@ "AnonymousFragmentSeconds": 0, "EstimatedAudioBytesPerSecond": 16000 }, - "YouTube": { - "Enabled": true, - "GeographicalLocation": "US", - "StreamUrlCacheSeconds": 1800, - "SearchContinuationCacheSeconds": 600, - "SearchResultsCacheSeconds": 900, - "SongInfoCacheSeconds": 86400, - "ThumbnailSize": 240, - "ArtworkSize": 544 - }, "Mix": { - "ArtistMixCount": 3, - "ItemsPerMix": 12, - "SeedArtistCount": 3, - "YouTubeResultsPerSeed": 12 + "ItemsPerMix": 12 }, "PlayList": { "Routes": { diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs index 90f8f85..2ce34e2 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs @@ -1,8 +1,3 @@ -using Musify.Domain.ValueObjects; - namespace Musify.Api.DataTransferObjects.Likes; -/// Toggle a like on a track already in the catalog by , or one from an -/// external source by + (provisioning it in the -/// background if needed) — exactly one of the two must be given. -public record ToggleLikeRequest(Guid? TrackId, TrackSource? Source, string? ExternalId); +public record ToggleLikeRequest(Guid TrackId); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs index 67b29d4..d000ed6 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs @@ -1,8 +1,3 @@ -using Musify.Domain.ValueObjects; - namespace Musify.Api.DataTransferObjects.PlayLists; -/// Add a track already in the catalog by , or one from an external -/// source by + (provisioning it in the background if -/// needed) — exactly one of the two must be given. -public record AddPlayListTrackRequest(Guid? TrackId, TrackSource? Source, string? ExternalId); +public record AddPlayListTrackRequest(Guid TrackId); diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index e4d6385..fb02cb4 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -8,8 +8,6 @@ using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using Musify.Application.YouTube; -using Musify.Domain.ValueObjects; namespace Musify.Api.Endpoints; @@ -22,7 +20,7 @@ public static class AlbumEndpoints group.MapGet("/", GetAlbums) .WithName("GetAlbums") - .WithSummary("Get Paginated Albums, Combined With Live YouTube Music Results When A Title Filter Is Given.") + .WithSummary("Get Paginated Albums, Optionally Filtered By Title.") .Produces(); group.MapGet("/{id}", GetAlbumById) @@ -31,12 +29,6 @@ public static class AlbumEndpoints .Produces() .Produces(StatusCodes.Status404NotFound); - group.MapGet("/external/{source}/{externalId}", GetExternalAlbum) - .WithName("GetExternalAlbum") - .WithSummary("Get An Album From An External Source (Currently Only YouTube Music) With Its Tracklist.") - .Produces() - .Produces(StatusCodes.Status404NotFound); - group.MapGet("/users/{userId}", GetAlbumsByUserId) .WithName("GetAlbumsByUserId") .WithSummary("Get Paginated Albums For A User.") @@ -125,10 +117,9 @@ public static class AlbumEndpoints CancellationToken cancellationToken, string? title, int pageNumber = 1, - int pageSize = 10, - string? youtubeContinuationToken = null) + int pageSize = 10) { - var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize, youtubeContinuationToken), cancellationToken); + var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize), cancellationToken); return result.ToHttpResult(); } @@ -141,19 +132,6 @@ public static class AlbumEndpoints return result.ToHttpResult(); } - private static async Task GetExternalAlbum( - IMediator mediator, - string source, - string externalId, - CancellationToken cancellationToken) - { - if (!Enum.TryParse(source, ignoreCase: true, out var trackSource) || trackSource != TrackSource.YouTube) - return Results.NotFound($"Unknown or unsupported source '{source}'."); - - var result = await mediator.Send(new GetYouTubeAlbumQuery(externalId), cancellationToken); - return result.ToHttpResult(); - } - private static async Task GetAlbumsByUserId( IMediator mediator, long userId, diff --git a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs index c707cd9..ac74df6 100644 --- a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs @@ -25,10 +25,8 @@ public static class LikeEndpoints group.MapPost("/toggle", ToggleTrackLike) .WithName("ToggleTrackLike") - .WithSummary("Like Or Unlike A Track, By Id Or By External Source, Provisioning It In The Background If Needed.") - .AddEndpointFilter>() + .WithSummary("Like Or Unlike A Track By Id.") .Produces() - .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); @@ -53,7 +51,7 @@ public static class LikeEndpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId, request.Source, request.ExternalId), + new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index 3004fbd..8ab6dc2 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -5,7 +5,6 @@ using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Musify.Application.Shared; using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.Extensions; @@ -88,11 +87,9 @@ public static class PlayListEndpoints group.MapPost("/{playlistId}/tracks", AddPlayListTrack) .WithName("AddPlayListTrack") - .WithSummary("Add A Track To A PlayList, By Id Or By External Source, Provisioning It In The Background If Needed.") - .AddEndpointFilter>() + .WithSummary("Add One Of Your Tracks To A PlayList.") .RequireAuthorization() .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); @@ -227,25 +224,14 @@ public static class PlayListEndpoints AddPlayListTrackRequest request, CancellationToken cancellationToken) { - if (request.TrackId is { } trackId) - { - var addResult = await mediator.Send( - new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, trackId), - cancellationToken); - if (addResult.IsError) - return addResult.ToHttpResult(); - - var trackResult = await mediator.Send(new GetTrackByIdQuery(trackId), cancellationToken); - return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}"); - } - - // The validator guarantees Source/ExternalId are set whenever TrackId isn't, and that Source - // is one of the currently supported external sources (only YouTube for now). - var result = await mediator.Send( - new AddYouTubeTrackToPlayListCommand(currentUser.RequiredId, playlistId, request.ExternalId!), + var addResult = await mediator.Send( + new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, request.TrackId), cancellationToken); + if (addResult.IsError) + return addResult.ToHttpResult(); - return result.ToCreatedResult(track => $"/tracks/{track.Id}"); + var trackResult = await mediator.Send(new GetTrackByIdQuery(request.TrackId), cancellationToken); + return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}"); } private static async Task RemoveTrackFromPlayList( diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 0b356ca..3a44359 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -3,9 +3,6 @@ using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Musify.Application.Shared; using Musify.Application.Contracts; -using Musify.Application.YouTube; -using Musify.Application.YouTube.Responses; -using Musify.Domain.ValueObjects; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; @@ -22,7 +19,7 @@ public static class TrackEndpoints group.MapGet("/", GetTracks) .WithName("GetTracks") - .WithSummary("Get Paginated Tracks, Combined With Live YouTube Music Results When A Name Filter Is Given.") + .WithSummary("Get Paginated Tracks, Optionally Filtered By Name.") .Produces(); group.MapGet("/{id}", GetTrackById) @@ -44,14 +41,6 @@ public static class TrackEndpoints .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); - group.MapGet("/external/{source}/{externalId}/stream", GetExternalTrackStream) - .WithName("GetExternalTrackStream") - .WithSummary("Resolve Playback For A Track From An External Source (Currently Only YouTube Music): Server Stream If Downloaded, Direct Stream From The Source Otherwise. Works Anonymously When The Playback Configuration Allows It.") - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); - group.MapGet("/users/{userId}", GetTracksByUserId) .WithName("GetTracksByUserId") .WithSummary("Get Paginated Tracks For A User.") @@ -96,10 +85,9 @@ public static class TrackEndpoints CancellationToken cancellationToken, string? name, int pageNumber = 1, - int pageSize = 10, - string? youtubeContinuationToken = null) + int pageSize = 10) { - var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, youtubeContinuationToken), cancellationToken); + var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken); return result.ToHttpResult(); } @@ -140,20 +128,6 @@ public static class TrackEndpoints return result.ToHttpResult(); } - private static async Task GetExternalTrackStream( - IMediator mediator, - CurrentUser currentUser, - string source, - string externalId, - CancellationToken cancellationToken) - { - if (!Enum.TryParse(source, ignoreCase: true, out var trackSource) || trackSource != TrackSource.YouTube) - return Results.NotFound($"Unknown or unsupported source '{source}'."); - - var result = await mediator.Send(new ResolveYouTubeTrackStreamCommand(externalId, currentUser.Id), cancellationToken); - return result.ToHttpResult(); - } - private static async Task GetTracksByUserId( IMediator mediator, long userId, diff --git a/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs b/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs index 78b93dd..1ceb89e 100644 --- a/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs @@ -18,7 +18,6 @@ public static class ServiceCollectionExtensions services.AddDatabase(configuration); services.AddStorageService(configuration); services.AddStreamTicketService(configuration); - services.AddYouTubeMusicService(configuration); services.AddMassTransitClient(configuration); services.AddAuthenticationConfiguration(configuration); services.AddOpenApiConfiguration(); diff --git a/backend/Hosts/Musify.Api/Validators/Likes/ToggleLikeRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Likes/ToggleLikeRequestValidator.cs deleted file mode 100644 index 13fec0c..0000000 --- a/backend/Hosts/Musify.Api/Validators/Likes/ToggleLikeRequestValidator.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FluentValidation; -using Musify.Api.DataTransferObjects.Likes; -using Musify.Domain.ValueObjects; - -namespace Musify.Api.Validators.Likes; - -public sealed class ToggleLikeRequestValidator : AbstractValidator -{ - public ToggleLikeRequestValidator() - { - RuleFor(x => x) - .Must(x => x.TrackId.HasValue ^ (x.Source.HasValue && !string.IsNullOrEmpty(x.ExternalId))) - .WithMessage("Provide either trackId, or source and externalId, but not both."); - - When(x => x.Source.HasValue, () => - { - RuleFor(x => x.Source) - .Must(source => source == TrackSource.YouTube) - .WithMessage("Unsupported source."); - - RuleFor(x => x.ExternalId) - .NotEmpty() - .Matches("^[A-Za-z0-9_-]{11}$") - .WithMessage("externalId must be a valid YouTube video id."); - }); - } -} diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/AddPlayListTrackRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/AddPlayListTrackRequestValidator.cs deleted file mode 100644 index eb5b0da..0000000 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/AddPlayListTrackRequestValidator.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FluentValidation; -using Musify.Api.DataTransferObjects.PlayLists; -using Musify.Domain.ValueObjects; - -namespace Musify.Api.Validators.PlayLists; - -public sealed class AddPlayListTrackRequestValidator : AbstractValidator -{ - public AddPlayListTrackRequestValidator() - { - RuleFor(x => x) - .Must(x => x.TrackId.HasValue ^ (x.Source.HasValue && !string.IsNullOrEmpty(x.ExternalId))) - .WithMessage("Provide either trackId, or source and externalId, but not both."); - - When(x => x.Source.HasValue, () => - { - RuleFor(x => x.Source) - .Must(source => source == TrackSource.YouTube) - .WithMessage("Unsupported source."); - - RuleFor(x => x.ExternalId) - .NotEmpty() - .Matches("^[A-Za-z0-9_-]{11}$") - .WithMessage("externalId must be a valid YouTube video id."); - }); - } -} diff --git a/backend/Hosts/Musify.Worker/AppSettings.Development.json b/backend/Hosts/Musify.Worker/AppSettings.Development.json index 4b42d48..3b8dee5 100644 --- a/backend/Hosts/Musify.Worker/AppSettings.Development.json +++ b/backend/Hosts/Musify.Worker/AppSettings.Development.json @@ -16,9 +16,6 @@ "OpenTelemetry": { "OtlpEndpoint": "http://localhost:4317" }, - "YouTube": { - "GeographicalLocation": "ES" - }, "InfrastructureStorage": { "Address": "http://localhost:8333", "AccessKey": "admin_access_key", diff --git a/backend/Hosts/Musify.Worker/AppSettings.json b/backend/Hosts/Musify.Worker/AppSettings.json index ec01d0a..724563d 100644 --- a/backend/Hosts/Musify.Worker/AppSettings.json +++ b/backend/Hosts/Musify.Worker/AppSettings.json @@ -26,14 +26,8 @@ "ApplicationStorage": { "Bucket": "" }, - "YouTube": { - "Enabled": true - }, "Mix": { - "ArtistMixCount": 3, - "ItemsPerMix": 12, - "SeedArtistCount": 3, - "YouTubeResultsPerSeed": 12 + "ItemsPerMix": 12 }, "PlayList": { "Routes": { @@ -83,12 +77,6 @@ }, "TranscodingTimeout": "00:05:00" }, - "YtDlp": { - "ExecutableName": "yt-dlp", - "Format": "bestaudio", - "AdditionalArguments": "", - "DownloadTimeout": "00:05:00" - }, "Workers": { "Routes": { "TemporaryFilesDirectory": "" diff --git a/backend/Hosts/Musify.Worker/Program.cs b/backend/Hosts/Musify.Worker/Program.cs index af4d9a1..d5cf800 100644 --- a/backend/Hosts/Musify.Worker/Program.cs +++ b/backend/Hosts/Musify.Worker/Program.cs @@ -1,6 +1,5 @@ using Hangfire; using Musify.Application.Configuration; -using Musify.Application.Services; using Musify.Infrastructure.Jobs; using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.Persistence; @@ -29,7 +28,6 @@ services.AddValidatedOptions(configuration, TrackConfigurati services.AddValidatedOptions(configuration, MixConfiguration.SectionName); services.AddStorageService(configuration); services.AddAudioTranscoder(configuration); -services.AddYouTubeDownloader(configuration); services.AddHttpClient(); services.AddPictureService(configuration); services.AddPlayListPresetSeeder(configuration); @@ -37,8 +35,6 @@ services.AddDatabase(configuration); services.AddMassTransitConsumers(configuration); services.AddUploadIntentJobs(configuration); services.AddMediator(); -services.AddScoped(); -services.AddYouTubeMusicService(configuration); services.AddDailyMixGenerationJob(configuration); var host = builder.Build(); diff --git a/backend/Musify.Worker.Dockerfile b/backend/Musify.Worker.Dockerfile index c21c71f..b254969 100644 --- a/backend/Musify.Worker.Dockerfile +++ b/backend/Musify.Worker.Dockerfile @@ -17,18 +17,9 @@ RUN cp /app/AppSettings.json /app/appsettings.json \ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app -# ffmpeg: the Worker transcodes audio to .m4a. yt-dlp: downloads YouTube audio. -# yt-dlp is installed via pip in a venv rather than the standalone PyInstaller -# binary: the binary adds a ~1s unpack cost per invocation and extracts slower, -# so pip roughly halves resolution latency. deno solves nsig challenges. +# ffmpeg: the Worker transcodes uploaded audio to .m4a. RUN apt-get update \ - && apt-get install -y --no-install-recommends ffmpeg python3 python3-venv ca-certificates curl unzip \ - && python3 -m venv /opt/ytdlp \ - && /opt/ytdlp/bin/pip install --no-cache-dir -U yt-dlp \ - && ln -s /opt/ytdlp/bin/yt-dlp /usr/local/bin/yt-dlp \ - && curl -fsSL https://deno.land/install.sh | sh -s -- -y \ - && mv /root/.deno/bin/deno /usr/local/bin/deno \ - && apt-get purge -y curl unzip \ - && rm -rf /var/lib/apt/lists/* /root/.deno + && apt-get install -y --no-install-recommends ffmpeg ca-certificates \ + && rm -rf /var/lib/apt/lists/* COPY --from=build /app ./ ENTRYPOINT ["dotnet", "Musify.Worker.dll"] diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs index 00bc5e7..d9697cb 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs @@ -25,8 +25,6 @@ namespace Musify.Api.Tests.TestSupport public IEventBus EventBus { get; } = Substitute.For(); - public IYouTubeMusicService YouTubeMusicService { get; } = Substitute.For(); - public IStreamTicketService StreamTicketService { get; } = Substitute.For(); public async ValueTask InitializeAsync() @@ -88,7 +86,6 @@ namespace Musify.Api.Tests.TestSupport { services.Replace(ServiceDescriptor.Singleton(StorageService)); services.Replace(ServiceDescriptor.Scoped(_ => EventBus)); - services.Replace(ServiceDescriptor.Singleton(YouTubeMusicService)); services.Replace(ServiceDescriptor.Singleton(StreamTicketService)); services diff --git a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs index 3f9d8cd..3d78fd9 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs @@ -15,8 +15,8 @@ namespace Musify.Application.Tests.Albums public async Task Handle_FirstTrack_AddsItAtNumberOne() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - var track = TestEntities.LocalTrack(owner); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(owner); await SeedAsync(owner, album, track); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken); @@ -30,7 +30,7 @@ namespace Musify.Application.Tests.Albums public async Task Handle_AlbumMissing_ReturnsNotFound() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, Guid.NewGuid(), track.Id), TestContext.Current.CancellationToken); @@ -43,8 +43,8 @@ namespace Musify.Application.Tests.Albums { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); - var album = TestEntities.UserAlbum(owner.Id); - var track = TestEntities.LocalTrack(owner); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(owner); await SeedAsync(owner, stranger, album, track); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken); @@ -56,7 +56,7 @@ namespace Musify.Application.Tests.Albums public async Task Handle_TrackMissing_ReturnsNotFound() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); + var album = TestEntities.Album(owner.Id); await SeedAsync(owner, album); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); @@ -69,8 +69,8 @@ namespace Musify.Application.Tests.Albums { var owner = TestEntities.User(1, "owner"); var otherOwner = TestEntities.User(2, "other"); - var album = TestEntities.UserAlbum(owner.Id); - var foreignTrack = TestEntities.LocalTrack(otherOwner); + var album = TestEntities.Album(owner.Id); + var foreignTrack = TestEntities.Track(otherOwner); await SeedAsync(owner, otherOwner, album, foreignTrack); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken); @@ -82,8 +82,8 @@ namespace Musify.Application.Tests.Albums public async Task Handle_AlreadyInAlbum_ReturnsConflict() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - var track = TestEntities.LocalTrack(owner); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(owner); var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }; await SeedAsync(owner, album, track, link); @@ -96,9 +96,9 @@ namespace Musify.Application.Tests.Albums public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - var firstTrack = TestEntities.LocalTrack(owner, "First"); - var secondTrack = TestEntities.LocalTrack(owner, "Second"); + var album = TestEntities.Album(owner.Id); + var firstTrack = TestEntities.Track(owner, "First"); + var secondTrack = TestEntities.Track(owner, "Second"); var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 }; await SeedAsync(owner, album, firstTrack, secondTrack, existingLink); diff --git a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs index 91b3664..eeb6cea 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs @@ -44,7 +44,7 @@ namespace Musify.Application.Tests.Albums Assert.Equal(owner.Id, result.Value.OwnerUserId); Assert.Equal(0, result.Value.TrackCount); - var stored = await Database.UserAlbums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + var stored = await Database.Albums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); Assert.NotNull(stored); Assert.Equal("MY ALBUM", stored.NormalizedTitle); Assert.Equal("cover.webp", stored.Pictures.OriginalName); diff --git a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs index a9f7fe5..6d2a371 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs @@ -13,13 +13,13 @@ namespace Musify.Application.Tests.Albums public async Task Handle_Owner_RemovesAlbum() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); + var album = TestEntities.Album(owner.Id); await SeedAsync(owner, album); var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Null(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken)); + Assert.Null(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); } [Fact] @@ -35,13 +35,13 @@ namespace Musify.Application.Tests.Albums { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); - var album = TestEntities.UserAlbum(owner.Id); + var album = TestEntities.Album(owner.Id); await SeedAsync(owner, stranger, album); var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - Assert.NotNull(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken)); + Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs index 56bca64..bb21b98 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs @@ -14,8 +14,8 @@ namespace Musify.Application.Tests.Albums public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - var track = TestEntities.LocalTrack(owner); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(owner); await SeedAsync(owner, album, track, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }); var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs index f5eff01..4a7610a 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs @@ -17,7 +17,7 @@ namespace Musify.Application.Tests.Albums public async Task Handle_ExistingAlbum_ReturnsRequestedSize(string size, string expectedFileName) { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); + var album = TestEntities.Album(owner.Id); await SeedAsync(owner, album); var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, size), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs index 6c412d9..955d7ab 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs @@ -14,9 +14,9 @@ namespace Musify.Application.Tests.Albums public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - var second = TestEntities.LocalTrack(owner, "Second"); - var first = TestEntities.LocalTrack(owner, "First"); + var album = TestEntities.Album(owner.Id); + var second = TestEntities.Track(owner, "Second"); + var first = TestEntities.Track(owner, "First"); await SeedAsync( owner, album, second, first, new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 }, diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs index 1bf6a66..93be0ed 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs @@ -14,7 +14,7 @@ namespace Musify.Application.Tests.Albums { var owner = TestEntities.User(1, "owner"); var other = TestEntities.User(2, "other"); - await SeedAsync(owner, other, TestEntities.UserAlbum(owner.Id, "Mine"), TestEntities.UserAlbum(other.Id, "Theirs")); + await SeedAsync(owner, other, TestEntities.Album(owner.Id, "Mine"), TestEntities.Album(other.Id, "Theirs")); var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs index 1d565c5..42f2484 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs @@ -1,99 +1,44 @@ -using ErrorOr; using Musify.Application.Albums; -using Musify.Application.Contracts; using Musify.Application.Tests.TestSupport; -using Musify.Domain.ValueObjects; -using NSubstitute; using Xunit; namespace Musify.Application.Tests.Albums { public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private GetAlbumsQueryHandler CreateHandler() => - new(Database, youTubeMusicService, NoOpLogger()); + private GetAlbumsQueryHandler CreateHandler() => new(Database); [Fact] - public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged_AndDoesNotCallYouTube() + public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged() { var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Alpha"), TestEntities.UserAlbum(owner.Id, "Beta")); + await SeedAsync(owner, TestEntities.Album(owner.Id, "Alpha"), TestEntities.Album(owner.Id, "Beta")); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal(2, result.Value.TotalItemCount); Assert.All(result.Value.Items, item => Assert.NotNull(item.Album)); - await youTubeMusicService.DidNotReceiveWithAnyArgs() - .SearchAlbumsAsync(default!, default!, TestContext.Current.CancellationToken); } [Fact] - public async Task Handle_TitleFilter_ReturnsMatchingLocalAlbums_FollowedByYouTubeResults() + public async Task Handle_TitleFilter_ReturnsMatchingAlbumsOnly() { var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Rock Classics"), TestEntities.UserAlbum(owner.Id, "Jazz Favorites")); - - var album = new YouTubeAlbumResult("album-1", "Rock Classics Reissue", "Various Artists", "thumb.jpg", 2020, false, false, []); - youTubeMusicService.SearchAlbumsAsync("rock", string.Empty, Arg.Any()) - .Returns(new YouTubeAlbumSearchResult([album], "next-token")); - - var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.Items.Count); - Assert.NotNull(result.Value.Items[0].Album); - Assert.Equal("Rock Classics", result.Value.Items[0].Album!.Title); - Assert.Null(result.Value.Items[1].Album); - Assert.NotNull(result.Value.Items[1].YouTubeAlbum); - Assert.Equal("album-1", result.Value.Items[1].YouTubeAlbum!.AlbumId); - Assert.Equal("next-token", result.Value.NextYoutubeContinuationToken); - Assert.False(result.Value.YoutubeUnavailable); - } - - [Fact] - public async Task Handle_TitleFilter_SkipsYouTubeResultAlreadyMaterializedLocally() - { - var externalAlbum = TestEntities.ExternalAlbum(externalId: "album-1", title: "Rock Classics", source: TrackSource.YouTube); - await SeedAsync(externalAlbum); - - var album = new YouTubeAlbumResult("album-1", "Rock Classics", "Various Artists", "thumb.jpg", 2020, false, false, []); - youTubeMusicService.SearchAlbumsAsync("rock", string.Empty, Arg.Any()) - .Returns(new YouTubeAlbumSearchResult([album], string.Empty)); + await SeedAsync(owner, TestEntities.Album(owner.Id, "Rock Classics"), TestEntities.Album(owner.Id, "Jazz Favorites")); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); Assert.False(result.IsError); var item = Assert.Single(result.Value.Items); - Assert.NotNull(item.Album); - Assert.Null(item.YouTubeAlbum); - } - - [Fact] - public async Task Handle_TitleFilter_YouTubeUnavailable_StillReturnsLocalResults() - { - var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Rock Classics")); - - youTubeMusicService.SearchAlbumsAsync("rock", string.Empty, Arg.Any()) - .Returns(Error.Forbidden(description: "The YouTube Music source is disabled.")); - - var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.NotNull(item.Album); - Assert.True(result.Value.YoutubeUnavailable); - Assert.Null(result.Value.NextYoutubeContinuationToken); + Assert.Equal("Rock Classics", item.Album.Title); } [Fact] public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage() { var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "One"), TestEntities.UserAlbum(owner.Id, "Two"), TestEntities.UserAlbum(owner.Id, "Three")); + await SeedAsync(owner, TestEntities.Album(owner.Id, "One"), TestEntities.Album(owner.Id, "Two"), TestEntities.Album(owner.Id, "Three")); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs index 28c0c0b..8898711 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs @@ -13,10 +13,10 @@ namespace Musify.Application.Tests.Albums public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst() { var owner = TestEntities.User(); - var olderAlbum = TestEntities.UserAlbum(owner.Id, "Older"); - var newerAlbum = TestEntities.UserAlbum(owner.Id, "Newer"); - var olderTrack = TestEntities.LocalTrack(owner, "In older album"); - var newerTrack = TestEntities.LocalTrack(owner, "In newer album"); + var olderAlbum = TestEntities.Album(owner.Id, "Older"); + var newerAlbum = TestEntities.Album(owner.Id, "Newer"); + var olderTrack = TestEntities.Track(owner, "In older album"); + var newerTrack = TestEntities.Track(owner, "In newer album"); await SeedAsync( owner, olderAlbum, newerAlbum, olderTrack, newerTrack, new AlbumHasTrack { AlbumId = olderAlbum.Id, TrackId = olderTrack.Id, TrackNumber = 1 }, @@ -46,8 +46,8 @@ namespace Musify.Application.Tests.Albums public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums() { var owner = TestEntities.User(); - var albums = Enumerable.Range(1, 3).Select(i => TestEntities.UserAlbum(owner.Id, $"Album {i}")).ToList(); - var tracks = albums.Select((_, i) => TestEntities.LocalTrack(owner, $"Track {i}")).ToList(); + var albums = Enumerable.Range(1, 3).Select(i => TestEntities.Album(owner.Id, $"Album {i}")).ToList(); + var tracks = albums.Select((_, i) => TestEntities.Track(owner, $"Track {i}")).ToList(); var links = albums.Zip(tracks, (album, track) => new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }).ToList(); var histories = tracks.Select((track, i) => TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddMinutes(-i))).ToList(); await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]); diff --git a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs index c51431a..62516f2 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs @@ -16,10 +16,10 @@ namespace Musify.Application.Tests.Albums public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - var first = TestEntities.LocalTrack(owner, "First"); - var second = TestEntities.LocalTrack(owner, "Second"); - var third = TestEntities.LocalTrack(owner, "Third"); + var album = TestEntities.Album(owner.Id); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + var third = TestEntities.Track(owner, "Third"); await SeedAsync( owner, album, first, second, third, new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 }, @@ -50,7 +50,7 @@ namespace Musify.Application.Tests.Albums { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); - var album = TestEntities.UserAlbum(owner.Id); + var album = TestEntities.Album(owner.Id); await SeedAsync(owner, stranger, album); var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); @@ -62,7 +62,7 @@ namespace Musify.Application.Tests.Albums public async Task Handle_TrackNotInAlbum_ReturnsNotFound() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); + var album = TestEntities.Album(owner.Id); await SeedAsync(owner, album); var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs index c82a160..930d33f 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -26,7 +26,7 @@ namespace Musify.Application.Tests.Albums public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020); + var album = TestEntities.Album(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020); await SeedAsync(owner, album); var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null); @@ -38,7 +38,7 @@ namespace Musify.Application.Tests.Albums Assert.Equal("New description", result.Value.Description); Assert.Equal(2025, result.Value.ReleaseYear); - var stored = await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken); + var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); Assert.NotNull(stored); Assert.Equal("NEW TITLE", stored.NormalizedTitle); } @@ -51,7 +51,7 @@ namespace Musify.Application.Tests.Albums .Returns(new ObjectMetaData("image/webp", 1024)); var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id, title: "Old Title"); + var album = TestEntities.Album(owner.Id, title: "Old Title"); var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "new-cover.webp"); await SeedAsync(owner, album, intent); @@ -60,7 +60,7 @@ namespace Musify.Application.Tests.Albums var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - var stored = await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken); + var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); Assert.NotNull(stored); Assert.Equal("new-cover.webp", stored.Pictures.OriginalName); await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); @@ -81,7 +81,7 @@ namespace Musify.Application.Tests.Albums { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); - var album = TestEntities.UserAlbum(owner.Id); + var album = TestEntities.Album(owner.Id); await SeedAsync(owner, stranger, album); var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null); diff --git a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs index 9be76bb..158ee08 100644 --- a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs @@ -13,8 +13,8 @@ namespace Musify.Application.Tests.Likes public async Task Handle_MultipleLikes_ReturnsMostRecentFirst() { var owner = TestEntities.User(); - var older = TestEntities.LocalTrack(owner, "Older like"); - var newer = TestEntities.LocalTrack(owner, "Newer like"); + var older = TestEntities.Track(owner, "Older like"); + var newer = TestEntities.Track(owner, "Newer like"); await SeedAsync( owner, older, newer, new TrackLike { UserId = owner.Id, TrackId = older.Id, CreatedAt = DateTime.UtcNow.AddMinutes(-10) }, @@ -43,7 +43,7 @@ namespace Musify.Application.Tests.Likes { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id }); var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs index a0ba867..f03b174 100644 --- a/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs @@ -1,40 +1,25 @@ using ErrorOr; using Microsoft.EntityFrameworkCore; -using Musify.Application.Contracts; using Musify.Application.Likes; -using Musify.Application.Services; using Musify.Application.Tests.TestSupport; -using Musify.Domain.ValueObjects; -using NSubstitute; using Xunit; namespace Musify.Application.Tests.Likes { public sealed class ToggleTrackLikeCommandHandlerTests : HandlerTestBase { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private ToggleTrackLikeCommandHandler CreateHandler() - { - var provisioner = new YouTubeTrackProvisioner( - Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - - return new ToggleTrackLikeCommandHandler(Database, provisioner, NoOpLogger()); - } - - private static YouTubeSongResult BuildSong(string videoId) => new( - videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false, - [new YouTubeArtistRef("artist-id", "Song Artist")]); + private ToggleTrackLikeCommandHandler CreateHandler() => + new(Database, NoOpLogger()); [Fact] - public async Task Handle_LocalTrackNotLiked_LikesIt() + public async Task Handle_TrackNotLiked_LikesIt() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, track.Id, null, null), TestContext.Current.CancellationToken); + new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.True(result.Value); @@ -42,14 +27,14 @@ namespace Musify.Application.Tests.Likes } [Fact] - public async Task Handle_LocalTrackAlreadyLiked_UnlikesIt() + public async Task Handle_TrackAlreadyLiked_UnlikesIt() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track, new Musify.Domain.Entities.TrackLike { UserId = owner.Id, TrackId = track.Id }); var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, track.Id, null, null), TestContext.Current.CancellationToken); + new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.False(result.Value); @@ -63,41 +48,9 @@ namespace Musify.Application.Tests.Likes await SeedAsync(owner); var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid(), null, null), TestContext.Current.CancellationToken); + new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } - - [Fact] - public async Task Handle_UnknownExternalTrack_ProvisionsAndLikesIt() - { - youTubeMusicService.GetSongAsync("new-video", Arg.Any()).Returns(BuildSong("new-video")); - - var owner = TestEntities.User(); - await SeedAsync(owner); - - var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, null, TrackSource.YouTube, "new-video"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.True(result.Value); - var track = await Database.ExternalTracks.SingleAsync(t => t.ExternalId == "new-video", TestContext.Current.CancellationToken); - Assert.True(await Database.TrackLikes.AnyAsync(like => like.TrackId == track.Id, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task Handle_ExistingExternalTrack_LikesItWithoutReprovisioning() - { - var owner = TestEntities.User(); - var track = TestEntities.ExternalTrack(externalId: "abc123"); - await SeedAsync(owner, track); - - var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, null, TrackSource.YouTube, "abc123"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.True(result.Value); - await youTubeMusicService.DidNotReceive().GetSongAsync(Arg.Any(), Arg.Any()); - } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs index 5d0405c..7a3a8a4 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -1,24 +1,18 @@ -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; namespace Musify.Application.Tests.Mixes { public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - private GenerateMixesForUserCommandHandler CreateHandler() => new( - Database, youTubeMusicService, TestConfigurations.Mix(), NoOpLogger()); + Database, TestConfigurations.Mix(), NoOpLogger()); [Fact] - public async Task Handle_NoListeningHistory_DoesNotCreateAnyMix() + public async Task Handle_NoTracks_DoesNotCreateAnyMix() { var user = TestEntities.User(); await SeedAsync(user); @@ -30,75 +24,53 @@ namespace Musify.Application.Tests.Mixes } [Fact] - public async Task Handle_HistoryWithNoKnownArtists_DoesNotCreateAnyMix() + public async Task Handle_AllTracksAlreadyListenedTo_CreatesOnlyTheDailyMix() { var user = TestEntities.User(); - var track = TestEntities.LocalTrack(user); - await SeedAsync(user, track, TestEntities.ListeningHistory(user.Id, track.Id)); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + new UserHasTrack { UserId = user.Id, TrackId = track.Id }, + TestEntities.ListeningHistory(user.Id, track.Id)); var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); + var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); + Assert.Single(mixes); + Assert.Equal("Tu mezcla diaria", mixes[0].Title); } [Fact] - public async Task Handle_SeedArtistWithYouTubeAndLocalCandidates_ReplacesPreviousMixes() + public async Task Handle_UnheardTracksInLibrary_CreatesDiscoveryAndDailyMixes_AndReplacesPreviousMixes() { - youTubeMusicService - .SearchSongsAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new YouTubeSearchResult( - [new YouTubeSongResult("yt-1", "YouTube Song", "Seed Artist", "Album", 200, "https://img/thumb.jpg", IsExplicit: false, [] )], - ContinuationToken: string.Empty)); - youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); - 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"); + var listenedTrack = TestEntities.Track(user, "Listened Track"); + var unheardTrack = TestEntities.Track(user, "Unheard Track"); var staleMix = TestEntities.Mix(user.Id, "Stale Mix"); - var staleItem = TestEntities.MixItem(staleMix.Id, title: "Stale Item"); + var staleItem = TestEntities.MixItem(staleMix.Id, listenedTrack.Id); await SeedAsync( - user, artist, listenedTrack, otherTrackByArtist, staleMix, staleItem, - new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 }, - new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 }, + user, listenedTrack, unheardTrack, staleMix, staleItem, + new UserHasTrack { UserId = user.Id, TrackId = listenedTrack.Id }, + new UserHasTrack { UserId = user.Id, TrackId = unheardTrack.Id }, TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.NotEmpty(mixes); Assert.DoesNotContain(mixes, mix => mix.Title == "Stale Mix"); + Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); + Assert.Contains(mixes, mix => mix.Title == "Tu mezcla diaria"); Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken)); - } - [Fact] - public async Task Handle_YouTubeSourceUnavailable_StillCreatesMixesFromLocalCandidatesOnly() - { - 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)); + var discoveryMix = mixes.Single(mix => mix.Title == "Descubrimiento"); + var discoveryItems = await Database.MixItems + .Where(item => item.MixId == discoveryMix.Id) + .ToListAsync(TestContext.Current.CancellationToken); + Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId)); } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs index a25ceda..97ba344 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs @@ -13,10 +13,12 @@ namespace Musify.Application.Tests.Mixes public async Task Handle_OwnedMix_ReturnsItWithOrderedItems() { var owner = TestEntities.User(); + var firstTrack = TestEntities.Track(owner, "First"); + var secondTrack = TestEntities.Track(owner, "Second"); var mix = TestEntities.Mix(owner.Id, "Discovery"); - var second = TestEntities.MixItem(mix.Id, position: 1, title: "Second"); - var first = TestEntities.MixItem(mix.Id, position: 0, title: "First"); - await SeedAsync(owner, mix, second, first); + var second = TestEntities.MixItem(mix.Id, secondTrack.Id, position: 1); + var first = TestEntities.MixItem(mix.Id, firstTrack.Id, position: 0); + await SeedAsync(owner, firstTrack, secondTrack, mix, second, first); var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs index df88fc1..2e1fad1 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs @@ -16,7 +16,7 @@ namespace Musify.Application.Tests.PlayLists { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, playList, track); var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); @@ -31,8 +31,8 @@ namespace Musify.Application.Tests.PlayLists { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id); - var existingTrack = TestEntities.LocalTrack(owner, "Existing"); - var newTrack = TestEntities.LocalTrack(owner, "New"); + var existingTrack = TestEntities.Track(owner, "Existing"); + var newTrack = TestEntities.Track(owner, "New"); await SeedAsync( owner, playList, existingTrack, newTrack, new PlayListHasTrack { PlayListId = playList.Id, TrackId = existingTrack.Id, Position = 0 }); @@ -82,7 +82,7 @@ namespace Musify.Application.Tests.PlayLists { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs deleted file mode 100644 index eeb832b..0000000 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs +++ /dev/null @@ -1,105 +0,0 @@ -using ErrorOr; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Contracts; -using Musify.Application.PlayLists; -using Musify.Application.Services; -using Musify.Application.Tests.TestSupport; -using Musify.Domain.Entities; -using NSubstitute; -using Xunit; - -namespace Musify.Application.Tests.PlayLists -{ - public sealed class AddYouTubeTrackToPlayListCommandHandlerTests : HandlerTestBase - { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private AddYouTubeTrackToPlayListCommandHandler CreateHandler() - { - var provisioner = new YouTubeTrackProvisioner( - Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - - return new AddYouTubeTrackToPlayListCommandHandler( - Database, - eventBus, - youTubeMusicService, - provisioner, - TestConfigurations.Storage(), - TestConfigurations.Track(), - NoOpLogger()); - } - - private static YouTubeSongResult BuildSong(string videoId) => new( - videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false, - [new YouTubeArtistRef("artist-id", "Song Artist")]); - - [Fact] - public async Task Handle_TrackAlreadyProcessedAndNotLinked_AddsItWithoutQueuingDownload() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.ExternalTrack(externalId: "abc123"); - await SeedAsync(owner, playList, track); - - var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task Handle_UnknownVideo_ProvisionsTrackAndQueuesDownload() - { - youTubeMusicService.GetSongAsync("new-video", Arg.Any()).Returns(BuildSong("new-video")); - youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); - - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); - - var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "new-video"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal("Song Title", result.Value.Title); - var track = await Database.ExternalTracks.SingleAsync(t => t.ExternalId == "new-video", TestContext.Current.CancellationToken); - Assert.True(track.Audio.DownloadRequested); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(1, Guid.NewGuid(), "abc123"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } - - [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, stranger, playList); - - var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(stranger.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } - - [Fact] - public async Task Handle_TrackAlreadyLinked_ReturnsConflict() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.ExternalTrack(externalId: "abc123"); - await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); - - var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - } - } -} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs index 45c5483..0610a17 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs @@ -15,7 +15,7 @@ namespace Musify.Application.Tests.PlayLists { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs index 5642616..bedf50f 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs @@ -15,8 +15,8 @@ namespace Musify.Application.Tests.PlayLists { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id); - var second = TestEntities.LocalTrack(owner, "Second"); - var first = TestEntities.LocalTrack(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + var first = TestEntities.Track(owner, "First"); await SeedAsync( owner, playList, second, first, new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 }, diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs index 81e1d59..67bab6e 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs @@ -15,7 +15,7 @@ namespace Musify.Application.Tests.PlayLists { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs index b421bb9..23e4924 100644 --- a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs @@ -24,7 +24,7 @@ namespace Musify.Application.Tests.Services { ticketService.IssueTicket(7, Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); var owner = TestEntities.User(7); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken); @@ -45,7 +45,7 @@ namespace Musify.Application.Tests.Services { ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); var owner = TestEntities.User(1); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); @@ -58,7 +58,7 @@ namespace Musify.Application.Tests.Services { ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); var owner = TestEntities.User(1); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var response = await CreateIssuer(TestConfigurations.Playback(allowAnonymousListening: true)) @@ -73,7 +73,7 @@ namespace Musify.Application.Tests.Services { ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); var owner = TestEntities.User(1); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); @@ -87,7 +87,7 @@ namespace Musify.Application.Tests.Services { ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); var owner = TestEntities.User(1); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); diff --git a/backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs b/backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs deleted file mode 100644 index 0613315..0000000 --- a/backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Musify.Application.Contracts; -using Musify.Application.Services; -using Musify.Application.Tests.TestSupport; -using NSubstitute; -using Xunit; - -namespace Musify.Application.Tests.Services -{ - public sealed class YouTubeTrackProvisionerTests : HandlerTestBase - { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private YouTubeTrackProvisioner CreateProvisioner() => - new(Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - - [Fact] - public async Task FindAsync_UnknownVideo_ReturnsNull() - { - var track = await CreateProvisioner().FindAsync("missing-video", TestContext.Current.CancellationToken); - - Assert.Null(track); - } - - [Fact] - public async Task GetOrCreateAsync_KnownVideo_ReturnsExistingTrackWithoutCallingTheService() - { - var existing = TestEntities.ExternalTrack(externalId: "known-video"); - await SeedAsync(existing); - - var result = await CreateProvisioner().GetOrCreateAsync("known-video", TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(existing.Id, result.Value.Track.Id); - await youTubeMusicService.DidNotReceiveWithAnyArgs().GetSongAsync(default!, TestContext.Current.CancellationToken); - } - - [Fact] - public async Task GetOrCreateAsync_NewVideo_CreatesTrackAndDeduplicatesArtistsByName() - { - youTubeMusicService.GetSongAsync("new-video", Arg.Any()).Returns( - new YouTubeSongResult( - "new-video", "New Song", "New Artist", "Album", 210, "https://img/thumb.jpg", false, - [new YouTubeArtistRef(null, "New Artist"), new YouTubeArtistRef(null, "new artist")])); - - var result = await CreateProvisioner().GetOrCreateAsync("new-video", TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal("New Song", result.Value.Track.Title); - Assert.Single(await Database.Artists.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Single(await Database.TrackArtists.ToListAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task GetOrCreateAsync_ServiceFails_ReturnsError() - { - youTubeMusicService.GetSongAsync("bad-video", Arg.Any()) - .Returns(ErrorOr.Error.NotFound(description: "Video not found")); - - var result = await CreateProvisioner().GetOrCreateAsync("bad-video", TestContext.Current.CancellationToken); - - Assert.True(result.IsError); - } - - [Fact] - public async Task MaterializeAlbumAsync_NewAlbum_CreatesAlbumTracksAndLinks() - { - var detail = new YouTubeAlbumDetail( - new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, - [new YouTubeArtistRef(null, "Album Artist")]), - Description: "desc", - TotalDurationSeconds: 400, - Tracks: - [ - new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false), - new YouTubeAlbumTrack("track-2", "Track Two", 200, 2, false) - ]); - - await CreateProvisioner().MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken); - - Assert.Equal(2, await Database.ExternalTracks.CountAsync(TestContext.Current.CancellationToken)); - Assert.Equal(2, await Database.AlbumHasTracks.CountAsync(TestContext.Current.CancellationToken)); - Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task MaterializeAlbumAsync_CalledTwice_DoesNotDuplicateTracksOrLinks() - { - var detail = new YouTubeAlbumDetail( - new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []), - Description: "desc", - TotalDurationSeconds: 200, - Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]); - - var provisioner = CreateProvisioner(); - await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken); - await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken); - - Assert.Single(await Database.ExternalTracks.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken)); - } - } -} diff --git a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs index 6eda042..a708efa 100644 --- a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs +++ b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs @@ -1,6 +1,5 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; using Xunit; namespace Musify.Application.Tests.Shared @@ -8,44 +7,16 @@ namespace Musify.Application.Tests.Shared public sealed class TrackApplicationResponseTests { [Fact] - public void FromEntity_LocalTrack_UsesOwnerNameAsArtist() + public void FromEntity_UsesOwnerNameAsArtist() { var owner = TestEntities.User(1, "The Owner"); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); - var response = Assert.IsType(TrackApplicationResponse.FromEntity(track, listensCount: 3)); + var response = TrackApplicationResponse.FromEntity(track, listensCount: 3); Assert.Equal("The Owner", response.Artist); Assert.Equal(owner.Id, response.OwnerUserId); Assert.Equal(3, response.ListensCount); } - - [Fact] - public void FromEntity_ExternalTrackWithArtists_JoinsThemInPositionOrder() - { - var track = TestEntities.ExternalTrack(); - var second = TestEntities.Artist("Second Artist", externalId: "second"); - var first = TestEntities.Artist("First Artist", externalId: "first"); - track.TrackArtists = - [ - new TrackArtist { TrackId = track.Id, ArtistId = second.Id, Position = 1, Artist = second, Track = track }, - new TrackArtist { TrackId = track.Id, ArtistId = first.Id, Position = 0, Artist = first, Track = track } - ]; - - var response = Assert.IsType(TrackApplicationResponse.FromEntity(track, listensCount: 0)); - - Assert.Equal("First Artist, Second Artist", response.Artist); - Assert.Equal(track.ExternalId, response.ExternalId); - } - - [Fact] - public void FromEntity_ExternalTrackWithNoArtists_HasNullArtist() - { - var track = TestEntities.ExternalTrack(); - - var response = TrackApplicationResponse.FromEntity(track, listensCount: 0); - - Assert.Null(response.Artist); - } } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index a689c5f..b273b02 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -39,59 +39,24 @@ namespace Musify.Application.Tests.TestSupport modelBuilder.Entity().OwnsOne(track => track.Audio); modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); - modelBuilder.Entity().ToTable("LocalTracks"); - - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(track => track.Owner) .WithMany() .HasForeignKey(track => track.OwnerUserId) .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity().ToTable("ExternalTracks"); - - modelBuilder.Entity() - .HasIndex(track => new { track.Source, track.ExternalId }) - .IsUnique(); - - modelBuilder.Entity() - .HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId }); - - modelBuilder.Entity() - .HasOne(trackArtist => trackArtist.Track) - .WithMany(track => track.TrackArtists) - .HasForeignKey(trackArtist => trackArtist.TrackId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(trackArtist => trackArtist.Artist) - .WithMany(artist => artist.TrackArtists) - .HasForeignKey(trackArtist => trackArtist.ArtistId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasIndex(artist => artist.ExternalId) - .IsUnique(); - modelBuilder.Entity().OwnsOne(playList => playList.Pictures); modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(); modelBuilder.Entity().OwnsOne(album => album.Pictures); modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); - modelBuilder.Entity().ToTable("UserAlbums"); - - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(album => album.Owner) .WithMany() .HasForeignKey(album => album.OwnerUserId) .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity().ToTable("ExternalAlbums"); - - modelBuilder.Entity() - .HasIndex(album => new { album.Source, album.ExternalId }) - .IsUnique(); - modelBuilder.Entity() .HasOne(albumTrack => albumTrack.Album) .WithMany(album => album.AlbumTracks) @@ -121,7 +86,6 @@ namespace Musify.Application.Tests.TestSupport .HasOne(item => item.Track) .WithMany() .HasForeignKey(item => item.TrackId) - .IsRequired(false) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() @@ -154,22 +118,10 @@ namespace Musify.Application.Tests.TestSupport public DbSet Albums => Set(); - public DbSet UserAlbums => Set(); - - public DbSet ExternalAlbums => Set(); - public DbSet AlbumHasTracks => Set(); public DbSet Tracks => Set(); - public DbSet LocalTracks => Set(); - - public DbSet ExternalTracks => Set(); - - public DbSet Artists => Set(); - - public DbSet TrackArtists => Set(); - public DbSet UserHasTracks => Set(); public DbSet PlayListHasTracks => Set(); diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs index 4e16da1..1716c54 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -49,7 +49,7 @@ namespace Musify.Application.Tests.TestSupport TranscodeStatus = ProcessingStatus.Pending }; - public static LocalTrack LocalTrack( + public static Track Track( User owner, string title = "Test Track", double durationSeconds = 180, @@ -68,36 +68,6 @@ namespace Musify.Application.Tests.TestSupport Audio = audio ?? ProcessedAudio() }; - public static ExternalTrack ExternalTrack( - string externalId = "video-id", - string title = "Test External Track", - double durationSeconds = 180, - TrackSource source = TrackSource.YouTube, - TrackPictures? pictures = null, - TrackAudio? audio = null, - LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) => - new() - { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - DurationSeconds = durationSeconds, - LifeCycleStatus = lifeCycleStatus, - Source = source, - ExternalId = externalId, - Pictures = pictures ?? ProcessedPictures(), - Audio = audio ?? ProcessedAudio() - }; - - public static Artist Artist( - string name = "Test Artist", - string externalId = "artist-id") => - new() - { - Name = name, - NormalizedName = name.ToUpperInvariant(), - ExternalId = externalId - }; - public static PlayListPictures PlayListPictures() => new() { OriginalName = "original.webp", @@ -132,7 +102,7 @@ namespace Musify.Application.Tests.TestSupport LargeName = "large.webp" }; - public static UserAlbum UserAlbum( + public static Album Album( long ownerUserId, string title = "Test Album", string? description = null, @@ -148,20 +118,6 @@ namespace Musify.Application.Tests.TestSupport Pictures = pictures ?? AlbumPictures() }; - public static ExternalAlbum ExternalAlbum( - string externalId = "album-id", - string title = "Test External Album", - TrackSource source = TrackSource.YouTube, - string? thumbnailUrl = null) => - new() - { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - Source = source, - ExternalId = externalId, - ThumbnailUrl = thumbnailUrl - }; - public static UploadIntent UploadIntent( long userId, UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture, @@ -200,25 +156,13 @@ namespace Musify.Application.Tests.TestSupport public static MixItem MixItem( Guid mixId, - int position = 0, - MixItemSource source = MixItemSource.Musify, - Guid? trackId = null, - string? videoId = null, - string title = "Test Mix Item", - string? artist = "Test Artist", - double durationSeconds = 180, - bool isExplicit = false) => + Guid trackId, + int position = 0) => new() { MixId = mixId, Position = position, - Source = source, - TrackId = trackId, - VideoId = videoId, - Title = title, - Artist = artist, - DurationSeconds = durationSeconds, - IsExplicit = isExplicit + TrackId = trackId }; public static ListeningHistory ListeningHistory( diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index a86a0d5..55898d1 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -48,11 +48,11 @@ namespace Musify.Application.Tests.Tracks Assert.False(result.IsError); Assert.Equal("My Song", result.Value.Title); - var localResponse = Assert.IsType(result.Value); + var localResponse = Assert.IsType(result.Value); Assert.Equal(user.Id, localResponse.OwnerUserId); await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - var stored = await Database.LocalTracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); Assert.NotNull(stored); Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs index df70129..da76df3 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs @@ -18,7 +18,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); @@ -43,7 +43,7 @@ namespace Musify.Application.Tests.Tracks { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, stranger, track); var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken); @@ -51,22 +51,11 @@ namespace Musify.Application.Tests.Tracks Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); } - [Fact] - public async Task Handle_ExternalTrack_ReturnsUnauthorized() - { - var externalTrack = TestEntities.ExternalTrack(); - await SeedAsync(externalTrack); - - var result = await CreateHandler().Handle(new DeleteTrackCommand(1, externalTrack.Id), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } - [Fact] public async Task Handle_AudioStillProcessing_ReturnsConflict() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio()); + var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs index 97178d0..c52a703 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs @@ -12,8 +12,8 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst() { var owner = TestEntities.User(); - var older = TestEntities.LocalTrack(owner, "Older listen"); - var newer = TestEntities.LocalTrack(owner, "Newer listen"); + var older = TestEntities.Track(owner, "Older listen"); + var newer = TestEntities.Track(owner, "Newer listen"); await SeedAsync( owner, older, newer, TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)), diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs index 3d21c08..9805b3d 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs @@ -14,7 +14,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist() { var owner = TestEntities.User(1, "artist-name"); - var track = TestEntities.LocalTrack(owner, "My Song"); + var track = TestEntities.Track(owner, "My Song"); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); @@ -22,7 +22,7 @@ namespace Musify.Application.Tests.Tracks Assert.False(result.IsError); Assert.Equal("My Song", result.Value.Title); Assert.Equal(owner.Name, result.Value.Artist); - var localResponse = Assert.IsType(result.Value); + var localResponse = Assert.IsType(result.Value); Assert.Equal(owner.Id, localResponse.OwnerUserId); } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs index c1144a1..cd3b6fd 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs @@ -18,7 +18,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_ProcessedTrack_ReturnsRequestedSize(string size, string expectedFileName) { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken); @@ -41,7 +41,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_PictureNotProcessedYet_ReturnsNotFound() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner, pictures: TestEntities.PendingPictures()); + var track = TestEntities.Track(owner, pictures: TestEntities.PendingPictures()); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs index b3f30de..d69cc17 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs @@ -32,7 +32,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); @@ -59,7 +59,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_AudioNotProcessedYet_ReturnsConflict() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio()); + var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); @@ -71,7 +71,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); @@ -85,7 +85,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_AnonymousUser_AnonymousListeningEnabled_IssuesTicketWithoutRecordingHistory() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs index 1a24965..1e6f20e 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs @@ -14,8 +14,8 @@ namespace Musify.Application.Tests.Tracks { var owner = TestEntities.User(1, "owner"); var other = TestEntities.User(2, "other"); - var mine = TestEntities.LocalTrack(owner, "Mine"); - var theirs = TestEntities.LocalTrack(other, "Theirs"); + var mine = TestEntities.Track(owner, "Mine"); + var theirs = TestEntities.Track(other, "Theirs"); await SeedAsync( owner, other, mine, theirs, new UserHasTrack { UserId = owner.Id, TrackId = mine.Id }, @@ -34,7 +34,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber) { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track, new UserHasTrack { UserId = owner.Id, TrackId = track.Id }); var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs index df94c4f..a22dd53 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -1,26 +1,19 @@ -using ErrorOr; -using Musify.Application.Contracts; using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; -using Musify.Domain.ValueObjects; -using NSubstitute; using Xunit; namespace Musify.Application.Tests.Tracks { public sealed class GetTracksQueryHandlerTests : HandlerTestBase { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private GetTracksQueryHandler CreateHandler() => - new(Database, youTubeMusicService, NoOpLogger()); + private GetTracksQueryHandler CreateHandler() => new(Database); [Fact] - public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns_AndDoesNotCallYouTube() + public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns() { var owner = TestEntities.User(); - var trackA = TestEntities.LocalTrack(owner, "Alpha"); - var trackB = TestEntities.LocalTrack(owner, "Beta"); + var trackA = TestEntities.Track(owner, "Alpha"); + var trackB = TestEntities.Track(owner, "Beta"); await SeedAsync( owner, trackA, trackB, new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackA.Id }, @@ -31,81 +24,24 @@ namespace Musify.Application.Tests.Tracks Assert.False(result.IsError); Assert.Equal(2, result.Value.TotalItemCount); Assert.Equal(2, result.Value.Items.Count); - Assert.All(result.Value.Items, item => Assert.NotNull(item.Track)); - await youTubeMusicService.DidNotReceiveWithAnyArgs() - .SearchSongsAsync(default!, default!, TestContext.Current.CancellationToken); } [Fact] - public async Task Handle_NameFilter_ReturnsMatchingLocalTracks_FollowedByYouTubeResults() + public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks() { var owner = TestEntities.User(); - var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody"); - var other = TestEntities.LocalTrack(owner, "Imagine"); + var match = TestEntities.Track(owner, "Bohemian Rhapsody"); + var other = TestEntities.Track(owner, "Imagine"); await SeedAsync( owner, match, other, new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id }, new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id }); - var song = new YouTubeSongResult("video-1", "Bohemian Rhapsody (Live)", "Queen", "A Night at the Opera", - 354, "thumb.jpg", false, []); - youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any()) - .Returns(new YouTubeSearchResult([song], "next-token")); - - var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.Items.Count); - Assert.NotNull(result.Value.Items[0].Track); - Assert.Equal("Bohemian Rhapsody", result.Value.Items[0].Track!.Title); - Assert.Null(result.Value.Items[1].Track); - Assert.NotNull(result.Value.Items[1].YouTubeSong); - Assert.Equal("video-1", result.Value.Items[1].YouTubeSong!.VideoId); - Assert.Equal("next-token", result.Value.NextYoutubeContinuationToken); - Assert.False(result.Value.YoutubeUnavailable); - } - - [Fact] - public async Task Handle_NameFilter_SkipsYouTubeResultAlreadyProvisionedLocally() - { - var owner = TestEntities.User(); - var provisioned = TestEntities.ExternalTrack(externalId: "video-1", title: "Bohemian Rhapsody", source: TrackSource.YouTube); - await SeedAsync( - owner, provisioned, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = provisioned.Id }); - - var song = new YouTubeSongResult("video-1", "Bohemian Rhapsody", "Queen", "A Night at the Opera", - 354, "thumb.jpg", false, []); - youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any()) - .Returns(new YouTubeSearchResult([song], string.Empty)); - var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); Assert.False(result.IsError); var item = Assert.Single(result.Value.Items); - Assert.NotNull(item.Track); - Assert.Null(item.YouTubeSong); - } - - [Fact] - public async Task Handle_NameFilter_YouTubeUnavailable_StillReturnsLocalResults() - { - var owner = TestEntities.User(); - var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody"); - await SeedAsync( - owner, match, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id }); - - youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any()) - .Returns(Error.Forbidden(description: "The YouTube Music source is disabled.")); - - var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.NotNull(item.Track); - Assert.True(result.Value.YoutubeUnavailable); - Assert.Null(result.Value.NextYoutubeContinuationToken); + Assert.Equal("Bohemian Rhapsody", item.Track.Title); } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs index bd3a725..1061cae 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs @@ -25,8 +25,8 @@ namespace Musify.Application.Tests.Users public async Task Handle_ListensThisWeek_CountsDistinctTracksAndTotalSeconds() { var user = TestEntities.User(); - var trackA = TestEntities.LocalTrack(user, "Track A", durationSeconds: 200); - var trackB = TestEntities.LocalTrack(user, "Track B", durationSeconds: 100); + var trackA = TestEntities.Track(user, "Track A", durationSeconds: 200); + var trackB = TestEntities.Track(user, "Track B", durationSeconds: 100); await SeedAsync( user, trackA, trackB, TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddDays(-1)), @@ -44,7 +44,7 @@ namespace Musify.Application.Tests.Users public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo() { var user = TestEntities.User(); - var track = TestEntities.LocalTrack(user); + var track = TestEntities.Track(user); await SeedAsync( user, track, TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), @@ -59,7 +59,7 @@ namespace Musify.Application.Tests.Users public async Task Handle_GapBeforeToday_StopsStreakAtGap() { var user = TestEntities.User(); - var track = TestEntities.LocalTrack(user); + var track = TestEntities.Track(user); await SeedAsync( user, track, TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), @@ -74,7 +74,7 @@ namespace Musify.Application.Tests.Users public async Task Handle_NoListenToday_StreakIsZero() { var user = TestEntities.User(); - var track = TestEntities.LocalTrack(user); + var track = TestEntities.Track(user); await SeedAsync( user, track, TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); diff --git a/backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs deleted file mode 100644 index ff13a7d..0000000 --- a/backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using ErrorOr; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Contracts; -using Musify.Application.Services; -using Musify.Application.Tests.TestSupport; -using Musify.Application.YouTube; -using NSubstitute; -using Xunit; - -namespace Musify.Application.Tests.YouTube -{ - public sealed class GetYouTubeAlbumQueryHandlerTests : HandlerTestBase - { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private GetYouTubeAlbumQueryHandler CreateHandler() - { - var provisioner = new YouTubeTrackProvisioner( - Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - - return new GetYouTubeAlbumQueryHandler(youTubeMusicService, provisioner, NoOpLogger()); - } - - private static YouTubeAlbumDetail BuildDetail() => new( - new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []), - Description: "An album", - TotalDurationSeconds: 600, - Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]); - - [Fact] - public async Task Handle_EmptyAlbumId_ReturnsValidationError() - { - var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery(string.Empty), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } - - [Fact] - public async Task Handle_ServiceSucceeds_MaterializesAlbumAndReturnsDetail() - { - var detail = BuildDetail(); - youTubeMusicService.GetAlbumAsync("album-1", Arg.Any()).Returns(detail); - - var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("album-1"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Same(detail, result.Value); - Assert.NotEmpty(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task Handle_ServiceFails_ReturnsError() - { - youTubeMusicService.GetAlbumAsync("missing", Arg.Any()) - .Returns(Error.NotFound(description: "Album not found")); - - var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("missing"), TestContext.Current.CancellationToken); - - Assert.True(result.IsError); - } - } -} diff --git a/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs deleted file mode 100644 index b47cbc5..0000000 --- a/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs +++ /dev/null @@ -1,125 +0,0 @@ -using ErrorOr; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Application.Services; -using Musify.Application.Tests.TestSupport; -using Musify.Application.YouTube; -using Musify.Application.YouTube.Responses; -using NSubstitute; -using Xunit; - -namespace Musify.Application.Tests.YouTube -{ - public sealed class ResolveYouTubeTrackStreamCommandHandlerTests : HandlerTestBase - { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - private readonly IStreamTicketService ticketService = Substitute.For(); - - private ResolveYouTubeTrackStreamCommandHandler CreateHandler(PlaybackConfiguration? playback = null) - { - playback ??= TestConfigurations.Playback(); - var provisioner = new YouTubeTrackProvisioner( - Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - var issuer = new TrackStreamIssuer( - Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway(), playback); - - return new ResolveYouTubeTrackStreamCommandHandler( - Database, youTubeMusicService, provisioner, issuer, playback, NoOpLogger()); - } - - [Fact] - public async Task Handle_AlreadyProcessedTrack_IssuesServerStreamWithoutCallingYouTubeForAudio() - { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - - var user = TestEntities.User(1); - var track = TestEntities.ExternalTrack(externalId: "abc123"); - await SeedAsync(user, track); - - var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("abc123", 1), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode); - Assert.Equal("token", result.Value.Ticket); - await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken); - } - - [Fact] - public async Task Handle_UnknownTrack_ProvisionsItAndRecordsListeningHistory() - { - youTubeMusicService.GetAudioStreamAsync("new-video", Arg.Any()) - .Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300)); - youTubeMusicService.GetSongAsync("new-video", Arg.Any()) - .Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, [])); - youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); - await SeedAsync(TestEntities.User(42)); - - var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("new-video", 42), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(YouTubeStreamMode.YouTube, result.Value.Mode); - Assert.Equal("https://youtube.example/stream", result.Value.StreamUrl); - Assert.NotNull(result.Value.TrackId); - - var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(42, history.UserId); - } - - [Fact] - public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized() - { - var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); - - var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("abc123", UserId: null), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken); - } - - [Fact] - public async Task Handle_AnonymousUser_AlreadyProcessedTrack_IssuesServerStreamWithoutRecordingHistory() - { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var track = TestEntities.ExternalTrack(externalId: "abc123"); - await SeedAsync(track); - - var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); - - var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("abc123", UserId: null), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode); - Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task Handle_AnonymousUser_FragmentCapConfigured_UnprovisionedTrack_ReturnsConflictWithoutCallingYouTube() - { - var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30)); - - var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("not-provisioned-yet", UserId: null), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken); - } - - [Fact] - public async Task Handle_AnonymousUser_NoFragmentCap_UnprovisionedTrack_FallsBackToDirectYouTubeStream() - { - youTubeMusicService.GetAudioStreamAsync("new-video", Arg.Any()) - .Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300)); - youTubeMusicService.GetSongAsync("new-video", Arg.Any()) - .Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, [])); - youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); - - var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 0)); - - var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("new-video", UserId: null), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(YouTubeStreamMode.YouTube, result.Value.Mode); - Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - } - } -} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs index 62afb43..730b9b4 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs @@ -10,7 +10,7 @@ namespace Musify.Infrastructure.Tests.Persistence public sealed class DatabaseTests(InfrastructureTestFixture fixture) { [Fact] - public async Task SaveChangesAsync_LocalTrackWithOwnedTypes_RoundTripsThroughPostgres() + public async Task SaveChangesAsync_TrackWithOwnedTypes_RoundTripsThroughPostgres() { await using var database = fixture.CreateDatabase(); @@ -19,7 +19,7 @@ namespace Musify.Infrastructure.Tests.Persistence await database.Users.AddAsync(user, TestContext.Current.CancellationToken); var trackTitle = "Round Trip Track"; - var track = new LocalTrack + var track = new Track { Title = trackTitle, NormalizedTitle = trackTitle.ToUpperInvariant(), @@ -29,32 +29,16 @@ namespace Musify.Infrastructure.Tests.Persistence Pictures = new TrackPictures { OriginalName = "cover.webp", ProcessingStatus = ProcessingStatus.Pending }, Audio = new TrackAudio { OriginalName = "song.mp3", TranscodeStatus = ProcessingStatus.Pending } }; - await database.LocalTracks.AddAsync(track, TestContext.Current.CancellationToken); + await database.Tracks.AddAsync(track, TestContext.Current.CancellationToken); await database.SaveChangesAsync(TestContext.Current.CancellationToken); await using var reloaded = fixture.CreateDatabase(); var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken); - Assert.IsType(stored); Assert.Equal(trackTitle, stored.Title); Assert.Equal("cover.webp", stored.Pictures.OriginalName); Assert.Equal("song.mp3", stored.Audio.OriginalName); - Assert.Equal(user.Id, ((LocalTrack)stored).OwnerUserId); - } - - [Fact] - public async Task ExternalTracks_SourceAndExternalIdUniqueIndex_RejectsDuplicates() - { - await using var database = fixture.CreateDatabase(); - - var videoId = $"dup-{Guid.NewGuid():N}"; - await database.ExternalTracks.AddAsync(BuildExternalTrack(videoId), TestContext.Current.CancellationToken); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - - await using var second = fixture.CreateDatabase(); - await second.ExternalTracks.AddAsync(BuildExternalTrack(videoId), TestContext.Current.CancellationToken); - - await Assert.ThrowsAsync(() => second.SaveChangesAsync(TestContext.Current.CancellationToken)); + Assert.Equal(user.Id, stored.OwnerUserId); } [Fact] @@ -65,33 +49,32 @@ namespace Musify.Infrastructure.Tests.Persistence var userName = "cascade-user"; var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; var albumTitle = "Cascade Album"; - var album = new UserAlbum { Title = albumTitle, NormalizedTitle = albumTitle.ToUpperInvariant(), OwnerUserId = user.Id }; - var track = BuildExternalTrack($"cascade-{Guid.NewGuid():N}"); + var album = new Album { Title = albumTitle, NormalizedTitle = albumTitle.ToUpperInvariant(), OwnerUserId = user.Id }; + var track = BuildTrack(user, $"cascade-{Guid.NewGuid():N}"); var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }; await database.AddRangeAsync(user, album, track, link); await database.SaveChangesAsync(TestContext.Current.CancellationToken); await using var deleter = fixture.CreateDatabase(); - var albumToDelete = await deleter.UserAlbums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken); - deleter.UserAlbums.Remove(albumToDelete); + var albumToDelete = await deleter.Albums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken); + deleter.Albums.Remove(albumToDelete); await deleter.SaveChangesAsync(TestContext.Current.CancellationToken); await using var verifier = fixture.CreateDatabase(); Assert.False(await verifier.AlbumHasTracks.AnyAsync(l => l.AlbumId == album.Id, TestContext.Current.CancellationToken)); - Assert.True(await verifier.ExternalTracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken)); + Assert.True(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken)); } - private static ExternalTrack BuildExternalTrack(string externalId) + private static Track BuildTrack(User owner, string title) { - var title = "External"; - return new ExternalTrack + return new Track { Title = title, NormalizedTitle = title.ToUpperInvariant(), DurationSeconds = 100, - Source = TrackSource.YouTube, - ExternalId = externalId, + OwnerUserId = owner.Id, + Owner = owner, Pictures = new TrackPictures(), Audio = new TrackAudio() }; diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs deleted file mode 100644 index 632b041..0000000 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -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); - } - } -} diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example index 7bcb394..b6befc8 100644 --- a/deploy/.env.prod.example +++ b/deploy/.env.prod.example @@ -63,8 +63,3 @@ AUTH_ALLOW_INSECURE_HTTP=false AUTH_REQUIRE_HTTPS=true # Largest request body nginx accepts (audio uploads go through the web player). MAX_UPLOAD_SIZE=1024m -# Disk nginx gives to the cached YouTube artwork it serves for the web player. -THUMBNAIL_CACHE_SIZE=2g -# Extra yt-dlp flags for the Worker. The cookie jar avoids YouTube's bot checks; -# drop it here if you do not have one: deploy/secrets/ is mounted at /secrets. -YTDLP_ADDITIONAL_ARGUMENTS=--cookies /secrets/youtube_cookies.txt --extractor-retries 1 --socket-timeout 15 -N 4 diff --git a/deploy/README.md b/deploy/README.md index bcc2a5f..03e6b89 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -7,7 +7,7 @@ same set of compose files — no wrapper scripts, just `docker compose`. |---|---| | `compose.yml` | Every service, including the one-shot bootstrap jobs. No published ports, no domains, no environment-specific values. | | `compose.dev.yml` | Development overlay: publishes the ports to the host, adds pgAdmin. | -| `compose.prod.yml` | Production overlay: adds the nginx edge and the mounted secrets. | +| `compose.prod.yml` | Production overlay: adds the nginx edge. | | `.env` / `.env.prod` | The values compose interpolates. Not versioned; templates in `.env.example` / `.env.prod.example`. | The four applications (`api`, `worker`, `gateway`, `web-player`) sit behind the @@ -76,9 +76,9 @@ Add `--profile tools` to also start pgAdmin. | pgAdmin (`--profile tools`) | `5050` | Postgres UI | | API, gateway, web player (`--profile apps`) | `5111` / `8081` / `3000` | | -Host requirements: .NET 10 SDK, Node 22, Docker Desktop. (ffmpeg and yt-dlp -are only needed on the host if you run the Worker outside Docker — inside -Docker its image already has them.) +Host requirements: .NET 10 SDK, Node 22, Docker Desktop. (ffmpeg is only +needed on the host if you run the Worker outside Docker — inside Docker its +image already has it.) ### Why `host.docker.internal` @@ -136,13 +136,6 @@ infra containers and only rebuilds and restarts the apps). Only nginx publishes ports (80/443); everything else is reachable only on the internal compose network. -### Optional secrets - -`deploy/secrets/` is mounted read-only at `/secrets` in the Worker. Drop a -`youtube_cookies.txt` there if you want yt-dlp to use a cookie jar — it is -referenced from `YTDLP_ADDITIONAL_ARGUMENTS` in `.env.prod`, which you can -empty if you do not have one. - --- ## Layout @@ -151,10 +144,9 @@ empty if you do not have one. deploy/ ├─ compose.yml # all services, incl. keys-init / migrate / zitadel-init ├─ compose.dev.yml # dev overlay: host ports, pgAdmin -├─ compose.prod.yml # prod overlay: nginx edge, secrets +├─ compose.prod.yml # prod overlay: nginx edge ├─ .env.example / .env.prod.example ├─ keys/ # RS256 stream-ticket key pair (generated) -├─ secrets/ # mounted into the Worker (prod, optional) ├─ nginx/ │ ├─ templates/default.conf.template # vhosts, rendered with the domain by envsubst │ ├─ snippets/proxy.conf # proxy headers shared by every vhost diff --git a/deploy/compose.prod.yml b/deploy/compose.prod.yml index b00ac3f..344b226 100644 --- a/deploy/compose.prod.yml +++ b/deploy/compose.prod.yml @@ -13,11 +13,6 @@ name: musify-prod # the first command just wrote into deploy/.env.prod. See deploy/README.md. services: - worker: - # yt-dlp reads the YouTube cookie jar from here (see YTDLP_ADDITIONAL_ARGUMENTS). - volumes: - - ./secrets:/secrets:ro - # zitadel-init (compose.yml) edits whichever env file is mounted here. zitadel-init: volumes: @@ -40,7 +35,6 @@ services: # The filter keeps it from eating nginx's own $host / $remote_addr. MUSIFY_ROOT_DOMAIN: ${ROOT_DOMAIN} MUSIFY_MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-1024m} - MUSIFY_THUMBNAIL_CACHE_SIZE: ${THUMBNAIL_CACHE_SIZE:-2g} NGINX_ENVSUBST_FILTER: ^MUSIFY_ ports: - "80:80" diff --git a/deploy/compose.yml b/deploy/compose.yml index 5773ed2..cdb5b6e 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -447,7 +447,6 @@ services: DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT} AudioTranscoder__Routes__WorkingDirectory: /tmp/musify Workers__Routes__TemporaryFilesDirectory: /tmp/musify - YtDlp__AdditionalArguments: ${YTDLP_ADDITIONAL_ARGUMENTS:-} restart: unless-stopped # YARP in front of the SeaweedFS filer: validates the RS256 stream ticket and diff --git a/deploy/nginx/templates/default.conf.template b/deploy/nginx/templates/default.conf.template index 2493e89..ed2ac8a 100644 --- a/deploy/nginx/templates/default.conf.template +++ b/deploy/nginx/templates/default.conf.template @@ -9,12 +9,6 @@ resolver 127.0.0.11 valid=10s; -# YouTube artwork the web player proxies from Google. Caching it on disk keeps -# repeated searches off both the node process and Google, which answers 429 -# once a client pulls a whole grid of covers at a time. -proxy_cache_path /var/cache/nginx/thumbnails levels=1:2 keys_zone=thumbnails:10m - max_size=${MUSIFY_THUMBNAIL_CACHE_SIZE} inactive=30d use_temp_path=off; - map $http_upgrade $connection_upgrade { default upgrade; '' close; @@ -40,21 +34,6 @@ server { ssl_certificate /etc/nginx/certs/origin.pem; ssl_certificate_key /etc/nginx/certs/origin.key; - location = /api/youtube/thumbnail { - set $upstream http://web-player:3000; - proxy_pass $upstream; - include /etc/nginx/snippets/proxy.conf; - - proxy_cache thumbnails; - proxy_cache_key $uri$is_args$args; - proxy_cache_valid 200 30d; - proxy_cache_valid 400 404 5m; - proxy_cache_lock on; - proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; - proxy_cache_background_update on; - add_header X-Cache-Status $upstream_cache_status; - } - location / { set $upstream http://web-player:3000; proxy_pass $upstream; diff --git a/docs/development.md b/docs/development.md index 33c0809..1acbc55 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,7 +83,7 @@ dotnet test backend/Musify.slnx | Proyecto | Cubre | Docker | |---|---|---| | `Musify.Domain.Tests` | Lógica de los value objects (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Las entidades son en su mayoría anémicas — sin comportamiento propio, nada más que testear ahí. | No | -| `Musify.Application.Tests` | Los ~45 handlers y servicios de `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes, YouTube). | No | +| `Musify.Application.Tests` | Los ~45 handlers y servicios de `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes). | No | | `Musify.Infrastructure.Tests` | `Database`/migraciones, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `SingleFlightCache`, `PlayListPresetSeeder`. | **Sí** | | `Musify.Api.Tests` | Endpoints HTTP reales (routing, auth, `ValidationFilter`, mapeo `ErrorOr`→HTTP) vía `WebApplicationFactory`, más `ErrorOrHttpExtensions`/`CurrentUser`/validators FluentValidation en aislado. | **Sí** | | `Musify.StreamingGateway.Tests` | `TicketValidator` (RS256) y `TicketValidationMiddleware` (traversal, límites de prefijo, extracción de ticket). | No | @@ -102,8 +102,8 @@ NSubstitute, no hay `IQueryable` detrás de un mock. En su lugar, del proveedor InMemory de EF Core, SQLite además soporta transacciones, que dos handlers usan). `TestSupport/TestEntities.cs` y `TestConfigurations.cs` construyen entidades y configuración con valores por defecto sensatos. -`IStorageService`, `IEventBus`, `IYouTubeMusicService`, etc. sí se mockean con -NSubstitute, al ser interfaces normales. +`IStorageService`, `IEventBus`, etc. sí se mockean con NSubstitute, al ser +interfaces normales. ### Infrastructure.Tests y Api.Tests: Testcontainers @@ -117,8 +117,8 @@ migraciones EF Core reales aplicadas contra el Postgres efímero. `Api.Tests` usa `WebApplicationFactory` contra ese mismo Postgres — routing, auth y validación reales — pero sustituye por fakes los servicios que -hablan por red (`IStorageService`, `IEventBus`, `IYouTubeMusicService`, -`IStreamTicketService`) y el esquema de autenticación JWT real por un handler +hablan por red (`IStorageService`, `IEventBus`, `IStreamTicketService`) y el +esquema de autenticación JWT real por un handler de prueba (ver `TestSupport/FakeAuthenticationHandler.cs`) que autentica según un header, sin necesitar un Zitadel real. @@ -142,11 +142,10 @@ se sobreescribe con variables de entorno (`Seccion__Clave`) desde Secciones clave: `Authentication` (Zitadel), `Database`, `MassTransit`, `InfrastructureStorage` (S3), `ApplicationStorage` (bucket), `Track`, `PlayList`, -`UploadIntent`, `AudioTranscoder`, `YtDlp`, `Workers`, `StreamGateway` / -`StreamTicket`. +`UploadIntent`, `AudioTranscoder`, `Workers`, `StreamGateway` / `StreamTicket`. ## Requisitos del host - .NET 10 SDK y Node 22. -- **ffmpeg** y **yt-dlp** en el PATH (transcode y descarga de audio del Worker). +- **ffmpeg** en el PATH (transcode de audio del Worker). - Docker Desktop.