diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..f641b22 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,24 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "musify-web", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "web-player", "run", "dev"], + "port": 3000, + "autoPort": true + }, + { + "name": "musify-api", + "runtimeExecutable": "dotnet", + "runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"], + "port": 5111 + }, + { + "name": "musify-streaming-gateway", + "runtimeExecutable": "dotnet", + "runtimeArgs": ["run", "--project", "backend/Hosts/Musify.StreamingGateway"], + "port": 8081 + } + ] +} diff --git a/.gitattributes b/.gitattributes index 9f3e779..da2c853 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,6 +4,13 @@ # Force LF for shell scripts *.sh text eol=lf +# Env templates get `cp`-ed straight into deploy/.env / deploy/.env.prod, +# which containers (e.g. zitadel-init in deploy/compose.yml) parse with +# plain shell text tools — keep them LF so a Windows checkout can't +# reintroduce CRLF into a value that then looks non-empty but isn't. +.env.example text eol=lf +.env.prod.example text eol=lf + # Treat common binaries as binary *.png binary *.jpg binary diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index c13be9d..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,4 +0,0 @@ -# Copilot Instructions - -## Project Guidelines -- No crear pruebas/tests si el usuario no lo solicita explícitamente; evitar cambios no pedidos. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 49ba807..151e9d4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,15 @@ artifacts/ Thumbs.db .DS_Store -# Secrets / local +# Environment files (deploy/.env, deploy/.env.prod, web-player/.env, ...) +.env +.env.* *.env +!.env.example +!.env.prod.example + +# Deploy secrets and runtime state +deploy/keys/ +deploy/secrets/ +deploy/nginx/certs/ +deploy/zitadel/.output/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a319a7 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Musify + +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 +web-player/ SvelteKit 2 + Svelte 5 + Tailwind 4 web client +deploy/ Docker Compose stacks — self-contained, no wrapper scripts +docs/ What each piece does and why (in Spanish) +``` + +```sh +dotnet test backend/Musify.slnx # backend/Tests/ — Docker running (Testcontainers: Postgres, SeaweedFS) +``` + +## Run it + +Development (Docker Desktop): + +```sh +docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d +``` + +Infrastructure plus stream-ticket keys, database migrations and Zitadel OIDC +provisioning — all as one-shot jobs baked into the compose file. Apps run from +the IDE by default; add `--profile apps up -d --build` as a second command to +run them in Docker instead. + +Production, on a server with a domain: + +```sh +cp deploy/.env.prod.example deploy/.env.prod # fill in the domain and secrets +docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d +docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build +``` + +Idempotent — run the same commands again to deploy an update. See +[deploy/README.md](deploy/README.md) for the details, the port map and the +production prerequisites. + +## Components + +| Service | Port (dev) | Role | +|---|---|---| +| `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 | +| `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 | + +More in [docs/](docs/README.md): [architecture](docs/architecture.md), +[authentication](docs/authentication.md), [storage](docs/storage.md), +[streaming](docs/streaming.md), [media processing](docs/media-processing.md), +[development](docs/development.md). diff --git a/backend/.dockerignore b/backend/.dockerignore index 32afcf6..4916b04 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -8,3 +8,5 @@ .claude/ **/DesignSettings.json *.user +# Test projects: never shipped, no app Dockerfile needs them in its build context. +Tests/ diff --git a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs new file mode 100644 index 0000000..219705e --- /dev/null +++ b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs @@ -0,0 +1,82 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.Entities; + +namespace Musify.Application.Albums +{ + public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) + : ICommand>; + + public class AddTrackToAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken) + { + var album = await database.Albums + .AsNoTracking() + .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); + if (album == null) + { + logger.LogInformation("Album {AlbumId} not found", request.AlbumId); + return Error.NotFound(); + } + + if (album.OwnerUserId != request.UserId) + { + logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId); + return Error.Unauthorized(); + } + + var track = await database.Tracks + .AsNoTracking() + .SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); + if (track == null) + { + logger.LogInformation("Track {TrackId} not found", request.TrackId); + return Error.NotFound(description: $"Track {request.TrackId} not found"); + } + + if (track.OwnerUserId != request.UserId) + { + logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId); + return Error.Unauthorized(); + } + + var alreadyAdded = await database.AlbumHasTracks + .AsNoTracking() + .AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken); + if (alreadyAdded) + { + logger.LogInformation("Track {TrackId} already in album {AlbumId}", request.TrackId, request.AlbumId); + return Error.Conflict(description: "Track is already in the album."); + } + + var albumTracksQuery = database.AlbumHasTracks + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId); + + var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken); + var nextTrackNumber = hasTracks + ? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1 + : 1; + + await database.AlbumHasTracks.AddAsync(new AlbumHasTrack + { + AlbumId = request.AlbumId, + TrackId = request.TrackId, + TrackNumber = nextTrackNumber + }, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}", + request.TrackId, request.AlbumId, nextTrackNumber); + + return new Success(); + } + } +} diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs new file mode 100644 index 0000000..842f97c --- /dev/null +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -0,0 +1,128 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Shared; +using Musify.Application.Events; +using Musify.Application.Albums.Responses; +using Musify.Application.Services; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record CreateAlbumCommand( + long UserId, + string Title, + string? Description, + int? ReleaseYear, + Guid PictureIntentId) + : ICommand>; + + public class CreateAlbumCommandHandler( + IEventBus eventBus, + IDatabase database, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + AlbumConfiguration albumConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(user => user.Id == request.UserId, cancellationToken); + if (!userExists) + { + logger.LogWarning("User {UserId} not found", request.UserId); + return Error.NotFound(description: $"User {request.UserId} not found"); + } + + var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken); + if (pictureIntent.IsError) + return pictureIntent.Errors; + + var intent = pictureIntent.Value; + var title = request.Title.Trim(); + + var album = new Album + { + OwnerUserId = request.UserId, + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + Description = request.Description, + ReleaseYear = request.ReleaseYear, + Pictures = new AlbumPictures + { + OriginalName = intent.ObjectName, + SmallName = intent.ObjectName, + MediumName = intent.ObjectName, + LargeName = intent.ObjectName + } + }; + + await database.Albums.AddAsync(album, cancellationToken); + + var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName); + var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken); + if (publishResult.IsError) + return publishResult.Errors; + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to save album for user {UserId}", request.UserId); + return Error.Failure(description: $"Failed to create album for user {request.UserId}"); + } + + logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId); + + return AlbumApplicationResponse.FromEntity(album, trackCount: 0); + } + + private async Task> PublishCreateAlbumEventAsync( + Guid albumId, + UploadIntent pictureIntent, + string finalPictureKey, + CancellationToken cancellationToken) + { + try + { + await eventBus.PublishAsync( + new CreateAlbumResourcesEvent( + albumId, + pictureIntent.Id, + storageConfiguration.Bucket, + pictureIntent.Key, + finalPictureKey, + new ImageSize( + albumConfiguration.Routes.SmallPicturesPath, + albumConfiguration.PicturesSizes.SmallPictureWidth, + albumConfiguration.PicturesSizes.SmallPictureHeight), + new ImageSize( + albumConfiguration.Routes.MediumPicturesPath, + albumConfiguration.PicturesSizes.MediumPictureWidth, + albumConfiguration.PicturesSizes.MediumPictureHeight), + new ImageSize( + albumConfiguration.Routes.LargePicturesPath, + albumConfiguration.PicturesSizes.LargePictureWidth, + albumConfiguration.PicturesSizes.LargePictureHeight)), + cancellationToken); + return new Success(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId); + return Error.Failure(description: $"Failed to publish create album event for album {albumId}"); + } + } + } +} diff --git a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs new file mode 100644 index 0000000..182a1fb --- /dev/null +++ b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs @@ -0,0 +1,50 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record DeleteAlbumCommand(long UserId, Guid AlbumId) + : ICommand>; + + public class DeleteAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) + { + var album = await database.Albums + .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); + if (album == null) + { + logger.LogDebug("Album {AlbumId} not found", request.AlbumId); + return Error.NotFound(); + } + + if (album.OwnerUserId != request.UserId) + { + logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId); + return Error.Unauthorized(); + } + + database.Albums.Remove(album); + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId); + return Error.Failure(description: $"Failed to delete album {request.AlbumId}"); + } + + logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId); + + return new Success(); + } + } +} diff --git a/backend/Core/Musify.Application/Albums/GetAlbumById.cs b/backend/Core/Musify.Application/Albums/GetAlbumById.cs new file mode 100644 index 0000000..97e92c2 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/GetAlbumById.cs @@ -0,0 +1,38 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record GetAlbumByIdQuery(Guid Id) + : IQuery>; + + public class GetAlbumByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) + { + var album = await database.Albums + .AsNoTracking() + .Where(a => a.Id == request.Id) + .Select(a => new + { + Album = a, + TrackCount = a.AlbumTracks.Count, + CoverTrackIds = a.AlbumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(AlbumApplicationResponse.CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToList() + }) + .SingleOrDefaultAsync(cancellationToken); + + if (album == null) + return Error.NotFound(); + + return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds); + } + } +} diff --git a/backend/Core/Musify.Application/Albums/GetAlbumCover.cs b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs new file mode 100644 index 0000000..0ade1b8 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs @@ -0,0 +1,49 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using MimeMapping; +using Musify.Application.Configuration; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record GetAlbumCoverQuery(Guid AlbumId, string Size) + : IQuery>; + + public record AlbumCoverLocation(string Bucket, string Key, string ContentType); + + public class GetAlbumCoverQueryHandler( + IDatabase database, + ApplicationStorageConfiguration storageConfiguration, + AlbumConfiguration albumConfiguration) + : IQueryHandler> + { + public async ValueTask> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken) + { + var pictures = await database.Albums.AsNoTracking() + .Where(a => a.Id == request.AlbumId) + .Select(a => a.Pictures) + .SingleOrDefaultAsync(cancellationToken); + + if (pictures == null) + return Error.NotFound(); + + var routes = albumConfiguration.Routes; + var (name, key) = request.Size.ToLowerInvariant() switch + { + "small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)), + "large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)), + _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName)) + }; + + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) + return Error.NotFound(); + + var contentType = MimeUtility.GetMimeMapping(name); + return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType); + } + + private static string? BuildKey(Func builder, string? name) + => string.IsNullOrEmpty(name) ? null : builder(name); + } +} diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs new file mode 100644 index 0000000..9d80336 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs @@ -0,0 +1,49 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; +using X.PagedList; +using X.PagedList.EF; + +namespace Musify.Application.Albums +{ + public record GetAlbumTracksQuery( + Guid AlbumId, + int PageNumber, + int PageSize) + : IQuery>>; + + public class GetAlbumTracksQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken) + { + var albumExists = await database.Albums + .AsNoTracking() + .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); + if (!albumExists) + return Error.NotFound(); + + var tracksQuery = database.AlbumHasTracks + .AsNoTracking() + .Include(albumTrack => albumTrack.Track.Owner) + .Include(albumTrack => albumTrack.Track.Tags) + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId); + + var totalCount = await tracksQuery.CountAsync(cancellationToken); + + var pagedEntities = await tracksQuery + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + var pagedTracks = new StaticPagedList( + pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + + return PaginatedResponse.FromPagedList(pagedTracks); + } + } +} diff --git a/backend/Core/Musify.Application/Albums/GetAlbums.cs b/backend/Core/Musify.Application/Albums/GetAlbums.cs new file mode 100644 index 0000000..f149d35 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/GetAlbums.cs @@ -0,0 +1,60 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; +using X.PagedList; +using X.PagedList.EF; + +namespace Musify.Application.Albums +{ + public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) + : IQuery>; + + public class GetAlbumsQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) + { + var albumsQuery = database.Albums + .AsNoTracking() + .AsQueryable(); + + if (!string.IsNullOrEmpty(request.Title)) + { + var normalizedTitle = request.Title.Trim().ToUpperInvariant(); + albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle)); + } + + var totalCount = await albumsQuery.CountAsync(cancellationToken); + + var pagedEntities = await albumsQuery + .OrderByDescending(album => album.CreatedAt) + .Select(album => new + { + Album = album, + TrackCount = album.AlbumTracks.Count, + CoverTrackIds = album.AlbumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(AlbumApplicationResponse.CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToList() + }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + var items = pagedEntities + .Select(entry => AlbumSearchItemResponse.FromAlbum( + AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))) + .ToList(); + + return new AlbumsSearchResponse( + items, + pagedEntities.PageNumber, + pagedEntities.PageSize, + pagedEntities.PageCount, + pagedEntities.TotalItemCount, + pagedEntities.HasPreviousPage, + pagedEntities.HasNextPage); + } + } +} diff --git a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs new file mode 100644 index 0000000..8b7b5b2 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs @@ -0,0 +1,53 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using X.PagedList; +using X.PagedList.EF; + +namespace Musify.Application.Albums +{ + public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize) + : IQuery>>; + + public class GetAlbumsByUserIdQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(user => user.Id == request.UserId, cancellationToken); + if (!userExists) + return Error.NotFound(description: $"User {request.UserId} not found"); + + var albumsQuery = database.Albums + .AsNoTracking() + .Where(album => album.OwnerUserId == request.UserId); + + var totalCount = await albumsQuery.CountAsync(cancellationToken); + + var pagedEntities = await albumsQuery + .OrderByDescending(album => album.CreatedAt) + .Select(album => new + { + Album = album, + TrackCount = album.AlbumTracks.Count, + CoverTrackIds = album.AlbumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(AlbumApplicationResponse.CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToList() + }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + var pagedAlbums = new StaticPagedList( + pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + + return PaginatedResponse.FromPagedList(pagedAlbums); + } + } +} diff --git a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs new file mode 100644 index 0000000..59e8631 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs @@ -0,0 +1,61 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) + : IQuery>>; + + public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) + { + var listenedTrackIds = database.ListeningHistories + .AsNoTracking() + .Where(history => history.UserId == request.UserId); + + var ranked = await database.AlbumHasTracks + .AsNoTracking() + .Join( + listenedTrackIds, + albumTrack => albumTrack.TrackId, + history => history.TrackId, + (albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt }) + .GroupBy(entry => entry.AlbumId) + .Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) }) + .OrderByDescending(entry => entry.LastListenedAt) + .Take(request.Limit) + .ToListAsync(cancellationToken); + + if (ranked.Count == 0) + return new List(); + + var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); + + var albums = await database.Albums + .AsNoTracking() + .Where(album => albumIds.Contains(album.Id)) + .Select(album => new + { + Album = album, + TrackCount = album.AlbumTracks.Count, + CoverTrackIds = album.AlbumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(AlbumApplicationResponse.CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToList() + }) + .ToDictionaryAsync(entry => entry.Album.Id, cancellationToken); + + return ranked + .Where(entry => albums.ContainsKey(entry.AlbumId)) + .Select(entry => albums[entry.AlbumId]) + .Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)) + .ToList(); + } + } +} diff --git a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs new file mode 100644 index 0000000..d913840 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs @@ -0,0 +1,59 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) + : ICommand>; + + public class RemoveTrackFromAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) + { + var album = await database.Albums + .AsNoTracking() + .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); + if (album == null) + { + logger.LogInformation("Album {AlbumId} not found", request.AlbumId); + return Error.NotFound(); + } + + if (album.OwnerUserId != request.UserId) + { + logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId); + return Error.Unauthorized(); + } + + var albumTracks = await database.AlbumHasTracks + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) + .OrderBy(albumTrack => albumTrack.TrackNumber) + .ToListAsync(cancellationToken); + + var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId); + if (link == null) + { + logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId); + return Error.NotFound(description: "Track is not in the album."); + } + + database.AlbumHasTracks.Remove(link); + + var trackNumber = 1; + foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id)) + remaining.TrackNumber = trackNumber++; + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId); + + return new Success(); + } + } +} diff --git a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs new file mode 100644 index 0000000..e0f0abc --- /dev/null +++ b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs @@ -0,0 +1,105 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Albums.Responses; +using Musify.Application.Services; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record RequestAlbumPictureUploadCommand( + long UserId, + string FileType, + string ContentType, + long? ExpectedSizeBytes = null) + : ICommand>; + + public class RequestAlbumPictureUploadCommandHandler( + IDatabase database, + UploadIntentValidator uploadIntentValidator, + IStorageService storageService, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + AlbumConfiguration albumConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.UserId, cancellationToken); + if (!userExists) + { + logger.LogWarning("User {UserId} not found", request.UserId); + return Error.NotFound(description: $"User {request.UserId} not found"); + } + + var effectiveSizeBytes = request.ExpectedSizeBytes + ?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; + + var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; + + var tempKey = albumConfiguration.Routes.BuildTempPicturePath( + uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName); + + try + { + var uploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, + tempKey, + request.ContentType, + TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), + cancellationToken); + + await using var transaction = await database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, cancellationToken); + + var quotaCheck = await uploadIntentValidator.CheckQuotaAsync( + uploadIntentConfiguration, + request.UserId, effectiveSizeBytes, 1, cancellationToken); + if (quotaCheck.IsError) + { + logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId); + return quotaCheck.Errors; + } + + var intent = new UploadIntent + { + UserId = request.UserId, + Bucket = storageConfiguration.Bucket, + Key = tempKey, + ObjectName = objectName, + ContentType = request.ContentType, + ExpectedSizeBytes = effectiveSizeBytes, + Purpose = UploadIntentPurpose.AlbumPicture, + ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), + }; + + await database.UploadIntents.AddAsync(intent, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key})", intent.Id, request.UserId, tempKey); + + return new AlbumPictureUploadResponse( + intent.Id, + storageConfiguration.Bucket, + tempKey, + objectName, + request.ContentType, + uploadIntentConfiguration.UploadUrlExpiresInSeconds, + uploadUrl); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId); + return Error.Failure(description: "Failed to generate upload URL"); + } + } + } +} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs new file mode 100644 index 0000000..ea03647 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; +using Musify.Domain.Entities; +using Musify.Application.Serialization; + +namespace Musify.Application.Albums.Responses +{ + public record AlbumApplicationResponse( + Guid Id, + string Title, + string? Description, + int? ReleaseYear, + [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, + int TrackCount, + string? SmallImageKeyName, + string? MediumImageKeyName, + string? LargeImageKeyName, + DateTime CreatedAt, + DateTime UpdatedAt, + IReadOnlyList CoverTrackIds) + { + public const int CoverTrackCount = 4; + + public static AlbumApplicationResponse FromEntity( + Album album, + int trackCount, + IReadOnlyList? coverTrackIds = null) + { + return new AlbumApplicationResponse( + album.Id, + album.Title, + album.Description, + album.ReleaseYear, + album.OwnerUserId, + trackCount, + album.Pictures?.SmallName, + album.Pictures?.MediumName, + album.Pictures?.LargeName, + album.CreatedAt, + album.UpdatedAt, + coverTrackIds ?? []); + } + } +} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs new file mode 100644 index 0000000..ab3dcd3 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs @@ -0,0 +1,11 @@ +namespace Musify.Application.Albums.Responses +{ + public record AlbumPictureUploadResponse( + Guid IntentId, + string Bucket, + string Key, + string PictureName, + string ContentType, + int ExpiresInSeconds, + string UploadUrl); +} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs new file mode 100644 index 0000000..b587b80 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Albums.Responses +{ + public record AlbumSearchItemResponse(AlbumApplicationResponse 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 new file mode 100644 index 0000000..ea5c64c --- /dev/null +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs @@ -0,0 +1,12 @@ +namespace Musify.Application.Albums.Responses +{ + + public record AlbumsSearchResponse( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasPreviousPage, + bool HasNextPage); +} diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs new file mode 100644 index 0000000..d89500e --- /dev/null +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -0,0 +1,150 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Shared; +using Musify.Application.Events; +using Musify.Application.Albums.Responses; +using Musify.Application.Services; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record UpdateAlbumCommand( + long UserId, + Guid AlbumId, + string Title, + string? Description, + int? ReleaseYear, + Guid? NewPictureIntentId) + : ICommand>; + + public class UpdateAlbumCommandHandler( + IEventBus eventBus, + IDatabase database, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + AlbumConfiguration albumConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) + { + var album = await database.Albums + .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); + if (album == null) + { + logger.LogInformation("Album {AlbumId} not found", request.AlbumId); + return Error.NotFound(); + } + + if (album.OwnerUserId != request.UserId) + { + logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId); + return Error.Unauthorized(); + } + + var title = request.Title.Trim(); + + album.Title = title; + album.NormalizedTitle = title.ToUpperInvariant(); + album.Description = request.Description; + album.ReleaseYear = request.ReleaseYear; + + UploadIntent? pictureIntent = null; + string? finalPictureKey = null; + + if (request.NewPictureIntentId.HasValue) + { + var validation = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, + request.NewPictureIntentId.Value, request.UserId, cancellationToken); + if (validation.IsError) + return validation.Errors; + + pictureIntent = validation.Value; + album.Pictures = new AlbumPictures + { + OriginalName = pictureIntent.ObjectName, + SmallName = pictureIntent.ObjectName, + MediumName = pictureIntent.ObjectName, + LargeName = pictureIntent.ObjectName + }; + finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); + } + + if (pictureIntent != null && finalPictureKey != null) + { + var publishResult = await PublishUpdateAlbumPictureSourceEventAsync( + album.Id, pictureIntent, finalPictureKey, cancellationToken); + if (publishResult.IsError) + return publishResult.Errors; + } + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to update album {AlbumId}", request.AlbumId); + return Error.Failure(description: $"Failed to update album {request.AlbumId}"); + } + + var albumTracks = database.AlbumHasTracks + .AsNoTracking() + .Where(albumTrack => albumTrack.AlbumId == album.Id); + + var trackCount = await albumTracks.CountAsync(cancellationToken); + var coverTrackIds = await albumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(AlbumApplicationResponse.CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToListAsync(cancellationToken); + + logger.LogInformation("Updated album {AlbumId}", album.Id); + return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds); + } + + private async Task> PublishUpdateAlbumPictureSourceEventAsync( + Guid albumId, + UploadIntent pictureIntent, + string finalPictureKey, + CancellationToken cancellationToken) + { + try + { + await eventBus.PublishAsync( + new UpdateAlbumPictureSourceEvent( + albumId, + pictureIntent.Id, + storageConfiguration.Bucket, + pictureIntent.Key, + finalPictureKey, + new ImageSize( + albumConfiguration.Routes.SmallPicturesPath, + albumConfiguration.PicturesSizes.SmallPictureWidth, + albumConfiguration.PicturesSizes.SmallPictureHeight), + new ImageSize( + albumConfiguration.Routes.MediumPicturesPath, + albumConfiguration.PicturesSizes.MediumPictureWidth, + albumConfiguration.PicturesSizes.MediumPictureHeight), + new ImageSize( + albumConfiguration.Routes.LargePicturesPath, + albumConfiguration.PicturesSizes.LargePictureWidth, + albumConfiguration.PicturesSizes.LargePictureHeight)), + cancellationToken); + return new Success(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId); + return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}"); + } + } + } +} diff --git a/backend/Musify.Application/Musify.Application.csproj b/backend/Core/Musify.Application/Application.csproj similarity index 78% rename from backend/Musify.Application/Musify.Application.csproj rename to backend/Core/Musify.Application/Application.csproj index e6266ce..a424aa4 100644 --- a/backend/Musify.Application/Musify.Application.csproj +++ b/backend/Core/Musify.Application/Application.csproj @@ -1,5 +1,10 @@ + + Musify.Application + Musify.Application + + @@ -13,12 +18,13 @@ + - + diff --git a/backend/Musify.Application/ApplicationDependencyInjection.cs b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs similarity index 74% rename from backend/Musify.Application/ApplicationDependencyInjection.cs rename to backend/Core/Musify.Application/ApplicationDependencyInjection.cs index c34340c..176157c 100644 --- a/backend/Musify.Application/ApplicationDependencyInjection.cs +++ b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs @@ -1,4 +1,3 @@ -using Mediator; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Musify.Application.Configuration; @@ -12,12 +11,16 @@ public static class ApplicationDependencyInjection { services.AddMediator(); services.AddScoped(); + services.AddScoped(); services.AddValidatedOptions(configuration, ApplicationStorageConfiguration.SectionName); services.AddValidatedOptions(configuration, PlayListConfiguration.SectionName); + services.AddValidatedOptions(configuration, AlbumConfiguration.SectionName); services.AddValidatedOptions(configuration, TrackConfiguration.SectionName); + services.AddValidatedOptions(configuration, MixConfiguration.SectionName); services.AddValidatedOptions(configuration, StreamGatewayConfiguration.SectionName); services.AddValidatedOptions(configuration, UploadIntentConfiguration.SectionName); + services.AddValidatedOptions(configuration, PlaybackConfiguration.SectionName); return services; } diff --git a/backend/Musify.Application/AssemblyInfo.cs b/backend/Core/Musify.Application/AssemblyInfo.cs similarity index 100% rename from backend/Musify.Application/AssemblyInfo.cs rename to backend/Core/Musify.Application/AssemblyInfo.cs diff --git a/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs b/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs new file mode 100644 index 0000000..1802775 --- /dev/null +++ b/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs @@ -0,0 +1,79 @@ +using System.ComponentModel.DataAnnotations; +using Musify.Application.Shared; + +namespace Musify.Application.Configuration +{ + public class AlbumConfiguration + { + public const string SectionName = "Album"; + + [Required] + public AlbumRoutes Routes { get; set; } = new AlbumRoutes(); + + [Required] + public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes(); + } + + public class AlbumRoutes + { + [Required] + public string UploadsFolder { get; set; } = "uploads"; + + [Required] + public string ParentFolders { get; set; } = "Albums"; + + [Required] + public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; + + [Required] + public string SmallPicturesFolder { get; set; } = "SmallPictures"; + + [Required] + public string MediumPicturesFolder { get; set; } = "MediumPictures"; + + [Required] + public string LargePicturesFolder { get; set; } = "LargePictures"; + + public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder); + + public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); + + public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder); + + public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder); + + public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName); + + public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName); + + public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName); + + public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName); + + public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName); + + public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) => + StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName); + } + + public class AlbumPicturesSizes + { + [Range(1, 1024)] + public int SmallPictureWidth { get; set; } = 128; + + [Range(1, 1024)] + public int SmallPictureHeight { get; set; } = 128; + + [Range(1, 1024)] + public int MediumPictureWidth { get; set; } = 256; + + [Range(1, 1024)] + public int MediumPictureHeight { get; set; } = 256; + + [Range(1, 1024)] + public int LargePictureWidth { get; set; } = 512; + + [Range(1, 1024)] + public int LargePictureHeight { get; set; } = 512; + } +} diff --git a/backend/Musify.Application/Configuration/ApplicationStorageConfiguration.cs b/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs similarity index 100% rename from backend/Musify.Application/Configuration/ApplicationStorageConfiguration.cs rename to backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs diff --git a/backend/Musify.Application/Configuration/ConfigurationDependencyInjection.cs b/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs similarity index 100% rename from backend/Musify.Application/Configuration/ConfigurationDependencyInjection.cs rename to backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs diff --git a/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs b/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs new file mode 100644 index 0000000..8b96121 --- /dev/null +++ b/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs @@ -0,0 +1,17 @@ +using Mediator; +using Musify.Application.Configuration.Responses; + +namespace Musify.Application.Configuration +{ + + public record GetPlaybackPublicConfigQuery : IQuery; + + public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration) + : IQueryHandler + { + public ValueTask Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) => + ValueTask.FromResult(new PlaybackPublicConfigResponse( + playbackConfiguration.AllowAnonymousListening, + playbackConfiguration.AnonymousFragmentSeconds)); + } +} diff --git a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs new file mode 100644 index 0000000..5c99baf --- /dev/null +++ b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Application.Configuration +{ + public class MixConfiguration + { + public const string SectionName = "Mix"; + + [Range(4, 100)] + public int ItemsPerMix { get; set; } = 12; + } +} diff --git a/backend/Musify.Application/Configuration/PlayListConfiguration.cs b/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs similarity index 79% rename from backend/Musify.Application/Configuration/PlayListConfiguration.cs rename to backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs index 39e0d39..cb1ea32 100644 --- a/backend/Musify.Application/Configuration/PlayListConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs @@ -34,30 +34,12 @@ namespace Musify.Application.Configuration [Required] public string LargePicturesFolder { get; set; } = "LargePictures"; - [Required] - public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; - - [Required] - public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp"; - - [Required] - public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp"; - - [Required] - public string PresetLargePicture { get; set; } = "PresetLargePicture.webp"; - public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder); public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder); - public string PresetSmallPicturePath => StorageKey.Combine(ParentFolders, PresetSmallPicture); - - public string PresetMediumPicturePath => StorageKey.Combine(ParentFolders, PresetMediumPicture); - - public string PresetLargePicturePath => StorageKey.Combine(ParentFolders, PresetLargePicture); - public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder); public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName); diff --git a/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs b/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs new file mode 100644 index 0000000..90ac7aa --- /dev/null +++ b/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Application.Configuration +{ + + public sealed class PlaybackConfiguration + { + public const string SectionName = "Playback"; + + public bool AllowAnonymousListening { get; set; } + + [Range(0, 3600)] + public int AnonymousFragmentSeconds { get; set; } + + [Range(1, int.MaxValue)] + public int EstimatedAudioBytesPerSecond { get; set; } = 16_000; + } +} diff --git a/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs b/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs new file mode 100644 index 0000000..90a92b9 --- /dev/null +++ b/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs @@ -0,0 +1,5 @@ +namespace Musify.Application.Configuration.Responses +{ + + public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds); +} diff --git a/backend/Musify.Application/Configuration/StreamGatewayConfiguration.cs b/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs similarity index 100% rename from backend/Musify.Application/Configuration/StreamGatewayConfiguration.cs rename to backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs diff --git a/backend/Musify.Application/Configuration/TrackConfiguration.cs b/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs similarity index 100% rename from backend/Musify.Application/Configuration/TrackConfiguration.cs rename to backend/Core/Musify.Application/Configuration/TrackConfiguration.cs diff --git a/backend/Musify.Application/Configuration/UploadIntentConfiguration.cs b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs similarity index 100% rename from backend/Musify.Application/Configuration/UploadIntentConfiguration.cs rename to backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs diff --git a/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs b/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs new file mode 100644 index 0000000..43902f1 --- /dev/null +++ b/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs @@ -0,0 +1,4 @@ +namespace Musify.Application.Contracts +{ + public record AudioTranscodeResult(int StatusCode, TimeSpan Duration); +} diff --git a/backend/Musify.Application/Contracts/IAudioTranscoderService.cs b/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs similarity index 57% rename from backend/Musify.Application/Contracts/IAudioTranscoderService.cs rename to backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs index c4f0d02..6f6c885 100644 --- a/backend/Musify.Application/Contracts/IAudioTranscoderService.cs +++ b/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs @@ -2,7 +2,7 @@ namespace Musify.Application.Contracts { public interface IAudioTranscoderService { - Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); + Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); Task IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken); } diff --git a/backend/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs similarity index 65% rename from backend/Musify.Application/Contracts/IDatabase.cs rename to backend/Core/Musify.Application/Contracts/IDatabase.cs index 8fbaf26..df8ebe6 100644 --- a/backend/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -9,16 +9,32 @@ namespace Musify.Application.Contracts DbSet PlayLists { get; } + DbSet Albums { get; } + + DbSet AlbumHasTracks { get; } + DbSet Tracks { get; } DbSet UserHasTracks { get; } DbSet PlayListHasTracks { get; } + DbSet Mixes { get; } + + DbSet MixItems { get; } + DbSet Uploads { get; } DbSet UploadIntents { get; } + DbSet ListeningHistories { get; } + + DbSet TrackLikes { get; } + + DbSet TrackTags { get; } + + DbSet UserFollows { get; } + Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); Task SaveChangesAsync(CancellationToken cancellationToken); diff --git a/backend/Musify.Application/Contracts/IDatabaseTransaction.cs b/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs similarity index 100% rename from backend/Musify.Application/Contracts/IDatabaseTransaction.cs rename to backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs diff --git a/backend/Musify.Application/Contracts/IEventBus.cs b/backend/Core/Musify.Application/Contracts/IEventBus.cs similarity index 100% rename from backend/Musify.Application/Contracts/IEventBus.cs rename to backend/Core/Musify.Application/Contracts/IEventBus.cs diff --git a/backend/Musify.Application/Contracts/IPictureService.cs b/backend/Core/Musify.Application/Contracts/IPictureService.cs similarity index 100% rename from backend/Musify.Application/Contracts/IPictureService.cs rename to backend/Core/Musify.Application/Contracts/IPictureService.cs diff --git a/backend/Musify.Application/Contracts/IStorageService.cs b/backend/Core/Musify.Application/Contracts/IStorageService.cs similarity index 100% rename from backend/Musify.Application/Contracts/IStorageService.cs rename to backend/Core/Musify.Application/Contracts/IStorageService.cs diff --git a/backend/Musify.Application/Contracts/IStreamTicketService.cs b/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs similarity index 65% rename from backend/Musify.Application/Contracts/IStreamTicketService.cs rename to backend/Core/Musify.Application/Contracts/IStreamTicketService.cs index f72282b..26925ac 100644 --- a/backend/Musify.Application/Contracts/IStreamTicketService.cs +++ b/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs @@ -2,7 +2,8 @@ namespace Musify.Application.Contracts { public interface IStreamTicketService { - StreamTicket IssueTicket(long userId, string keyPrefix); + + StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null); } public record StreamTicket(string Token, int ExpiresInSeconds); diff --git a/backend/Musify.Application/Contracts/ObjectMetaData.cs b/backend/Core/Musify.Application/Contracts/ObjectMetaData.cs similarity index 100% rename from backend/Musify.Application/Contracts/ObjectMetaData.cs rename to backend/Core/Musify.Application/Contracts/ObjectMetaData.cs diff --git a/backend/Musify.Application/Contracts/StorageObject.cs b/backend/Core/Musify.Application/Contracts/StorageObject.cs similarity index 100% rename from backend/Musify.Application/Contracts/StorageObject.cs rename to backend/Core/Musify.Application/Contracts/StorageObject.cs diff --git a/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs b/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs new file mode 100644 index 0000000..73db1d3 --- /dev/null +++ b/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs @@ -0,0 +1,4 @@ +namespace Musify.Application.Events +{ + public record AlbumPictureProcessed(Guid AlbumId); +} diff --git a/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs b/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs new file mode 100644 index 0000000..24f4cc5 --- /dev/null +++ b/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs @@ -0,0 +1,4 @@ +namespace Musify.Application.Events +{ + public record AlbumPictureProcessingFailed(Guid AlbumId); +} diff --git a/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs b/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs new file mode 100644 index 0000000..5ee8895 --- /dev/null +++ b/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Events +{ + public record AlbumProcessingFailed( + Guid AlbumId, + string? Bucket, + string? PictureKey); +} diff --git a/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs new file mode 100644 index 0000000..a6bbe38 --- /dev/null +++ b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs @@ -0,0 +1,14 @@ +using Musify.Application.Shared; + +namespace Musify.Application.Events +{ + public record CreateAlbumResourcesEvent( + Guid AlbumId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/backend/Musify.Application/Events/CreatePlayListResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs similarity index 100% rename from backend/Musify.Application/Events/CreatePlayListResourcesEvent.cs rename to backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs diff --git a/backend/Musify.Application/Events/CreateTrackResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs similarity index 100% rename from backend/Musify.Application/Events/CreateTrackResourcesEvent.cs rename to backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs diff --git a/backend/Musify.Application/Events/DeletePlayListEvent.cs b/backend/Core/Musify.Application/Events/DeletePlayListEvent.cs similarity index 100% rename from backend/Musify.Application/Events/DeletePlayListEvent.cs rename to backend/Core/Musify.Application/Events/DeletePlayListEvent.cs diff --git a/backend/Musify.Application/Events/DeleteTrackEvent.cs b/backend/Core/Musify.Application/Events/DeleteTrackEvent.cs similarity index 100% rename from backend/Musify.Application/Events/DeleteTrackEvent.cs rename to backend/Core/Musify.Application/Events/DeleteTrackEvent.cs diff --git a/backend/Musify.Application/Events/PlayListPictureProcessed.cs b/backend/Core/Musify.Application/Events/PlayListPictureProcessed.cs similarity index 100% rename from backend/Musify.Application/Events/PlayListPictureProcessed.cs rename to backend/Core/Musify.Application/Events/PlayListPictureProcessed.cs diff --git a/backend/Musify.Application/Events/PlayListPictureProcessingFailed.cs b/backend/Core/Musify.Application/Events/PlayListPictureProcessingFailed.cs similarity index 100% rename from backend/Musify.Application/Events/PlayListPictureProcessingFailed.cs rename to backend/Core/Musify.Application/Events/PlayListPictureProcessingFailed.cs diff --git a/backend/Musify.Application/Events/PlayListProcessingFailed.cs b/backend/Core/Musify.Application/Events/PlayListProcessingFailed.cs similarity index 100% rename from backend/Musify.Application/Events/PlayListProcessingFailed.cs rename to backend/Core/Musify.Application/Events/PlayListProcessingFailed.cs diff --git a/backend/Musify.Application/Events/TrackAudioProcessed.cs b/backend/Core/Musify.Application/Events/TrackAudioProcessed.cs similarity index 100% rename from backend/Musify.Application/Events/TrackAudioProcessed.cs rename to backend/Core/Musify.Application/Events/TrackAudioProcessed.cs diff --git a/backend/Musify.Application/Events/TrackAudioProcessingFailed.cs b/backend/Core/Musify.Application/Events/TrackAudioProcessingFailed.cs similarity index 100% rename from backend/Musify.Application/Events/TrackAudioProcessingFailed.cs rename to backend/Core/Musify.Application/Events/TrackAudioProcessingFailed.cs diff --git a/backend/Musify.Application/Events/TrackPictureProcessed.cs b/backend/Core/Musify.Application/Events/TrackPictureProcessed.cs similarity index 100% rename from backend/Musify.Application/Events/TrackPictureProcessed.cs rename to backend/Core/Musify.Application/Events/TrackPictureProcessed.cs diff --git a/backend/Musify.Application/Events/TrackPictureProcessingFailed.cs b/backend/Core/Musify.Application/Events/TrackPictureProcessingFailed.cs similarity index 100% rename from backend/Musify.Application/Events/TrackPictureProcessingFailed.cs rename to backend/Core/Musify.Application/Events/TrackPictureProcessingFailed.cs diff --git a/backend/Musify.Application/Events/TrackProcessingFailed.cs b/backend/Core/Musify.Application/Events/TrackProcessingFailed.cs similarity index 100% rename from backend/Musify.Application/Events/TrackProcessingFailed.cs rename to backend/Core/Musify.Application/Events/TrackProcessingFailed.cs diff --git a/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs new file mode 100644 index 0000000..338dc6c --- /dev/null +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs @@ -0,0 +1,12 @@ +using Musify.Application.Shared; + +namespace Musify.Application.Events +{ + public record UpdateAlbumPictureEvent( + Guid AlbumId, + string Bucket, + string SourceKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs new file mode 100644 index 0000000..a6de018 --- /dev/null +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs @@ -0,0 +1,14 @@ +using Musify.Application.Shared; + +namespace Musify.Application.Events +{ + public record UpdateAlbumPictureSourceEvent( + Guid AlbumId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/backend/Musify.Application/Events/UpdatePlayListPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs similarity index 100% rename from backend/Musify.Application/Events/UpdatePlayListPictureEvent.cs rename to backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs diff --git a/backend/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs b/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs similarity index 100% rename from backend/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs rename to backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs diff --git a/backend/Musify.Application/Events/UpdateTrackAudioEvent.cs b/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs similarity index 100% rename from backend/Musify.Application/Events/UpdateTrackAudioEvent.cs rename to backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs diff --git a/backend/Musify.Application/Events/UpdateTrackPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs similarity index 100% rename from backend/Musify.Application/Events/UpdateTrackPictureEvent.cs rename to backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs diff --git a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs new file mode 100644 index 0000000..3cd61e0 --- /dev/null +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -0,0 +1,43 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; +using X.PagedList; +using X.PagedList.EF; + +namespace Musify.Application.Likes +{ + public record GetLikedTracksQuery( + long UserId, + int PageNumber, + int PageSize) + : IQuery>>; + + public class GetLikedTracksQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) + { + var likesQuery = database.TrackLikes + .AsNoTracking() + .Include(like => like.Track.Owner) + .Include(like => like.Track.Tags) + .Where(like => like.UserId == request.UserId); + + var totalCount = await likesQuery.CountAsync(cancellationToken); + + var pagedEntities = await likesQuery + .OrderByDescending(like => like.CreatedAt) + .Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + var pagedTracks = new StaticPagedList( + pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + + return PaginatedResponse.FromPagedList(pagedTracks); + } + } +} diff --git a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs new file mode 100644 index 0000000..7fe93a1 --- /dev/null +++ b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs @@ -0,0 +1,56 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.Entities; + +namespace Musify.Application.Likes +{ + public record ToggleTrackLikeCommand(long UserId, Guid TrackId) + : ICommand>; + + public class ToggleTrackLikeCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) + { + 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 == request.TrackId, cancellationToken); + + bool nowLiked; + if (existingLike != null) + { + database.TrackLikes.Remove(existingLike); + nowLiked = false; + } + else + { + await database.TrackLikes.AddAsync(new TrackLike + { + UserId = request.UserId, + TrackId = request.TrackId + }, cancellationToken); + nowLiked = true; + } + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("User {UserId} {Action} track {TrackId}", + request.UserId, nowLiked ? "liked" : "unliked", request.TrackId); + + return nowLiked; + } + } +} diff --git a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs new file mode 100644 index 0000000..0aad625 --- /dev/null +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -0,0 +1,153 @@ +using ErrorOr; +using Mediator; +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.Mixes +{ + public record GenerateMixesForUserCommand(long UserId) + : ICommand>; + + public class GenerateMixesForUserCommandHandler( + IDatabase database, + MixConfiguration mixConfiguration, + ILogger logger) + : ICommandHandler> + { + 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) + .Select(history => history.TrackId) + .Distinct() + .ToListAsync(cancellationToken); + + 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) + { + drafts.Add(new MixDraft( + "Descubrimiento", + "Canciones de tu biblioteca que todavía no has escuchado.", + discovery)); + } + + var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds))); + if (daily.Count > 0) + { + drafts.Add(new MixDraft( + "Tu mezcla diaria", + "Lo que más escuchas, con alguna sorpresa de tu biblioteca.", + daily)); + } + + if (drafts.Count == 0) + { + logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); + return new Success(); + } + + await ReplaceMixesAsync(userId, drafts, cancellationToken); + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId); + return Error.Failure(description: $"Failed to generate mixes for user {userId}"); + } + + logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId); + return new Success(); + } + + 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 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]); + if (index < second.Count) + merged.Add(second[index]); + } + + return merged; + } + + private async Task ReplaceMixesAsync(long userId, IReadOnlyList drafts, CancellationToken cancellationToken) + { + var previousMixes = await database.Mixes + .Where(mix => mix.UserId == userId) + .ToListAsync(cancellationToken); + + if (previousMixes.Count > 0) + { + var previousIds = previousMixes.Select(mix => mix.Id).ToList(); + var previousItems = await database.MixItems + .Where(item => previousIds.Contains(item.MixId)) + .ToListAsync(cancellationToken); + + database.MixItems.RemoveRange(previousItems); + database.Mixes.RemoveRange(previousMixes); + } + + for (var position = 0; position < drafts.Count; position++) + { + var draft = drafts[position]; + var mix = new Mix + { + UserId = userId, + Title = draft.Title, + Subtitle = draft.Subtitle, + Position = position + }; + + await database.Mixes.AddAsync(mix, cancellationToken); + + for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++) + { + await database.MixItems.AddAsync(new MixItem + { + MixId = mix.Id, + Position = itemPosition, + TrackId = draft.TrackIds[itemPosition] + }, cancellationToken); + } + } + } + } +} diff --git a/backend/Core/Musify.Application/Mixes/GetMixById.cs b/backend/Core/Musify.Application/Mixes/GetMixById.cs new file mode 100644 index 0000000..c909ad1 --- /dev/null +++ b/backend/Core/Musify.Application/Mixes/GetMixById.cs @@ -0,0 +1,31 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Mixes.Responses; + +namespace Musify.Application.Mixes +{ + public record GetMixByIdQuery(long UserId, Guid MixId) + : IQuery>; + + public class GetMixByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetMixByIdQuery request, CancellationToken cancellationToken) + { + var mix = await database.Mixes + .AsNoTracking() + .Include(mix => mix.Items) + .SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken); + + if (mix == null) + return Error.NotFound(description: $"Mix {request.MixId} was not found"); + + var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); + var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken); + + 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 new file mode 100644 index 0000000..570aa6e --- /dev/null +++ b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs @@ -0,0 +1,37 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Mixes.Responses; + +namespace Musify.Application.Mixes +{ + public record GetMixesByUserIdQuery(long UserId) + : IQuery>>; + + public class GetMixesByUserIdQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) + { + var mixes = await database.Mixes + .AsNoTracking() + .Where(mix => mix.UserId == request.UserId) + .Include(mix => mix.Items) + .OrderBy(mix => mix.Position) + .ToListAsync(cancellationToken); + + 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..10b533b --- /dev/null +++ b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Mixes.Responses; +using Musify.Domain.Entities; + +namespace Musify.Application.Mixes +{ + + 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)) + .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count }) + .ToDictionaryAsync(x => x.Track.Id, cancellationToken); + + return orderedItems + .Where(item => tracksById.ContainsKey(item.TrackId)) + .Select(item => + { + var entry = tracksById[item.TrackId]; + return new MixItemApplicationResponse( + item.TrackId, + entry.Track.Title, + entry.Track.Owner.Name, + entry.Track.DurationSeconds, + entry.ListensCount); + }) + .ToList(); + } + } +} diff --git a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs new file mode 100644 index 0000000..6af9494 --- /dev/null +++ b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs @@ -0,0 +1,12 @@ +namespace Musify.Application.Mixes.Responses +{ + public record MixApplicationResponse( + Guid Id, + string Title, + string? Subtitle, + int ItemCount, + IReadOnlyList Items) + { + public const int CoverItemCount = 4; + } +} diff --git a/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs b/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs new file mode 100644 index 0000000..1b75458 --- /dev/null +++ b/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs @@ -0,0 +1,9 @@ +namespace Musify.Application.Mixes.Responses +{ + public record MixItemApplicationResponse( + Guid TrackId, + string Title, + string? Artist, + double DurationSeconds, + int ListensCount); +} diff --git a/backend/Musify.Application/PlayLists/AddTrackToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs similarity index 88% rename from backend/Musify.Application/PlayLists/AddTrackToPlayList.cs rename to backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs index 2e81be1..737f9be 100644 --- a/backend/Musify.Application/PlayLists/AddTrackToPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs @@ -19,7 +19,7 @@ namespace Musify.Application.PlayLists { var playList = await database.PlayLists .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playList is null) + if (playList == null) { logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); return Error.NotFound(); @@ -49,10 +49,13 @@ namespace Musify.Application.PlayLists return Error.Conflict(description: "Track is already in the playlist."); } - var nextPosition = await database.PlayListHasTracks - .Where(plt => plt.PlayListId == request.PlayListId) - .Select(plt => (int?)plt.Position) - .MaxAsync(cancellationToken) + 1 ?? 0; + 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 { diff --git a/backend/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs similarity index 70% rename from backend/Musify.Application/PlayLists/CreatePlayList.cs rename to backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index 8d3fe1c..1568a88 100644 --- a/backend/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -8,6 +8,7 @@ using Musify.Application.Events; using Musify.Application.PlayLists.Responses; using Musify.Application.Services; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; using Musify.Application.Contracts; namespace Musify.Application.PlayLists @@ -15,8 +16,9 @@ namespace Musify.Application.PlayLists public record CreatePlayListCommand( long UserId, string Name, - string Description, - Guid? PictureIntentId) + string? Description, + Guid? PictureIntentId, + PlaylistVisibility Visibility = PlaylistVisibility.Private) : ICommand>; public class CreatePlayListCommandHandler( @@ -29,6 +31,8 @@ namespace Musify.Application.PlayLists UploadIntentConfiguration uploadIntentConfiguration) : ICommandHandler> { + private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent); + public async ValueTask> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) { var userExists = await database.Users @@ -40,44 +44,27 @@ namespace Musify.Application.PlayLists return Error.NotFound(description: $"User {request.UserId} not found"); } - UploadIntent? pictureIntent = null; - string originalPictureName; + var picturesResult = await ResolvePicturesAsync(request, cancellationToken); + if (picturesResult.IsError) + return picturesResult.Errors; - if (request.PictureIntentId.HasValue) - { - var validation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, - request.PictureIntentId.Value, request.UserId, cancellationToken); - if (validation.IsError) - return validation.Errors; - - pictureIntent = validation.Value; - originalPictureName = pictureIntent.ObjectName; - } - else - { - originalPictureName = playListConfiguration.Routes.PresetOriginalPicture; - } + var resolvedPictures = picturesResult.Value; var playList = new PlayList { UserId = request.UserId, Name = request.Name, NormalizedName = request.Name.Trim().ToUpperInvariant(), - Description = request.Description, - OriginalPictureName = originalPictureName, - SmallPictureName = playListConfiguration.Routes.PresetSmallPicture, - MediumPictureName = playListConfiguration.Routes.PresetMediumPicture, - LargePictureName = playListConfiguration.Routes.PresetLargePicture + Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), + Pictures = resolvedPictures.Pictures, + Visibility = request.Visibility }; - await database.PlayLists.AddAsync(playList, cancellationToken); - if (pictureIntent is not null) + if (resolvedPictures.Intent != null) { - var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, originalPictureName); - var publishResult = await PublishCreatePlayListEventAsync( - playList.Id, pictureIntent, finalPictureKey, cancellationToken); + var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures!.OriginalName); + var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken); if (publishResult.IsError) return publishResult.Errors; } @@ -96,7 +83,30 @@ namespace Musify.Application.PlayLists return PlayListApplicationResponse.FromEntity(playList); } - async Task> PublishCreatePlayListEventAsync( + private async ValueTask> ResolvePicturesAsync( + CreatePlayListCommand request, CancellationToken cancellationToken) + { + if (!request.PictureIntentId.HasValue) + { + return new ResolvedPlayListPictures(null, null); + } + + var validation = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken); + + if (validation.IsError) + return validation.Errors; + + return new ResolvedPlayListPictures(new PlayListPictures + { + OriginalName = validation.Value.ObjectName, + SmallName = validation.Value.ObjectName, + MediumName = validation.Value.ObjectName, + LargeName = validation.Value.ObjectName + }, validation.Value); + } + + private async Task> PublishCreatePlayListEventAsync( Guid playListId, UploadIntent pictureIntent, string finalPictureKey, diff --git a/backend/Musify.Application/PlayLists/DeletePlayList.cs b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs similarity index 98% rename from backend/Musify.Application/PlayLists/DeletePlayList.cs rename to backend/Core/Musify.Application/PlayLists/DeletePlayList.cs index 05c0b61..51b4fd5 100644 --- a/backend/Musify.Application/PlayLists/DeletePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs @@ -20,7 +20,7 @@ namespace Musify.Application.PlayLists public async ValueTask> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) { var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken); - if (playList is null) + if (playList == null) { logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId); return Error.NotFound(); diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs new file mode 100644 index 0000000..0fc11b7 --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs @@ -0,0 +1,42 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.PlayLists.Responses; +using Mediator; +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.PlayLists +{ + public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null) + : IQuery>; + + public class GetPlayListByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) + { + var entry = await database.PlayLists + .AsNoTracking() + .Where(p => p.Id == request.Id) + .Select(p => new + { + PlayList = p, + CoverTrackIds = p.PlayListTracks + .OrderBy(plt => plt.Position) + .Take(PlayListApplicationResponse.CoverTrackCount) + .Select(plt => plt.TrackId) + .ToList() + }) + .SingleOrDefaultAsync(cancellationToken); + + if (entry == null) + return Error.NotFound(); + + if (entry.PlayList.Visibility == PlaylistVisibility.Private + && entry.PlayList.UserId != request.RequestingUserId) + return Error.NotFound(); + + return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds); + } + } +} diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs new file mode 100644 index 0000000..312d609 --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs @@ -0,0 +1,49 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using MimeMapping; +using Musify.Application.Configuration; +using Musify.Application.Contracts; + +namespace Musify.Application.PlayLists +{ + public record GetPlayListCoverQuery(Guid PlayListId, string Size) + : IQuery>; + + public record PlayListCoverLocation(string Bucket, string Key, string ContentType); + + public class GetPlayListCoverQueryHandler( + IDatabase database, + ApplicationStorageConfiguration storageConfiguration, + PlayListConfiguration playListConfiguration) + : IQueryHandler> + { + public async ValueTask> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken) + { + var pictures = await database.PlayLists.AsNoTracking() + .Where(p => p.Id == request.PlayListId) + .Select(p => p.Pictures) + .SingleOrDefaultAsync(cancellationToken); + + if (pictures == null) + return Error.NotFound(); + + var routes = playListConfiguration.Routes; + var (name, key) = request.Size.ToLowerInvariant() switch + { + "small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)), + "large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)), + _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName)) + }; + + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) + return Error.NotFound(); + + var contentType = MimeUtility.GetMimeMapping(name); + return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType); + } + + private static string? BuildKey(Func builder, string? name) + => string.IsNullOrEmpty(name) ? null : builder(name); + } +} diff --git a/backend/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs similarity index 64% rename from backend/Musify.Application/PlayLists/GetPlayListTracks.cs rename to backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index 27d49f0..cb3cbc9 100644 --- a/backend/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -4,20 +4,21 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; +using X.PagedList; using X.PagedList.EF; namespace Musify.Application.PlayLists { public record GetPlayListTracksQuery( Guid PlayListId, - int PageNumber = 1, - int PageSize = 10) + int PageNumber, + int PageSize) : IQuery>>; public class GetPlayListTracksQueryHandler(IDatabase database) - : IQueryHandler> > + : IQueryHandler>> { - public async ValueTask >> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) + public async ValueTask>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) { var playListExists = await database.PlayLists .AsNoTracking() @@ -27,16 +28,21 @@ namespace Musify.Application.PlayLists var tracksQuery = database.PlayListHasTracks .AsNoTracking() - .Include(plt => plt.Track) + .Include(plt => plt.Track.Owner) + .Include(plt => plt.Track.Tags) .Where(plt => plt.PlayListId == request.PlayListId); var totalCount = await tracksQuery.CountAsync(cancellationToken); - var pagedTracks = await tracksQuery + var pagedEntities = await tracksQuery .OrderBy(plt => plt.Position) - .Select(plt => TrackApplicationResponse.FromEntity(plt.Track)) + .Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count }) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + var pagedTracks = new StaticPagedList( + pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + return PaginatedResponse.FromPagedList(pagedTracks); } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs b/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs new file mode 100644 index 0000000..07d8874 --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs @@ -0,0 +1,43 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Shared; +using Musify.Application.PlayLists.Responses; +using X.PagedList; +using X.PagedList.EF; +using Mediator; +using Musify.Application.Contracts; + +namespace Musify.Application.PlayLists +{ + public record GetPlayListsQuery(int PageNumber, int PageSize) + : IQuery>>; + + public class GetPlayListsQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken) + { + var totalCount = await database.PlayLists.CountAsync(cancellationToken); + + var pagedEntities = await database.PlayLists + .AsNoTracking() + .OrderBy(p => p.Id) + .Select(p => new + { + PlayList = p, + CoverTrackIds = p.PlayListTracks + .OrderBy(plt => plt.Position) + .Take(PlayListApplicationResponse.CoverTrackCount) + .Select(plt => plt.TrackId) + .ToList() + }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + var pagedPlayLists = new StaticPagedList( + pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + + return PaginatedResponse.FromPagedList(pagedPlayLists); + } + } +} diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs new file mode 100644 index 0000000..f877f7e --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -0,0 +1,64 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.PlayLists.Responses; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; +using X.PagedList; +using X.PagedList.EF; + +namespace Musify.Application.PlayLists +{ + public record GetPlayListsByUserIdQuery( + long UserId, + string? Name, + int PageNumber, + int PageSize, + bool OnlyPublic = false) + : IQuery>>; + + public class GetPlayListsByUserIdQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) + { + var playListsQuery = database.PlayLists + .AsNoTracking() + .Where(p => p.UserId == request.UserId); + + if (request.OnlyPublic) + { + playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public); + } + + if (!string.IsNullOrEmpty(request.Name)) + { + var normalizedName = request.Name.Trim().ToUpperInvariant(); + playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName)); + } + + var totalCount = await playListsQuery.CountAsync(cancellationToken); + + var pagedEntities = await playListsQuery + .OrderBy(p => p.CreatedAt) + .Where(p => p.LifeCycleStatus == LifeCycleStatus.Active) + .Select(p => new + { + PlayList = p, + CoverTrackIds = p.PlayListTracks + .OrderBy(plt => plt.Position) + .Take(PlayListApplicationResponse.CoverTrackCount) + .Select(plt => plt.TrackId) + .ToList() + }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + var pagedPlayLists = new StaticPagedList( + pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + + return PaginatedResponse.FromPagedList(pagedPlayLists); + } + } +} diff --git a/backend/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs similarity index 96% rename from backend/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs rename to backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs index c7024f7..1202324 100644 --- a/backend/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs @@ -18,7 +18,7 @@ namespace Musify.Application.PlayLists { var playList = await database.PlayLists .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playList is null) + if (playList == null) { logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); return Error.NotFound(); @@ -32,7 +32,7 @@ namespace Musify.Application.PlayLists var link = await database.PlayListHasTracks .SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); - if (link is null) + if (link == null) { logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId); return Error.NotFound(description: "Track is not in the playlist."); diff --git a/backend/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs similarity index 95% rename from backend/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs rename to backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs index 539daad..dbf519d 100644 --- a/backend/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs +++ b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs @@ -42,7 +42,7 @@ namespace Musify.Application.PlayLists var effectiveSizeBytes = request.ExpectedSizeBytes ?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; - var objectName = $"{Guid.NewGuid() }.{request.FileType.TrimStart('.').ToLowerInvariant() }"; + var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; var tempKey = playListConfiguration.Routes.BuildTempPicturePath( uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName); @@ -84,7 +84,7 @@ namespace Musify.Application.PlayLists await database.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); - logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key} )", intent.Id, request.UserId, tempKey); + logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey); return new PlayListPictureUploadResponse( intent.Id, diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs new file mode 100644 index 0000000..225c5d2 --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -0,0 +1,37 @@ +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.PlayLists.Responses +{ + public record PlayListApplicationResponse( + Guid Id, + string Name, + string? Description, + string? SmallImageKeyName, + string? MediumImageKeyName, + string? LargeImageKeyName, + PlaylistVisibility Visibility, + DateTime CreatedAt, + DateTime UpdatedAt, + IReadOnlyList CoverTrackIds) + { + public const int CoverTrackCount = 4; + + public static PlayListApplicationResponse FromEntity( + PlayList playList, + IReadOnlyList? coverTrackIds = null) + { + return new PlayListApplicationResponse( + playList.Id, + playList.Name, + playList.Description, + playList.Pictures?.SmallName, + playList.Pictures?.MediumName, + playList.Pictures?.LargeName, + playList.Visibility, + playList.CreatedAt, + playList.UpdatedAt, + coverTrackIds ?? []); + } + } +} diff --git a/backend/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs similarity index 100% rename from backend/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs rename to backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs diff --git a/backend/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs similarity index 80% rename from backend/Musify.Application/PlayLists/UpdatePlayList.cs rename to backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index 40f2440..46cc87a 100644 --- a/backend/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -8,6 +8,7 @@ using Musify.Application.Events; using Musify.Application.PlayLists.Responses; using Musify.Application.Services; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; using Musify.Application.Contracts; namespace Musify.Application.PlayLists @@ -17,7 +18,8 @@ namespace Musify.Application.PlayLists Guid PlayListId, string? NewName, string? NewDescription, - Guid? NewPictureIntentId) + Guid? NewPictureIntentId, + PlaylistVisibility? NewVisibility = null) : ICommand>; public class UpdatePlayListCommandHandler( @@ -33,7 +35,7 @@ namespace Musify.Application.PlayLists public async ValueTask> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) { var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playListEntity is null) + if (playListEntity == null) { logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); return Error.NotFound(); @@ -45,17 +47,22 @@ namespace Musify.Application.PlayLists return Error.Unauthorized(); } - if (request.NewName is not null) + if (!string.IsNullOrWhiteSpace(request.NewName)) { playListEntity.Name = request.NewName; playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant(); } - if (request.NewDescription is not null) + if (!string.IsNullOrWhiteSpace(request.NewDescription)) { playListEntity.Description = request.NewDescription; } + if (request.NewVisibility.HasValue) + { + playListEntity.Visibility = request.NewVisibility.Value; + } + UploadIntent? pictureIntent = null; string? finalPictureKey = null; @@ -68,13 +75,17 @@ namespace Musify.Application.PlayLists return validation.Errors; pictureIntent = validation.Value; - playListEntity.OriginalPictureName = pictureIntent.ObjectName; + playListEntity.Pictures = new PlayListPictures + { + OriginalName = pictureIntent.ObjectName, + SmallName = pictureIntent.ObjectName, + MediumName = pictureIntent.ObjectName, + LargeName = pictureIntent.ObjectName + }; finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); } - database.PlayLists.Update(playListEntity); - - if (pictureIntent is not null && finalPictureKey is not null) + if (pictureIntent != null && finalPictureKey != null) { var publishResult = await PublishUpdatePlayListPictureSourceEventAsync( playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken); @@ -92,11 +103,19 @@ namespace Musify.Application.PlayLists return Error.Failure(description: $"Failed to update playlist {playListEntity.Id}"); } + var coverTrackIds = await database.PlayListHasTracks + .AsNoTracking() + .Where(plt => plt.PlayListId == playListEntity.Id) + .OrderBy(plt => plt.Position) + .Take(PlayListApplicationResponse.CoverTrackCount) + .Select(plt => plt.TrackId) + .ToListAsync(cancellationToken); + logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id); - return PlayListApplicationResponse.FromEntity(playListEntity); + return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds); } - async Task> PublishUpdatePlayListPictureSourceEventAsync( + private async Task> PublishUpdatePlayListPictureSourceEventAsync( Guid playListId, UploadIntent pictureIntent, string finalPictureKey, diff --git a/backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs b/backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs new file mode 100644 index 0000000..a0d7642 --- /dev/null +++ b/backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs @@ -0,0 +1,37 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Musify.Application.Serialization; + +public sealed class LongAsStringConverter : JsonConverter +{ + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType == JsonTokenType.String + ? long.Parse(reader.GetString()!, CultureInfo.InvariantCulture) + : reader.GetInt64(); + + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) => + writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture)); +} + +public sealed class NullableLongAsStringConverter : JsonConverter +{ + public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + + return reader.TokenType == JsonTokenType.String + ? string.IsNullOrEmpty(reader.GetString()) ? null : long.Parse(reader.GetString()!, CultureInfo.InvariantCulture) + : reader.GetInt64(); + } + + public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options) + { + if (value.HasValue) + writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture)); + else + writer.WriteNullValue(); + } +} diff --git a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs new file mode 100644 index 0000000..91031b9 --- /dev/null +++ b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs @@ -0,0 +1,54 @@ +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Tracks.Responses; +using Musify.Domain.Entities; + +namespace Musify.Application.Services; + +public sealed class TrackStreamIssuer( + IDatabase database, + IStreamTicketService ticketService, + TrackConfiguration trackConfiguration, + StreamGatewayConfiguration streamGatewayConfiguration, + PlaybackConfiguration playbackConfiguration) +{ + public async Task IssueAsync( + Guid trackId, + string audioFolderName, + long? userId, + CancellationToken cancellationToken) + { + var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName); + var keyPrefix = $"{folderPath}/"; + + var maxBytes = AnonymousFragmentBytes(userId); + var ticket = ticketService.IssueTicket(userId, keyPrefix, maxBytes); + + var manifestUrl = string.Join('/', + streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), + "media", + folderPath, + streamGatewayConfiguration.AudioFileName); + + if (userId != null) + { + var newListeningHistory = new ListeningHistory + { + UserId = userId.Value, + TrackId = trackId, + }; + await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + } + + return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds); + } + + private long? AnonymousFragmentBytes(long? userId) + { + if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0) + return null; + + return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond; + } +} diff --git a/backend/Musify.Application/Services/UploadIntentValidator.cs b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs similarity index 91% rename from backend/Musify.Application/Services/UploadIntentValidator.cs rename to backend/Core/Musify.Application/Services/UploadIntentValidator.cs index 239b9c1..7b9bbb4 100644 --- a/backend/Musify.Application/Services/UploadIntentValidator.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs @@ -42,17 +42,17 @@ public sealed class UploadIntentValidator( var intent = await database.UploadIntents .FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken); - if (intent is null || intent.UserId != userId) + if (intent == null || intent.UserId != userId) return Error.NotFound(description: "Upload intent not found or not accessible."); - if (intent.Status == UploadIntentStatus.Consumed) + if (intent.IsConsumed) return Error.Conflict(description: "Upload intent has already been consumed."); - if (intent.Status == UploadIntentStatus.Expired || intent.ExpiresAt < DateTime.UtcNow) + if (intent.IsExpired) return Error.Validation(description: "Upload intent has expired."); var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken); - if (metadata is null) + if (metadata == null) return Error.NotFound(description: "Uploaded object not found in storage. Upload the file first."); if (metadata.ContentLength > config.MaxUploadBytes) diff --git a/backend/Musify.Application/Shared/ImageSize.cs b/backend/Core/Musify.Application/Shared/ImageSize.cs similarity index 100% rename from backend/Musify.Application/Shared/ImageSize.cs rename to backend/Core/Musify.Application/Shared/ImageSize.cs diff --git a/backend/Musify.Application/Shared/PaginatedResponse.cs b/backend/Core/Musify.Application/Shared/PaginatedResponse.cs similarity index 100% rename from backend/Musify.Application/Shared/PaginatedResponse.cs rename to backend/Core/Musify.Application/Shared/PaginatedResponse.cs diff --git a/backend/Musify.Application/Shared/StorageKey.cs b/backend/Core/Musify.Application/Shared/StorageKey.cs similarity index 100% rename from backend/Musify.Application/Shared/StorageKey.cs rename to backend/Core/Musify.Application/Shared/StorageKey.cs diff --git a/backend/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs similarity index 72% rename from backend/Musify.Application/Tracks/CreateTrack.cs rename to backend/Core/Musify.Application/Tracks/CreateTrack.cs index e891ff6..1b4ff52 100644 --- a/backend/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -17,7 +17,8 @@ namespace Musify.Application.Tracks long UserId, string Title, Guid PictureIntentId, - Guid AudioIntentId) + Guid AudioIntentId, + IReadOnlyCollection Tags) : ICommand>; public class CreateTrackCommandHandler( @@ -32,14 +33,31 @@ namespace Musify.Application.Tracks { public async ValueTask> Handle(CreateTrackCommand request, CancellationToken cancellationToken) { - var userExists = await database.Users.AsNoTracking() - .AnyAsync(u => u.Id == request.UserId, cancellationToken); - if (!userExists) + var user = await database.Users + .SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + if (user == null) { logger.LogWarning("User {UserId} not found", request.UserId); return Error.NotFound(description: $"User {request.UserId} not found"); } + var distinctTags = request.Tags.Distinct().ToList(); + if (distinctTags.Count == 0) + { + logger.LogWarning("Track {Title} was submitted without any tags", request.Title); + return Error.Validation(description: "At least one tag is required."); + } + + var tagConflicts = GenreCompatibility.FindConflicts(distinctTags); + if (tagConflicts.Count > 0) + { + var conflict = tagConflicts.First(); + logger.LogWarning( + "Track {Title} was submitted with incompatible tags {First} and {Second}", + request.Title, conflict.First, conflict.Second); + return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."); + } + var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken); @@ -63,15 +81,27 @@ namespace Musify.Application.Tracks { Title = request.Title, NormalizedTitle = request.Title.ToUpperInvariant(), - OriginalPictureName = pictureIntent.ObjectName, - OriginalAudioName = audioIntent.ObjectName, - SmallPictureName = trackConfiguration.Routes.PresetSmallPicture, - MediumPictureName = trackConfiguration.Routes.PresetMediumPicture, - LargePictureName = trackConfiguration.Routes.PresetLargePicture, - PicturesProcessingStatus = ProcessingStatus.Pending, - AudioTranscodeProcessingStatus = ProcessingStatus.Pending + OwnerUserId = user.Id, + Owner = user, + Pictures = new TrackPictures + { + OriginalName = pictureIntent.ObjectName, + SmallName = trackConfiguration.Routes.PresetSmallPicture, + MediumName = trackConfiguration.Routes.PresetMediumPicture, + LargeName = trackConfiguration.Routes.PresetLargePicture, + ProcessingStatus = ProcessingStatus.Pending + }, + Audio = new TrackAudio + { + OriginalName = audioIntent.ObjectName, + TranscodeStatus = ProcessingStatus.Pending + } }; + trackEntity.Tags = distinctTags + .Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag }) + .ToList(); + await database.Tracks.AddAsync(trackEntity, cancellationToken); await database.UserHasTracks.AddAsync(new UserHasTrack @@ -123,7 +153,7 @@ namespace Musify.Application.Tracks return Error.Failure(description: $"Failed to create track for {request.Title}"); } - return TrackApplicationResponse.FromEntity(trackEntity); + return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0); } } } diff --git a/backend/Musify.Application/Tracks/DeleteTrack.cs b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs similarity index 85% rename from backend/Musify.Application/Tracks/DeleteTrack.cs rename to backend/Core/Musify.Application/Tracks/DeleteTrack.cs index 7f6fa37..9756b3b 100644 --- a/backend/Musify.Application/Tracks/DeleteTrack.cs +++ b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs @@ -2,6 +2,7 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Musify.Application.Events; using Musify.Application.Contracts; @@ -22,23 +23,19 @@ namespace Musify.Application.Tracks public async ValueTask> Handle(DeleteTrackCommand request, CancellationToken cancellationToken) { var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); - if (track is null) + if (track == null) { logger.LogInformation("Track {TrackId} not found", request.TrackId); return Error.NotFound(); } - var isOwner = await database.UserHasTracks - .AsNoTracking() - .AnyAsync(ut => ut.TrackId == request.TrackId && ut.UserId == request.UserId, cancellationToken); - if (!isOwner) + if (track.OwnerUserId != request.UserId) { logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId); return Error.Unauthorized(); } - if (track.AudioTranscodeProcessingStatus == ProcessingStatus.Processing - || track.AudioTranscodeProcessingStatus == ProcessingStatus.Pending) + if (track.Audio.IsInProgress) { logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId); return Error.Conflict(description: "Track is currently being processed and cannot be deleted"); diff --git a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs new file mode 100644 index 0000000..a73bbac --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs @@ -0,0 +1,30 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Tracks.Responses; + +namespace Musify.Application.Tracks +{ + public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery>; + + public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) + { + var track = await database.ListeningHistories + .AsNoTracking() + .Include(t => t.Track.Owner) + .Include(t => t.Track.Tags) + .Where(t => t.UserId == query.UserId) + .OrderByDescending(t => t.ListenedAt) + .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count)) + .FirstOrDefaultAsync(cancellationToken); + + return track == null + ? Error.NotFound("Track.NotFound", "No track found for the given user ID.") + : track; + } + } +} \ No newline at end of file diff --git a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs new file mode 100644 index 0000000..a828fa8 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs @@ -0,0 +1,40 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Tracks.Responses; + +namespace Musify.Application.Tracks +{ + public record GetListeningHistoryQuery(long UserId) : IQuery>; + + public class GetListeningHistoryQueryHandler( + IDatabase database) + : IQueryHandler> + { + public const int ListSize = 15; + + public async ValueTask> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken) + { + var recentTrackIds = await database.ListeningHistories + .AsNoTracking() + .Where(l => l.UserId == query.UserId) + .GroupBy(l => l.TrackId) + .OrderByDescending(g => g.Max(l => l.ListenedAt)) + .Select(g => g.Key) + .Take(ListSize) + .ToListAsync(cancellationToken); + + var tracksById = await database.Tracks + .AsNoTracking() + .Include(t => t.Owner) + .Include(t => t.Tags) + .Where(t => recentTrackIds.Contains(t.Id)) + .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count }) + .ToDictionaryAsync(t => t.Id, cancellationToken); + + return recentTrackIds + .Select(id => tracksById[id]) + .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount)); + } + } +} diff --git a/backend/Musify.Application/Tracks/GetTrackById.cs b/backend/Core/Musify.Application/Tracks/GetTrackById.cs similarity index 66% rename from backend/Musify.Application/Tracks/GetTrackById.cs rename to backend/Core/Musify.Application/Tracks/GetTrackById.cs index 861194c..d1fb84b 100644 --- a/backend/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackById.cs @@ -10,19 +10,21 @@ namespace Musify.Application.Tracks : IQuery>; public class GetTrackByIdQueryHandler(IDatabase database) - : IQueryHandler > + : IQueryHandler> { public async ValueTask> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) { - var track = await database.Tracks.AsNoTracking() + var entity = await database.Tracks.AsNoTracking() + .Include(t => t.Owner) + .Include(t => t.Tags) .Where(t => t.Id == request.TrackId) - .Select(t => TrackApplicationResponse.FromEntity(t)) + .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count }) .SingleOrDefaultAsync(cancellationToken); - if (track is null) + if (entity == null) return Error.NotFound(); - return track; + return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount); } } } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackCover.cs b/backend/Core/Musify.Application/Tracks/GetTrackCover.cs new file mode 100644 index 0000000..80b00ec --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/GetTrackCover.cs @@ -0,0 +1,53 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Configuration; +using Musify.Application.Contracts; + +namespace Musify.Application.Tracks +{ + public record GetTrackCoverQuery(Guid TrackId, string Size) + : IQuery>; + + public record TrackCoverLocation(string Bucket, string Key, string ContentType); + + public class GetTrackCoverQueryHandler( + IDatabase database, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration) + : IQueryHandler> + { + public async ValueTask> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken) + { + var track = await database.Tracks.AsNoTracking() + .Where(t => t.Id == request.TrackId) + .Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName }) + .SingleOrDefaultAsync(cancellationToken); + + if (track == null) + return Error.NotFound(); + + var routes = trackConfiguration.Routes; + var (name, key) = request.Size.ToLowerInvariant() switch + { + "small" => (track.SmallName, BuildKey(routes.BuildSmallPicturePath, track.SmallName)), + "large" => (track.LargeName, BuildKey(routes.BuildLargePicturePath, track.LargeName)), + _ => (track.MediumName, BuildKey(routes.BuildMediumPicturePath, track.MediumName)) + }; + + if (string.IsNullOrEmpty(name) || key == null) + return Error.NotFound(); + + var contentType = name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) + ? "image/webp" + : name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) + ? "image/png" + : "image/jpeg"; + + return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType); + } + + private static string? BuildKey(Func builder, string? name) + => string.IsNullOrEmpty(name) ? null : builder(name); + } +} \ No newline at end of file diff --git a/backend/Musify.Application/Tracks/GetTrackStream.cs b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs similarity index 51% rename from backend/Musify.Application/Tracks/GetTrackStream.cs rename to backend/Core/Musify.Application/Tracks/GetTrackStream.cs index e69d47f..1135863 100644 --- a/backend/Musify.Application/Tracks/GetTrackStream.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs @@ -4,53 +4,46 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Services; using Musify.Application.Tracks.Responses; -using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks { - public record GetTrackStreamQuery(Guid TrackId, long UserId) + + public record GetTrackStreamQuery(Guid TrackId, long? UserId) : IQuery>; public class GetTrackStreamQueryHandler( IDatabase database, - IStreamTicketService ticketService, - TrackConfiguration trackConfiguration, - StreamGatewayConfiguration streamGatewayConfiguration, + TrackStreamIssuer streamIssuer, + PlaybackConfiguration playbackConfiguration, ILogger logger) : IQueryHandler> { public async ValueTask> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken) { + if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening) + return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening."); + var track = await database.Tracks.AsNoTracking() .Where(t => t.Id == request.TrackId) - .Select(t => new { t.Id, t.AudioTranscodeProcessingStatus, t.AudioFolderName } ) + .Select(t => new { t.Id, t.Audio }) .SingleOrDefaultAsync(cancellationToken); - if (track is null) + if (track == null) return Error.NotFound(); - if (string.IsNullOrWhiteSpace(track.AudioFolderName) - || track.AudioTranscodeProcessingStatus != ProcessingStatus.Completed) + if (!track.Audio.IsProcessed) { logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId); return Error.Conflict(description: "Track audio is not available for streaming yet."); } - var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(track.AudioFolderName); - var keyPrefix = $"{folderPath}/"; + var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken); - var ticket = ticketService.IssueTicket(request.UserId, keyPrefix); + logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString() ?? "(anonymous)"); - var manifestUrl = string.Join('/', - streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), - "media", - folderPath, - streamGatewayConfiguration.AudioFileName); - - logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId); - - return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds); + return response; } } } diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs new file mode 100644 index 0000000..c2b614b --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -0,0 +1,53 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Tracks.Responses; +using X.PagedList; +using X.PagedList.EF; +using Musify.Application.Contracts; + +namespace Musify.Application.Tracks +{ + public record GetTracksQuery(string? Name, int PageNumber, int PageSize) + : IQuery>; + + public class GetTracksQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) + { + var tracksQuery = database.UserHasTracks + .AsNoTracking() + .Include(ut => ut.Track.Owner) + .Include(ut => ut.Track.Tags) + .AsQueryable(); + + if (!string.IsNullOrEmpty(request.Name)) + { + var normalizedName = request.Name.Trim().ToUpperInvariant(); + tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); + } + + var totalCount = await tracksQuery.CountAsync(cancellationToken); + + var pagedEntities = await tracksQuery + .OrderByDescending(t => t.Track.CreatedAt) + .ThenBy(t => t.Id) + .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + var items = pagedEntities + .Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount))) + .ToList(); + + return new TracksSearchResponse( + items, + pagedEntities.PageNumber, + pagedEntities.PageSize, + pagedEntities.PageCount, + pagedEntities.TotalItemCount, + pagedEntities.HasPreviousPage, + pagedEntities.HasNextPage); + } + } +} diff --git a/backend/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs similarity index 63% rename from backend/Musify.Application/Tracks/GetTracksByUserId.cs rename to backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index 657aa2e..ecc7a81 100644 --- a/backend/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; +using X.PagedList; using X.PagedList.EF; using Musify.Application.Contracts; @@ -10,25 +11,26 @@ namespace Musify.Application.Tracks { public record GetTracksByUserIdQuery( long UserId, - string? Name = null, - int PageNumber = 1, - int PageSize = 10) - : IQuery > >; + string? Name, + int PageNumber, + int PageSize) + : IQuery>>; public class GetTracksByUserIdQueryHandler(IDatabase database) - : IQueryHandler> > + : IQueryHandler>> { - public async ValueTask >> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) + public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) { var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber; var pageSize = request.PageSize < 1 ? 10 : request.PageSize; var tracksQuery = database.UserHasTracks .AsNoTracking() - .Include(ut => ut.Track) + .Include(ut => ut.Track.Owner) + .Include(ut => ut.Track.Tags) .Where(p => p.UserId == request.UserId); - if (request.Name is not null) + if (!string.IsNullOrEmpty(request.Name)) { var normalizedName = request.Name.Trim().ToUpperInvariant(); tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); @@ -36,11 +38,15 @@ namespace Musify.Application.Tracks var totalCount = await tracksQuery.CountAsync(cancellationToken); - var pagedTracks = await tracksQuery + var pagedEntities = await tracksQuery .OrderBy(t => t.Track.CreatedAt) - .Select(t => TrackApplicationResponse.FromEntity(t.Track)) + .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count }) .ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken); + var pagedTracks = new StaticPagedList( + pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + return PaginatedResponse.FromPagedList(pagedTracks); } } diff --git a/backend/Musify.Application/Tracks/RequestTrackUploadUrls.cs b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs similarity index 100% rename from backend/Musify.Application/Tracks/RequestTrackUploadUrls.cs rename to backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs new file mode 100644 index 0000000..02f87de --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -0,0 +1,33 @@ +using Musify.Application.Serialization; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using System.Text.Json.Serialization; + +namespace Musify.Application.Tracks.Responses +{ + public record TrackApplicationResponse( + Guid Id, + string Title, + string? Artist, + ProcessingStatus AudioStatus, + double Duration, + int ListensCount, + DateTime CreatedAt, + DateTime UpdatedAt, + [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, + IReadOnlyCollection Tags) + { + public static TrackApplicationResponse FromEntity(Track track, int listensCount) => + new( + track.Id, + track.Title, + track.Owner.Name, + track.Audio.TranscodeStatus, + track.DurationSeconds, + listensCount, + track.CreatedAt, + track.UpdatedAt, + track.OwnerUserId, + track.Tags.Select(tag => tag.Tag).ToList()); + } +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs new file mode 100644 index 0000000..1477ec4 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Tracks.Responses +{ + public record TrackSearchItemResponse(TrackApplicationResponse Track) + { + public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track); + } +} diff --git a/backend/Musify.Application/Tracks/Responses/TrackStreamResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs similarity index 100% rename from backend/Musify.Application/Tracks/Responses/TrackStreamResponse.cs rename to backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs diff --git a/backend/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs similarity index 100% rename from backend/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs rename to backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs diff --git a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs new file mode 100644 index 0000000..4fddfb6 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs @@ -0,0 +1,12 @@ +namespace Musify.Application.Tracks.Responses +{ + + public record TracksSearchResponse( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasPreviousPage, + bool HasNextPage); +} diff --git a/backend/Musify.Application/Users/CreateUser.cs b/backend/Core/Musify.Application/Users/CreateUser.cs similarity index 99% rename from backend/Musify.Application/Users/CreateUser.cs rename to backend/Core/Musify.Application/Users/CreateUser.cs index 392e026..ad19221 100644 --- a/backend/Musify.Application/Users/CreateUser.cs +++ b/backend/Core/Musify.Application/Users/CreateUser.cs @@ -28,7 +28,7 @@ namespace Musify.Application.Users } public class CreateUserCommandHandler(IDatabase database, ILogger logger) - : ICommandHandler > + : ICommandHandler> { public async ValueTask> Handle(CreateUserCommand request, CancellationToken cancellationToken) { diff --git a/backend/Core/Musify.Application/Users/FollowUser.cs b/backend/Core/Musify.Application/Users/FollowUser.cs new file mode 100644 index 0000000..c4d6541 --- /dev/null +++ b/backend/Core/Musify.Application/Users/FollowUser.cs @@ -0,0 +1,50 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.Entities; + +namespace Musify.Application.Users +{ + public record FollowUserCommand(long FollowerId, long FollowedId) : ICommand>; + + public class FollowUserCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(FollowUserCommand request, CancellationToken cancellationToken) + { + if (request.FollowerId == request.FollowedId) + return Error.Validation(description: "A user cannot follow themselves."); + + var followedExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.FollowedId, cancellationToken); + if (!followedExists) + { + logger.LogInformation("User {FollowedId} not found", request.FollowedId); + return Error.NotFound(description: $"User {request.FollowedId} not found"); + } + + var alreadyFollowing = await database.UserFollows + .AsNoTracking() + .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); + if (alreadyFollowing) + return new Success(); + + await database.UserFollows.AddAsync(new UserFollow + { + FollowerId = request.FollowerId, + FollowedId = request.FollowedId + }, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId); + + return new Success(); + } + } +} diff --git a/backend/Core/Musify.Application/Users/GetListeningStats.cs b/backend/Core/Musify.Application/Users/GetListeningStats.cs new file mode 100644 index 0000000..6794717 --- /dev/null +++ b/backend/Core/Musify.Application/Users/GetListeningStats.cs @@ -0,0 +1,50 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; + +namespace Musify.Application.Users +{ + public record GetListeningStatsQuery(long UserId) : IQuery; + + public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays); + + public class GetListeningStatsQueryHandler(IDatabase database) + : IQueryHandler + { + public async ValueTask Handle(GetListeningStatsQuery request, CancellationToken cancellationToken) + { + var weekAgo = DateTime.UtcNow.AddDays(-7); + + var thisWeekHistory = database.ListeningHistories + .AsNoTracking() + .Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo); + + var tracksThisWeek = await thisWeekHistory + .Select(l => l.TrackId) + .Distinct() + .CountAsync(cancellationToken); + + var secondsThisWeek = await thisWeekHistory + .Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds) + .SumAsync(cancellationToken); + + var listenedDates = await database.ListeningHistories + .AsNoTracking() + .Where(l => l.UserId == request.UserId) + .Select(l => l.ListenedAt.Date) + .Distinct() + .ToListAsync(cancellationToken); + + var listenedDateSet = listenedDates.ToHashSet(); + var streakDays = 0; + var day = DateTime.UtcNow.Date; + while (listenedDateSet.Contains(day)) + { + streakDays++; + day = day.AddDays(-1); + } + + return new ListeningStatsResponse(tracksThisWeek, (int)Math.Round(secondsThisWeek), streakDays); + } + } +} diff --git a/backend/Musify.Application/Users/GetUserById.cs b/backend/Core/Musify.Application/Users/GetUserById.cs similarity index 89% rename from backend/Musify.Application/Users/GetUserById.cs rename to backend/Core/Musify.Application/Users/GetUserById.cs index 50e9359..92d651b 100644 --- a/backend/Musify.Application/Users/GetUserById.cs +++ b/backend/Core/Musify.Application/Users/GetUserById.cs @@ -7,10 +7,10 @@ using Musify.Application.Users.Responses; namespace Musify.Application.Users { public record GetUserByIdQuery(long Id) - : IQuery >; + : IQuery>; public class GetUserByIdQueryHandler(IDatabase database) - : IQueryHandler > + : IQueryHandler> { public async ValueTask> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) { @@ -20,7 +20,7 @@ namespace Musify.Application.Users .Select(u => UserApplicationResponse.FromEntity(u)) .SingleOrDefaultAsync(cancellationToken); - if (user is null) + if (user == null) return Error.NotFound(); return user; diff --git a/backend/Core/Musify.Application/Users/GetUserProfile.cs b/backend/Core/Musify.Application/Users/GetUserProfile.cs new file mode 100644 index 0000000..4d897d2 --- /dev/null +++ b/backend/Core/Musify.Application/Users/GetUserProfile.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Serialization; + +namespace Musify.Application.Users +{ + public record GetUserProfileQuery(long UserId) : IQuery>; + + public record UserProfileResponse( + [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + DateTime CreatedAt, + int FollowersCount, + int FollowingCount); + + public class GetUserProfileQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetUserProfileQuery request, CancellationToken cancellationToken) + { + var user = await database.Users + .AsNoTracking() + .Where(u => u.Id == request.UserId) + .Select(u => new + { + u.Id, + u.Name, + u.FirstName, + u.SecondName, + u.ProfilePictureUrl, + u.CreatedAt + }) + .SingleOrDefaultAsync(cancellationToken); + + if (user == null) + return Error.NotFound(); + + var followersCount = await database.UserFollows + .AsNoTracking() + .CountAsync(f => f.FollowedId == request.UserId, cancellationToken); + + var followingCount = await database.UserFollows + .AsNoTracking() + .CountAsync(f => f.FollowerId == request.UserId, cancellationToken); + + return new UserProfileResponse( + user.Id, + user.Name, + user.FirstName, + user.SecondName, + user.ProfilePictureUrl, + user.CreatedAt, + followersCount, + followingCount); + } + } +} diff --git a/backend/Musify.Application/Users/GetUsers.cs b/backend/Core/Musify.Application/Users/GetUsers.cs similarity index 66% rename from backend/Musify.Application/Users/GetUsers.cs rename to backend/Core/Musify.Application/Users/GetUsers.cs index 6f57f60..90955bb 100644 --- a/backend/Musify.Application/Users/GetUsers.cs +++ b/backend/Core/Musify.Application/Users/GetUsers.cs @@ -8,30 +8,30 @@ using Musify.Application.Contracts; namespace Musify.Application.Users { - public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch = null) - : IQuery >>; + public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch) + : IQuery>>; public class GetUsersQueryHandler(IDatabase database) - : IQueryHandler > > + : IQueryHandler>> { - public async ValueTask >> Handle(GetUsersQuery request, CancellationToken cancellationToken) + public async ValueTask>> Handle(GetUsersQuery request, CancellationToken cancellationToken) { - var user = database.Users + var usersQuery = database.Users .AsNoTracking() .AsQueryable(); - if (request.UsernameSearch is not null) + if (!string.IsNullOrEmpty(request.UsernameSearch)) { var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant(); - user = user.Where(u => u.NormalizedName.Contains(normalizedUsername)); + usersQuery = usersQuery.Where(u => u.NormalizedName.Contains(normalizedUsername)); } - var totalCount = await user.CountAsync(cancellationToken); - var pagedUser = await user + var totalCount = await usersQuery.CountAsync(cancellationToken); + var pagedUsers = await usersQuery .Select(u => UserApplicationResponse.FromEntity(u)) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - return PaginatedResponse.FromPagedList(pagedUser); + return PaginatedResponse.FromPagedList(pagedUsers); } } } diff --git a/backend/Core/Musify.Application/Users/IsFollowingUser.cs b/backend/Core/Musify.Application/Users/IsFollowingUser.cs new file mode 100644 index 0000000..a8e9a2a --- /dev/null +++ b/backend/Core/Musify.Application/Users/IsFollowingUser.cs @@ -0,0 +1,17 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; + +namespace Musify.Application.Users +{ + public record IsFollowingUserQuery(long FollowerId, long FollowedId) : IQuery; + + public class IsFollowingUserQueryHandler(IDatabase database) + : IQueryHandler + { + public ValueTask Handle(IsFollowingUserQuery request, CancellationToken cancellationToken) => + new(database.UserFollows + .AsNoTracking() + .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken)); + } +} diff --git a/backend/Musify.Application/Users/Responses/UserApplicationResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs similarity index 77% rename from backend/Musify.Application/Users/Responses/UserApplicationResponse.cs rename to backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs index bde3649..9bd442f 100644 --- a/backend/Musify.Application/Users/Responses/UserApplicationResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs @@ -1,9 +1,11 @@ -using Musify.Domain.Entities; +using System.Text.Json.Serialization; +using Musify.Application.Serialization; +using Musify.Domain.Entities; namespace Musify.Application.Users.Responses { public record UserApplicationResponse( - long Id, + [property: JsonConverter(typeof(LongAsStringConverter))] long Id, string Name, string? FirstName, string? SecondName, diff --git a/backend/Musify.Application/Users/SyncUser.cs b/backend/Core/Musify.Application/Users/SyncUser.cs similarity index 98% rename from backend/Musify.Application/Users/SyncUser.cs rename to backend/Core/Musify.Application/Users/SyncUser.cs index 576d149..58056ba 100644 --- a/backend/Musify.Application/Users/SyncUser.cs +++ b/backend/Core/Musify.Application/Users/SyncUser.cs @@ -23,7 +23,7 @@ namespace Musify.Application.Users var user = await database.Users .SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken); - if (user is null) + if (user == null) { await database.Users.AddAsync(new User { diff --git a/backend/Core/Musify.Application/Users/UnfollowUser.cs b/backend/Core/Musify.Application/Users/UnfollowUser.cs new file mode 100644 index 0000000..1009d94 --- /dev/null +++ b/backend/Core/Musify.Application/Users/UnfollowUser.cs @@ -0,0 +1,32 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; + +namespace Musify.Application.Users +{ + public record UnfollowUserCommand(long FollowerId, long FollowedId) : ICommand>; + + public class UnfollowUserCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(UnfollowUserCommand request, CancellationToken cancellationToken) + { + var follow = await database.UserFollows + .SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); + + if (follow == null) + return new Success(); + + database.UserFollows.Remove(follow); + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId); + + return new Success(); + } + } +} diff --git a/backend/Musify.Domain/Abstractions/IAuditable.cs b/backend/Core/Musify.Domain/Abstractions/IAuditable.cs similarity index 100% rename from backend/Musify.Domain/Abstractions/IAuditable.cs rename to backend/Core/Musify.Domain/Abstractions/IAuditable.cs diff --git a/backend/Core/Musify.Domain/Domain.csproj b/backend/Core/Musify.Domain/Domain.csproj new file mode 100644 index 0000000..7704456 --- /dev/null +++ b/backend/Core/Musify.Domain/Domain.csproj @@ -0,0 +1,8 @@ + + + + Musify.Domain + Musify.Domain + + + diff --git a/backend/Core/Musify.Domain/Entities/Album.cs b/backend/Core/Musify.Domain/Entities/Album.cs new file mode 100644 index 0000000..bf3f866 --- /dev/null +++ b/backend/Core/Musify.Domain/Entities/Album.cs @@ -0,0 +1,46 @@ +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 class Album : IAuditable + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(200)] + public required string Title { get; set; } + + [Required] + [MaxLength(200)] + public required string NormalizedTitle { get; set; } + + [MaxLength(256)] + public string? Description { get; set; } + + public int? ReleaseYear { get; set; } + + 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; + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Required] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public ICollection AlbumTracks { get; set; } = new List(); + } +} diff --git a/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs b/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs new file mode 100644 index 0000000..ed0bcae --- /dev/null +++ b/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + [Table("AlbumHasTrack")] + public class AlbumHasTrack + { + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required Guid AlbumId { get; set; } + + [Required] + public required Guid TrackId { get; set; } + + [Required] + public int TrackNumber { get; set; } = 1; + + [Required] + public DateTime AddedAt { get; set; } = DateTime.UtcNow; + + [ForeignKey(nameof(AlbumId))] + public Album Album { get; set; } + + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } + } +} diff --git a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs new file mode 100644 index 0000000..ed94b6c --- /dev/null +++ b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + [Table("ListeningHistory")] + public class ListeningHistory + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required long UserId { get; set; } + + [Required] + public required Guid TrackId { get; set; } + + [Required] + public DateTime ListenedAt { get; set; } = DateTime.UtcNow; + + [ForeignKey(nameof(UserId) )] + public User User { get; set; } + + [ForeignKey(nameof(TrackId)) ] + public Track Track { get; set; } + } +} \ No newline at end of file diff --git a/backend/Core/Musify.Domain/Entities/Mix.cs b/backend/Core/Musify.Domain/Entities/Mix.cs new file mode 100644 index 0000000..3d115d4 --- /dev/null +++ b/backend/Core/Musify.Domain/Entities/Mix.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Musify.Domain.Abstractions; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + [Table("Mixes")] + public class Mix : IAuditable + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required long UserId { get; set; } + + [Required] + [MaxLength(100)] + public required string Title { get; set; } + + [MaxLength(256)] + public string? Subtitle { get; set; } + + [Required] + public int Position { get; set; } = 0; + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Required] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + [ForeignKey(nameof(UserId))] + public User User { get; set; } + + public ICollection Items { get; set; } = new List(); + } +} diff --git a/backend/Core/Musify.Domain/Entities/MixItem.cs b/backend/Core/Musify.Domain/Entities/MixItem.cs new file mode 100644 index 0000000..2269e2d --- /dev/null +++ b/backend/Core/Musify.Domain/Entities/MixItem.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + [Table("MixItems")] + public class MixItem + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required Guid MixId { get; set; } + + [Required] + public int Position { get; set; } = 0; + + [Required] + public required Guid TrackId { get; set; } + + [ForeignKey(nameof(MixId))] + public Mix Mix { get; set; } + + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } + } +} diff --git a/backend/Musify.Domain/Entities/PlayList.cs b/backend/Core/Musify.Domain/Entities/PlayList.cs similarity index 66% rename from backend/Musify.Domain/Entities/PlayList.cs rename to backend/Core/Musify.Domain/Entities/PlayList.cs index cf3d509..61ba628 100644 --- a/backend/Musify.Domain/Entities/PlayList.cs +++ b/backend/Core/Musify.Domain/Entities/PlayList.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; @@ -24,29 +24,17 @@ namespace Musify.Domain.Entities [MaxLength(50)] public required string NormalizedName { get; set; } - [Required] [MaxLength(256)] - public required string Description { get; set; } = "No description was provided."; + public string? Description { get; set; } - [Required] - [MaxLength(64)] - public required string OriginalPictureName { get; set; } - - [Required] - [MaxLength(64)] - public required string SmallPictureName { get; set; } - - [Required] - [MaxLength(64)] - public required string MediumPictureName { get; set; } - - [Required] - [MaxLength(64)] - public required string LargePictureName { get; set; } + public PlayListPictures? Pictures { get; set; } [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + [Required] + public PlaylistVisibility Visibility { get; set; } = PlaylistVisibility.Private; + [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/backend/Musify.Domain/Entities/PlayListHasTrack.cs b/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs similarity index 100% rename from backend/Musify.Domain/Entities/PlayListHasTrack.cs rename to backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs diff --git a/backend/Core/Musify.Domain/Entities/Track.cs b/backend/Core/Musify.Domain/Entities/Track.cs new file mode 100644 index 0000000..439970e --- /dev/null +++ b/backend/Core/Musify.Domain/Entities/Track.cs @@ -0,0 +1,54 @@ +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 class Track : IAuditable + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(200)] + public required string Title { get; set; } + + [Required] + [MaxLength(200)] + public required string NormalizedTitle { get; set; } + + [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; + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Required] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public TrackPictures Pictures { get; set; } = new(); + + public TrackAudio Audio { get; set; } = new(); + + public ICollection UserTracks { get; set; } = new List(); + + public ICollection PlayListTracks { get; set; } = new List(); + + public ICollection AlbumTracks { get; set; } = new List(); + + public ICollection ListeningHistories { get; set; } = new List(); + + public ICollection Tags { get; set; } = new List(); + } +} diff --git a/backend/Core/Musify.Domain/Entities/TrackLike.cs b/backend/Core/Musify.Domain/Entities/TrackLike.cs new file mode 100644 index 0000000..41ebb9f --- /dev/null +++ b/backend/Core/Musify.Domain/Entities/TrackLike.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + [Table("TrackLikes")] + public class TrackLike + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required long UserId { get; set; } + + [Required] + public required Guid TrackId { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [ForeignKey(nameof(UserId))] + public User User { get; set; } + + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } + } +} diff --git a/backend/Core/Musify.Domain/Entities/TrackTag.cs b/backend/Core/Musify.Domain/Entities/TrackTag.cs new file mode 100644 index 0000000..16fece7 --- /dev/null +++ b/backend/Core/Musify.Domain/Entities/TrackTag.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Musify.Domain.ValueObjects; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + public class TrackTag + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required Guid TrackId { get; set; } + + [Required] + public required Genre Tag { get; set; } + + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } + } +} diff --git a/backend/Musify.Domain/Entities/Upload.cs b/backend/Core/Musify.Domain/Entities/Upload.cs similarity index 100% rename from backend/Musify.Domain/Entities/Upload.cs rename to backend/Core/Musify.Domain/Entities/Upload.cs diff --git a/backend/Musify.Domain/Entities/UploadIntent.cs b/backend/Core/Musify.Domain/Entities/UploadIntent.cs similarity index 84% rename from backend/Musify.Domain/Entities/UploadIntent.cs rename to backend/Core/Musify.Domain/Entities/UploadIntent.cs index 4f1d15e..7150dd1 100644 --- a/backend/Musify.Domain/Entities/UploadIntent.cs +++ b/backend/Core/Musify.Domain/Entities/UploadIntent.cs @@ -40,6 +40,12 @@ namespace Musify.Domain.Entities [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + [NotMapped] + public bool IsConsumed => Status == UploadIntentStatus.Consumed; + + [NotMapped] + public bool IsExpired => Status == UploadIntentStatus.Expired || ExpiresAt < DateTime.UtcNow; + [ForeignKey(nameof(UserId)) ] public User User { get; set; } = null!; } diff --git a/backend/Musify.Domain/Entities/User.cs b/backend/Core/Musify.Domain/Entities/User.cs similarity index 91% rename from backend/Musify.Domain/Entities/User.cs rename to backend/Core/Musify.Domain/Entities/User.cs index 184dbc6..8030e65 100644 --- a/backend/Musify.Domain/Entities/User.cs +++ b/backend/Core/Musify.Domain/Entities/User.cs @@ -37,5 +37,7 @@ namespace Musify.Domain.Entities public ICollection UserTracks { get; set; } = new List(); public ICollection PlayLists { get; set; } = new List(); + + public ICollection ListeningHistories { get; set; } = new List(); } } \ No newline at end of file diff --git a/backend/Core/Musify.Domain/Entities/UserFollow.cs b/backend/Core/Musify.Domain/Entities/UserFollow.cs new file mode 100644 index 0000000..1cd3322 --- /dev/null +++ b/backend/Core/Musify.Domain/Entities/UserFollow.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + [Table("UserFollows")] + public class UserFollow + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required long FollowerId { get; set; } + + [Required] + public required long FollowedId { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [ForeignKey(nameof(FollowerId))] + public User Follower { get; set; } + + [ForeignKey(nameof(FollowedId))] + public User Followed { get; set; } + } +} diff --git a/backend/Musify.Domain/Entities/UserHasTrack.cs b/backend/Core/Musify.Domain/Entities/UserHasTrack.cs similarity index 100% rename from backend/Musify.Domain/Entities/UserHasTrack.cs rename to backend/Core/Musify.Domain/Entities/UserHasTrack.cs diff --git a/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs b/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs new file mode 100644 index 0000000..26e39c6 --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Domain.ValueObjects +{ + public class AlbumPictures + { + [Required] + public required string OriginalName { get; set; } + + [Required] + public required string SmallName { get; set; } + + [Required] + public required string MediumName { get; set; } + + [Required] + public required string LargeName { get; set; } + } +} diff --git a/backend/Core/Musify.Domain/ValueObjects/Genre.cs b/backend/Core/Musify.Domain/ValueObjects/Genre.cs new file mode 100644 index 0000000..dc9eeb9 --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/Genre.cs @@ -0,0 +1,32 @@ +namespace Musify.Domain.ValueObjects +{ + public enum Genre + { + Pop = 0, + Rock = 1, + HipHop = 2, + RnB = 3, + Jazz = 4, + Blues = 5, + Classical = 6, + Electronic = 7, + House = 8, + Techno = 9, + Trance = 10, + Dubstep = 11, + DrumAndBass = 12, + Metal = 13, + Punk = 14, + Reggae = 15, + Reggaeton = 16, + Country = 17, + Folk = 18, + Indie = 19, + KPop = 20, + Latin = 21, + Soul = 22, + Funk = 23, + Ambient = 24, + Lofi = 25 + } +} diff --git a/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs b/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs new file mode 100644 index 0000000..8b8710c --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs @@ -0,0 +1,48 @@ +namespace Musify.Domain.ValueObjects +{ + + public static class GenreCompatibility + { + private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs(); + + private static HashSet<(Genre, Genre)> BuildIncompatiblePairs() + { + var pairs = new HashSet<(Genre, Genre)>(); + + void Add(Genre a, Genre b) + { + pairs.Add((a, b)); + pairs.Add((b, a)); + } + + var intense = new[] { Genre.Metal, Genre.Punk, Genre.Dubstep, Genre.DrumAndBass }; + var mellow = new[] { Genre.Classical, Genre.Ambient, Genre.Lofi }; + + foreach (var intenseGenre in intense) + foreach (var mellowGenre in mellow) + Add(intenseGenre, mellowGenre); + + return pairs; + } + + public static bool AreCompatible(Genre a, Genre b) => + a == b || !IncompatiblePairs.Contains((a, b)); + + public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable tags) + { + var distinctTags = tags.Distinct().ToList(); + var conflicts = new List<(Genre, Genre)>(); + + for (var i = 0; i < distinctTags.Count; i++) + { + for (var j = i + 1; j < distinctTags.Count; j++) + { + if (!AreCompatible(distinctTags[i], distinctTags[j])) + conflicts.Add((distinctTags[i], distinctTags[j])); + } + } + + return conflicts; + } + } +} diff --git a/backend/Musify.Domain/ValueObjects/LifeCycleStatus.cs b/backend/Core/Musify.Domain/ValueObjects/LifeCycleStatus.cs similarity index 100% rename from backend/Musify.Domain/ValueObjects/LifeCycleStatus.cs rename to backend/Core/Musify.Domain/ValueObjects/LifeCycleStatus.cs diff --git a/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs b/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs new file mode 100644 index 0000000..7c701a9 --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Domain.ValueObjects +{ + public class PlayListPictures + { + [Required] + public required string OriginalName { get; set; } + + [Required] + public required string SmallName { get; set; } + + [Required] + public required string MediumName { get; set; } + + [Required] + public required string LargeName { get; set; } + } +} diff --git a/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs b/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs new file mode 100644 index 0000000..f809e24 --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs @@ -0,0 +1,8 @@ +namespace Musify.Domain.ValueObjects +{ + public enum PlaylistVisibility + { + Private = 0, + Public = 1 + } +} diff --git a/backend/Musify.Domain/ValueObjects/ProcessingStatus.cs b/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs similarity index 100% rename from backend/Musify.Domain/ValueObjects/ProcessingStatus.cs rename to backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs diff --git a/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs b/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs new file mode 100644 index 0000000..2333465 --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs @@ -0,0 +1,27 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Musify.Domain.ValueObjects +{ +#pragma warning disable CS8618 + public class TrackAudio + { + public string? OriginalName { get; set; } + + public string? FolderName { get; set; } + + public ProcessingStatus TranscodeStatus { get; set; } = ProcessingStatus.Pending; + + public bool DownloadRequested { get; set; } + + public int RetryCount { get; set; } + + public DateTime? LastRetryAt { get; set; } + + [MemberNotNullWhen(true, nameof(FolderName))] + public bool IsProcessed => TranscodeStatus == ProcessingStatus.Completed; + + public bool IsFailed => TranscodeStatus == ProcessingStatus.Failed; + + public bool IsInProgress => TranscodeStatus is ProcessingStatus.Pending or ProcessingStatus.Processing; + } +} diff --git a/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs b/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs new file mode 100644 index 0000000..143d9da --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Musify.Domain.ValueObjects +{ +#pragma warning disable CS8618 + public class TrackPictures + { + public string? OriginalName { get; set; } + + public string? SmallName { get; set; } + + public string? MediumName { get; set; } + + public string? LargeName { get; set; } + + public ProcessingStatus ProcessingStatus { get; set; } = ProcessingStatus.Pending; + + [MemberNotNullWhen(true, nameof(SmallName))] + [MemberNotNullWhen(true, nameof(MediumName))] + [MemberNotNullWhen(true, nameof(LargeName))] + public bool IsProcessed => ProcessingStatus == ProcessingStatus.Completed; + } +} diff --git a/backend/Musify.Domain/ValueObjects/UploadIntentPurpose.cs b/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs similarity index 87% rename from backend/Musify.Domain/ValueObjects/UploadIntentPurpose.cs rename to backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs index dc0339b..9629594 100644 --- a/backend/Musify.Domain/ValueObjects/UploadIntentPurpose.cs +++ b/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs @@ -5,5 +5,6 @@ namespace Musify.Domain.ValueObjects PlayListPicture, TrackPicture, TrackAudio, + AlbumPicture, } } diff --git a/backend/Musify.Domain/ValueObjects/UploadIntentStatus.cs b/backend/Core/Musify.Domain/ValueObjects/UploadIntentStatus.cs similarity index 100% rename from backend/Musify.Domain/ValueObjects/UploadIntentStatus.cs rename to backend/Core/Musify.Domain/ValueObjects/UploadIntentStatus.cs diff --git a/backend/Musify.Domain/ValueObjects/UploadState.cs b/backend/Core/Musify.Domain/ValueObjects/UploadState.cs similarity index 100% rename from backend/Musify.Domain/ValueObjects/UploadState.cs rename to backend/Core/Musify.Domain/ValueObjects/UploadState.cs diff --git a/backend/Musify.Infrastructure/Configuration/AudioTranscoderConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs similarity index 95% rename from backend/Musify.Infrastructure/Configuration/AudioTranscoderConfiguration.cs rename to backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs index 7bf532c..b1cb2a9 100644 --- a/backend/Musify.Infrastructure/Configuration/AudioTranscoderConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs @@ -2,7 +2,7 @@ namespace Musify.Infrastructure.Configuration { - public class AudioTranscoderConfiguration + public class AudioConfiguration { public const string SectionName = "AudioTranscoder"; diff --git a/backend/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs similarity index 100% rename from backend/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs rename to backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs diff --git a/backend/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs similarity index 100% rename from backend/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs rename to backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs diff --git a/backend/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs similarity index 100% rename from backend/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs rename to backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs diff --git a/backend/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs similarity index 100% rename from backend/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs rename to backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs diff --git a/backend/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs similarity index 100% rename from backend/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs rename to backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs diff --git a/backend/Musify.Infrastructure/Configuration/WorkerConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs similarity index 100% rename from backend/Musify.Infrastructure/Configuration/WorkerConfiguration.cs rename to backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs diff --git a/backend/Musify.Infrastructure/DesignSettings.json b/backend/Core/Musify.Infrastructure/DesignSettings.json similarity index 100% rename from backend/Musify.Infrastructure/DesignSettings.json rename to backend/Core/Musify.Infrastructure/DesignSettings.json diff --git a/backend/Musify.Infrastructure/Musify.Infrastructure.csproj b/backend/Core/Musify.Infrastructure/Infrastructure.csproj similarity index 80% rename from backend/Musify.Infrastructure/Musify.Infrastructure.csproj rename to backend/Core/Musify.Infrastructure/Infrastructure.csproj index 5a937af..306716f 100644 --- a/backend/Musify.Infrastructure/Musify.Infrastructure.csproj +++ b/backend/Core/Musify.Infrastructure/Infrastructure.csproj @@ -1,11 +1,15 @@ + Musify.Infrastructure + Musify.Infrastructure 07d419fc-9ade-471a-bc0e-aebece785b30 + + @@ -14,6 +18,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + @@ -31,8 +36,8 @@ - - + + diff --git a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs new file mode 100644 index 0000000..16a701c --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs @@ -0,0 +1,37 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Mixes; + +namespace Musify.Infrastructure.Jobs +{ + public class DailyMixGenerationJob( + IDatabase database, + IMediator mediator, + ILogger logger) + { + public async Task RunAsync(CancellationToken cancellationToken) + { + var userIds = await database.ListeningHistories + .AsNoTracking() + .Select(history => history.UserId) + .Distinct() + .ToListAsync(cancellationToken); + + logger.LogInformation("Starting daily mix generation for {Count} users", userIds.Count); + + foreach (var userId in userIds) + { + var result = await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken); + if (result.IsError) + { + logger.LogWarning("Failed to generate mixes for user {UserId}: {Error}", + userId, result.FirstError.Description); + } + } + + logger.LogInformation("Finished daily mix generation"); + } + } +} diff --git a/backend/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs similarity index 100% rename from backend/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs rename to backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs diff --git a/backend/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs similarity index 100% rename from backend/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs rename to backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs new file mode 100644 index 0000000..9e55e31 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs @@ -0,0 +1,32 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.Albums +{ + internal class MarkAlbumAsFailedActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "mark-album-as-failed"; + + public async Task Execute(ExecuteContext executeContext) + { + var album = await database.Albums + .SingleOrDefaultAsync(a => a.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken); + + if (album != null) + { + album.LifeCycleStatus = LifeCycleStatus.Failed; + await database.SaveChangesAsync(executeContext.CancellationToken); + logger.LogInformation("Marked album {AlbumId} as failed", executeContext.Arguments.AlbumId); + } + + return executeContext.Completed(); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs new file mode 100644 index 0000000..0db2cab --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs @@ -0,0 +1,34 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.Albums +{ + internal class PublishAlbumPictureProcessingEventActivity( + IPublishEndpoint publishEndpoint, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "publish-album-picture-processing-event"; + + public async Task Execute(ExecuteContext executeContext) + { + var arguments = executeContext.Arguments; + + await publishEndpoint.Publish( + new UpdateAlbumPictureEvent( + arguments.AlbumId, + arguments.Bucket, + arguments.FinalPictureKey, + arguments.Small, + arguments.Medium, + arguments.Large), + executeContext.CancellationToken); + + logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId); + + return executeContext.Completed(); + } + } +} diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs similarity index 94% rename from backend/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs index 1db4ba5..85b5216 100644 --- a/backend/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs @@ -32,7 +32,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Audio try { - track.AudioTranscodeProcessingStatus = ProcessingStatus.Processing; + track.Audio.TranscodeStatus = ProcessingStatus.Processing; var folderName = Guid.NewGuid().ToString(); var sourceFileName = Path.GetFileName(executeContext.Arguments.SourceKey); var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, folderName); @@ -57,7 +57,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Audio try { - track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed; + track.Audio.TranscodeStatus = ProcessingStatus.Failed; await database.SaveChangesAsync(executeContext.CancellationToken); } catch (Exception dbException) diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs similarity index 83% rename from backend/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs index a0d2b33..c09875c 100644 --- a/backend/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs @@ -38,16 +38,25 @@ namespace Musify.Infrastructure.MassTransit.Activities.Audio try { + AudioTranscodeResult transcodeResult; await using (var fileStream = File.OpenRead(sourceFilePath)) { - await audioTranscoder.TranscodeToAudioFileAsync( + transcodeResult = await audioTranscoder.TranscodeToAudioFileAsync( fileStream, workingDirectory, executeContext.CancellationToken); } + if (transcodeResult.StatusCode != 0) + throw new InvalidOperationException($"ffmpeg transcoding failed with exit code {transcodeResult.StatusCode}."); + File.Delete(sourceFilePath); - return executeContext.Completed(); + + var log = new TranscodeAudioLog(workingDirectory); + return executeContext.CompletedWithVariables(log, new Dictionary + { + [RoutingSlipVariableNames.Audio.DurationSeconds] = (int)Math.Round(transcodeResult.Duration.TotalSeconds) + } ); } catch (Exception exception) { @@ -55,7 +64,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Audio try { - track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed; + track.Audio.TranscodeStatus = ProcessingStatus.Failed; await database.SaveChangesAsync(executeContext.CancellationToken); } catch (Exception dbException) diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs similarity index 81% rename from backend/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs index 499ee0e..472e7fc 100644 --- a/backend/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs @@ -47,12 +47,17 @@ namespace Musify.Infrastructure.MassTransit.Activities.Audio try { + var durationSeconds = executeContext.GetVariable(executeContext.Arguments.DurationSecondsVariable) + ?? throw new InvalidOperationException($"Missing routing slip variable {executeContext.Arguments.DurationSecondsVariable}"); + var log = new UpdateTrackAudioLog( track.Id, - track.AudioFolderName); + track.Audio.FolderName, + track.DurationSeconds); - track.AudioFolderName = Path.GetFileName(audioFolderKey); - track.AudioTranscodeProcessingStatus = ProcessingStatus.Completed; + track.Audio.FolderName = Path.GetFileName(audioFolderKey); + track.DurationSeconds = durationSeconds; + track.Audio.TranscodeStatus = ProcessingStatus.Completed; await database.SaveChangesAsync(executeContext.CancellationToken); logger.LogInformation("Updated track {TrackId} audio", executeContext.Arguments.TrackId); @@ -70,7 +75,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Audio try { - track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed; + track.Audio.TranscodeStatus = ProcessingStatus.Failed; await database.SaveChangesAsync(executeContext.CancellationToken); } catch (Exception dbException) @@ -88,11 +93,12 @@ namespace Musify.Infrastructure.MassTransit.Activities.Audio try { var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == compensateContext.Log.TrackId, compensateContext.CancellationToken); - if (track is null) + if (track == null) throw new InvalidOperationException($"Track with id {compensateContext.Log.TrackId} not found"); - track.AudioFolderName = compensateContext.Log.PreviousAudioFolderName; - track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed; + track.Audio.FolderName = compensateContext.Log.PreviousAudioFolderName; + track.DurationSeconds = compensateContext.Log.PreviousDurationSeconds; + track.Audio.TranscodeStatus = ProcessingStatus.Failed; await database.SaveChangesAsync(compensateContext.CancellationToken); logger.LogInformation("Compensated track {TrackId} audio", compensateContext.Log.TrackId); diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromUrlActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromUrlActivity.cs new file mode 100644 index 0000000..6565766 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromUrlActivity.cs @@ -0,0 +1,69 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities.Files +{ + internal class DownloadFileFromUrlActivity( + IHttpClientFactory httpClientFactory, + ILogger logger) + : IActivity + { + public const string ExecuteEndpointName = "download-file-from-url"; + + public async Task Execute(ExecuteContext executeContext) + { + var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); + ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath)); + + try + { + var httpClient = httpClientFactory.CreateClient(); + + using var response = await httpClient.GetAsync( + executeContext.Arguments.SourceUrl, + HttpCompletionOption.ResponseHeadersRead, + executeContext.CancellationToken); + response.EnsureSuccessStatusCode(); + + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrWhiteSpace(destinationDirectory)) + Directory.CreateDirectory(destinationDirectory); + + await using (var destinationStream = File.Create(destinationPath)) + { + await response.Content.CopyToAsync(destinationStream, executeContext.CancellationToken); + } + + logger.LogInformation("Downloaded {SourceUrl} to {DestinationPath}", + executeContext.Arguments.SourceUrl, destinationPath); + + return executeContext.Completed(new DownloadFileFromUrlLog( + executeContext.Arguments.SourceUrl, + destinationPath)); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to download file from {SourceUrl}", executeContext.Arguments.SourceUrl); + 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 file {DestinationFilePath}", + compensateContext.Log.DestinationFilePath); + return Task.FromResult(compensateContext.Failed(exception)); + } + } + } +} diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs new file mode 100644 index 0000000..53102ea --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs @@ -0,0 +1,113 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities.Pictures +{ + internal class UpdateAlbumPictureActivity( + IDatabase database, + IPublishEndpoint publishEndpoint, + ILogger logger) + : IActivity + { + public const string ExecuteEndpointName = "update-album-picture"; + + public async Task Execute(ExecuteContext executeContext) + { + var smallResizedVariable = executeContext.GetVariable(executeContext.Arguments.SmallPictureVariable); + ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable)); + var mediumResizedVariable = executeContext.GetVariable(executeContext.Arguments.MediumPictureVariable); + ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable)); + var largeResizedVariable = executeContext.GetVariable(executeContext.Arguments.LargePictureVariable); + ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable)); + + try + { + var album = await database.Albums.FindAsync( + [executeContext.Arguments.AlbumId], + cancellationToken: executeContext.CancellationToken); + + if (album == null) + { + logger.LogWarning("Album {AlbumId} not found", + executeContext.Arguments.AlbumId); + throw new InvalidOperationException($"Album with id {executeContext.Arguments.AlbumId} not found"); + } + + if (album.Pictures == null) + { + logger.LogWarning("Album {AlbumId} has no pending picture upload", + executeContext.Arguments.AlbumId); + throw new InvalidOperationException($"Album with id {executeContext.Arguments.AlbumId} has no pending picture upload"); + } + + var log = new UpdateAlbumPictureLog( + album.Id, + album.Pictures.OriginalName, + album.Pictures.SmallName, + album.Pictures.MediumName, + album.Pictures.LargeName); + + album.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); + album.Pictures.SmallName = Path.GetFileName(smallResizedVariable); + album.Pictures.MediumName = Path.GetFileName(mediumResizedVariable); + album.Pictures.LargeName = Path.GetFileName(largeResizedVariable); + + database.Albums.Update(album); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Updated album {AlbumId} pictures", + executeContext.Arguments.AlbumId); + + await publishEndpoint.Publish( + new AlbumPictureProcessed(executeContext.Arguments.AlbumId), + executeContext.CancellationToken); + + return executeContext.Completed(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to update album {AlbumId} pictures", + executeContext.Arguments.AlbumId); + throw; + } + } + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + var album = await database.Albums.FindAsync( + [compensateContext.Log.AlbumId], + cancellationToken: compensateContext.CancellationToken); + + if (album == null) + { + return compensateContext.Compensated(); + } + + album.Pictures = new AlbumPictures + { + OriginalName = compensateContext.Log.PreviousOriginalPictureKey, + SmallName = compensateContext.Log.PreviousSmallPictureKey, + MediumName = compensateContext.Log.PreviousMediumPictureKey, + LargeName = compensateContext.Log.PreviousLargePictureKey + }; + + database.Albums.Update(album); + await database.SaveChangesAsync(compensateContext.CancellationToken); + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate album {AlbumId} pictures", compensateContext.Log.AlbumId); + return compensateContext.Failed(exception); + } + } + } +} diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs similarity index 71% rename from backend/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs index 4d38041..4a4bfe2 100644 --- a/backend/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs @@ -2,6 +2,7 @@ using MassTransit; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Events; +using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; @@ -30,24 +31,31 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures [executeContext.Arguments.PlayListId], cancellationToken: executeContext.CancellationToken); - if (playList is null) + if (playList == null) { logger.LogWarning("Playlist {PlayListId} not found", executeContext.Arguments.PlayListId); throw new InvalidOperationException($"PlayList with id {executeContext.Arguments.PlayListId} not found"); } + if (playList.Pictures == null) + { + logger.LogWarning("Playlist {PlayListId} has no pending picture upload", + executeContext.Arguments.PlayListId); + throw new InvalidOperationException($"PlayList with id {executeContext.Arguments.PlayListId} has no pending picture upload"); + } + var log = new UpdatePlayListPictureLog( playList.Id, - playList.OriginalPictureName, - playList.SmallPictureName, - playList.MediumPictureName, - playList.LargePictureName); + playList.Pictures.OriginalName, + playList.Pictures.SmallName, + playList.Pictures.MediumName, + playList.Pictures.LargeName); - playList.OriginalPictureName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); - playList.SmallPictureName = Path.GetFileName(smallResizedVariable); - playList.MediumPictureName = Path.GetFileName(mediumResizedVariable); - playList.LargePictureName = Path.GetFileName(largeResizedVariable); + playList.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); + playList.Pictures.SmallName = Path.GetFileName(smallResizedVariable); + playList.Pictures.MediumName = Path.GetFileName(mediumResizedVariable); + playList.Pictures.LargeName = Path.GetFileName(largeResizedVariable); database.PlayLists.Update(playList); await database.SaveChangesAsync(executeContext.CancellationToken); @@ -77,15 +85,18 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures [compensateContext.Log.PlayListId], cancellationToken: compensateContext.CancellationToken); - if (playList is null) + if (playList == null) { return compensateContext.Compensated(); } - playList.OriginalPictureName = compensateContext.Log.PreviousOriginalPictureKey; - playList.SmallPictureName = compensateContext.Log.PreviousSmallPictureKey; - playList.MediumPictureName = compensateContext.Log.PreviousMediumPictureKey; - playList.LargePictureName = compensateContext.Log.PreviousLargePictureKey; + playList.Pictures = new PlayListPictures + { + OriginalName = compensateContext.Log.PreviousOriginalPictureKey, + SmallName = compensateContext.Log.PreviousSmallPictureKey, + MediumName = compensateContext.Log.PreviousMediumPictureKey, + LargeName = compensateContext.Log.PreviousLargePictureKey + }; database.PlayLists.Update(playList); await database.SaveChangesAsync(compensateContext.CancellationToken); diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs similarity index 76% rename from backend/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs index 068b44b..2f240c8 100644 --- a/backend/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs @@ -31,7 +31,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures [executeContext.Arguments.TrackId], cancellationToken: executeContext.CancellationToken); - if (track is null) + if (track == null) { logger.LogWarning("Track {TrackId} not found", executeContext.Arguments.TrackId); @@ -40,16 +40,16 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures var log = new UpdateTrackPictureLog( track.Id, - track.OriginalPictureName, - track.SmallPictureName, - track.MediumPictureName, - track.LargePictureName); + track.Pictures.OriginalName, + track.Pictures.SmallName, + track.Pictures.MediumName, + track.Pictures.LargeName); - track.OriginalPictureName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); - track.SmallPictureName = Path.GetFileName(smallResizedVariable); - track.MediumPictureName = Path.GetFileName(mediumResizedVariable); - track.LargePictureName = Path.GetFileName(largeResizedVariable); - track.PicturesProcessingStatus = ProcessingStatus.Completed; + track.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); + track.Pictures.SmallName = Path.GetFileName(smallResizedVariable); + track.Pictures.MediumName = Path.GetFileName(mediumResizedVariable); + track.Pictures.LargeName = Path.GetFileName(largeResizedVariable); + track.Pictures.ProcessingStatus = ProcessingStatus.Completed; database.Tracks.Update(track); await database.SaveChangesAsync(executeContext.CancellationToken); @@ -79,14 +79,14 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures [compensateContext.Log.TrackId], cancellationToken: compensateContext.CancellationToken); - if (track is null) + if (track == null) return compensateContext.Compensated(); - track.OriginalPictureName = compensateContext.Log.PreviousOriginalPictureKey; - track.SmallPictureName = compensateContext.Log.PreviousSmallPictureKey; - track.MediumPictureName = compensateContext.Log.PreviousMediumPictureKey; - track.LargePictureName = compensateContext.Log.PreviousLargePictureKey; - track.PicturesProcessingStatus = ProcessingStatus.Failed; + track.Pictures.OriginalName = compensateContext.Log.PreviousOriginalPictureKey; + track.Pictures.SmallName = compensateContext.Log.PreviousSmallPictureKey; + track.Pictures.MediumName = compensateContext.Log.PreviousMediumPictureKey; + track.Pictures.LargeName = compensateContext.Log.PreviousLargePictureKey; + track.Pictures.ProcessingStatus = ProcessingStatus.Failed; database.Tracks.Update(track); await database.SaveChangesAsync(compensateContext.CancellationToken); diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs similarity index 97% rename from backend/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs index 8329408..1c589e8 100644 --- a/backend/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs @@ -18,7 +18,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.PlayLists var playList = await database.PlayLists .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); - if (playList is null) + if (playList == null) { logger.LogInformation( "PlayList {PlayListId} not found while deleting from DB, skipping", diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs similarity index 96% rename from backend/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs index 41f4ddd..f534204 100644 --- a/backend/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs @@ -19,7 +19,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.PlayLists var playList = await database.PlayLists .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); - if (playList is not null) + if (playList != null) { playList.LifeCycleStatus = LifeCycleStatus.Failed; await database.SaveChangesAsync(executeContext.CancellationToken); diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs similarity index 97% rename from backend/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs index fefc42a..bac3433 100644 --- a/backend/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs @@ -19,7 +19,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.PlayLists var playList = await database.PlayLists .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); - if (playList is null) + if (playList == null) { logger.LogInformation( "PlayList {PlayListId} not found while marking as removing, skipping", diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs similarity index 97% rename from backend/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs index 28e4c15..cb1b0e0 100644 --- a/backend/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs @@ -18,7 +18,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks var track = await database.Tracks .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - if (track is null) + if (track == null) { logger.LogInformation( "Track {TrackId} not found while deleting from DB, skipping", diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs similarity index 97% rename from backend/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs index 10541b1..46f2a69 100644 --- a/backend/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs @@ -19,7 +19,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks var track = await database.Tracks .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - if (track is not null) + if (track != null) { track.LifeCycleStatus = LifeCycleStatus.Failed; await database.SaveChangesAsync(executeContext.CancellationToken); diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs similarity index 97% rename from backend/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs index f54b42e..d5b9a10 100644 --- a/backend/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs @@ -19,7 +19,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks var track = await database.Tracks .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - if (track is null) + if (track == null) { logger.LogInformation( "Track {TrackId} not found while marking as removing, skipping", diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromUrlArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromUrlArguments.cs new file mode 100644 index 0000000..5d7422b --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromUrlArguments.cs @@ -0,0 +1,6 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record DownloadFileFromUrlArguments( + string SourceUrl, + string DestinationFilePathVariable); +} diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs new file mode 100644 index 0000000..1c9cf0e --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs @@ -0,0 +1,4 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record MarkAlbumAsFailedArguments(Guid AlbumId); +} diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsFailedArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsFailedArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsFailedArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsFailedArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs new file mode 100644 index 0000000..f484264 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs @@ -0,0 +1,12 @@ +using Musify.Application.Shared; + +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record PublishAlbumPictureProcessingEventArguments( + Guid AlbumId, + string Bucket, + string FinalPictureKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs new file mode 100644 index 0000000..6f006f0 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs @@ -0,0 +1,9 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record UpdateAlbumPictureArguments( + Guid AlbumId, + string OriginalPictureKey, + string SmallPictureVariable, + string MediumPictureVariable, + string LargePictureVariable); +} diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs similarity index 63% rename from backend/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs index 10edb2b..af0ae38 100644 --- a/backend/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs @@ -2,5 +2,6 @@ { public record UpdateTrackAudioArguments( Guid TrackId, - string AudioFolderKey); + string AudioFolderKey, + string DurationSecondsVariable); } \ No newline at end of file diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs new file mode 100644 index 0000000..db2c554 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs @@ -0,0 +1,36 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Activities.Albums; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.RoutingSlip; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer + { + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + + var builder = new RoutingSlipBuilder(NewId.NextGuid()); + builder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.PictureKey)) + builder.AddActivity( + ActivityNames.RemoveAlbumOriginalPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments(message.Bucket, message.PictureKey)); + + builder.AddActivity( + ActivityNames.MarkAlbumAsFailed, + EndpointHelper.BuildExecuteActivityUri(MarkAlbumAsFailedActivity.ExecuteEndpointName), + new MarkAlbumAsFailedArguments(message.AlbumId)); + + await bus.Execute(builder.Build()); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs new file mode 100644 index 0000000..0c022f8 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs @@ -0,0 +1,22 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class CreateAlbumConsumer( + IBus bus, + AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); + + await bus.Execute(routingSlip); + } + } +} diff --git a/backend/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs new file mode 100644 index 0000000..0a31f11 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs @@ -0,0 +1,22 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class UpdateAlbumPictureConsumer( + IBus bus, + PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); + + await bus.Execute(routingSlip); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs new file mode 100644 index 0000000..9ce0dcd --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs @@ -0,0 +1,22 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class UpdateAlbumPictureSourceConsumer( + IBus bus, + AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); + + await bus.Execute(routingSlip); + } + } +} diff --git a/backend/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs diff --git a/backend/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs rename to backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs diff --git a/backend/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs similarity index 84% rename from backend/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs rename to backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs index ccfdb8b..cae468b 100644 --- a/backend/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -1,6 +1,7 @@ using MassTransit; using Microsoft.Extensions.DependencyInjection; using Musify.Infrastructure.MassTransit.Activities.Pictures; +using Musify.Infrastructure.MassTransit.Activities.Albums; using Musify.Infrastructure.MassTransit.Activities.Audio; using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Activities.PlayLists; @@ -23,6 +24,7 @@ namespace Musify.Infrastructure.MassTransit services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options) @@ -39,6 +41,10 @@ namespace Musify.Infrastructure.MassTransit options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); options.AddExecuteActivity(); options.AddExecuteActivity(); @@ -51,9 +57,12 @@ namespace Musify.Infrastructure.MassTransit options.AddExecuteActivity(); options.AddExecuteActivity(); options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); options.AddActivity(); options.AddActivity(); + options.AddActivity(); options.AddActivity(); options.AddActivity(); options.AddActivity(); @@ -62,6 +71,7 @@ namespace Musify.Infrastructure.MassTransit options.AddActivity(); options.AddActivity(); options.AddActivity(); + options.AddActivity(); } } } diff --git a/backend/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs similarity index 92% rename from backend/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs rename to backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs index ae9df96..5790c2c 100644 --- a/backend/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs @@ -83,6 +83,14 @@ namespace Musify.Infrastructure.MassTransit repository.UsePostgres(); } ); + options.AddSagaStateMachine() + .EntityFrameworkRepository(repository => + { + repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; + repository.ExistingDbContext(); + repository.UsePostgres(); + } ); + options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) => { UseStandardRetry(endpointConfigurator); diff --git a/backend/Musify.Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromUrlLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromUrlLog.cs new file mode 100644 index 0000000..5c80cc1 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromUrlLog.cs @@ -0,0 +1,6 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record DownloadFileFromUrlLog( + string SourceUrl, + string DestinationFilePath); +} diff --git a/backend/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Logs/ResizePictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/ResizePictureLog.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Logs/ResizePictureLog.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Logs/ResizePictureLog.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs new file mode 100644 index 0000000..7563dbf --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs @@ -0,0 +1,9 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record UpdateAlbumPictureLog( + Guid AlbumId, + string PreviousOriginalPictureKey, + string PreviousSmallPictureKey, + string PreviousMediumPictureKey, + string PreviousLargePictureKey); +} diff --git a/backend/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs similarity index 58% rename from backend/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs index 355a755..eee0342 100644 --- a/backend/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs @@ -2,5 +2,6 @@ { public record UpdateTrackAudioLog( Guid TrackId, - string? PreviousAudioFolderName); + string? PreviousAudioFolderName, + double PreviousDurationSeconds); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs new file mode 100644 index 0000000..eb8c991 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs @@ -0,0 +1,9 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record UpdateTrackPictureLog( + Guid TrackId, + string? PreviousOriginalPictureKey, + string? PreviousSmallPictureKey, + string? PreviousMediumPictureKey, + string? PreviousLargePictureKey); +} diff --git a/backend/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs diff --git a/backend/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs b/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs rename to backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs diff --git a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs similarity index 87% rename from backend/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs rename to backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs index 902f107..544388c 100644 --- a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs @@ -8,11 +8,13 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip internal const string MarkPlayListAsRemoving = "MarkPlayListAsRemoving"; internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed"; internal const string DeletePlayListFromDb = "DeletePlayListFromDb"; + internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed"; internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths"; internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; internal const string DownloadFile = "DownloadFile"; + internal const string DownloadThumbnail = "DownloadThumbnail"; internal const string TranscodeAudio = "TranscodeAudio"; @@ -28,6 +30,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip internal const string UpdateTrackPicture = "UpdateTrackPicture"; internal const string UpdatePlayListPicture = "UpdatePlayListPicture"; + internal const string UpdateAlbumPicture = "UpdateAlbumPicture"; internal const string UpdateTrackAudio = "UpdateTrackAudio"; internal const string CopyPictureToFinal = "CopyPictureToFinal"; @@ -37,6 +40,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip internal const string PublishTrackProcessingEvents = "PublishTrackProcessingEvents"; internal const string PublishPlayListPictureProcessingEvent = "PublishPlayListPictureProcessingEvent"; + internal const string PublishAlbumPictureProcessingEvent = "PublishAlbumPictureProcessingEvent"; internal const string RemoveTrackOriginalPicture = "RemoveTrackOriginalPicture"; internal const string RemoveTrackSmallPicture = "RemoveTrackSmallPicture"; @@ -49,5 +53,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip internal const string RemovePlayListSmallPicture = "RemovePlayListSmallPicture"; internal const string RemovePlayListMediumPicture = "RemovePlayListMediumPicture"; internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture"; + + internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture"; } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs new file mode 100644 index 0000000..f19686f --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs @@ -0,0 +1,85 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Musify.Application.Shared; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Activities.Albums; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.UploadIntents; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Consumers; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +{ + public class AlbumPictureSourceRoutingSlipBuilder + { + public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) + => BuildSlip( + message.AlbumId, + message.PictureIntentId, + message.Bucket, + message.PictureSourceKey, + message.PictureDestinationKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId) + => BuildSlip( + message.AlbumId, + message.PictureIntentId, + message.Bucket, + message.PictureSourceKey, + message.PictureDestinationKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + static RoutingSlipBuilder BuildSlip( + Guid albumId, + Guid pictureIntentId, + string bucket, + string pictureSourceKey, + string pictureDestinationKey, + ImageSize small, + ImageSize medium, + ImageSize large, + 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.CopyPictureToFinal, + EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), + new CopyFileInBucketArguments( + bucket, + pictureSourceKey, + bucket, + pictureDestinationKey)); + + routingSlipBuilder.AddActivity( + ActivityNames.ConsumeUploadIntents, + EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName), + new ConsumeUploadIntentsArguments([pictureIntentId])); + + routingSlipBuilder.AddActivity( + ActivityNames.PublishAlbumPictureProcessingEvent, + EndpointHelper.BuildExecuteActivityUri(PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName), + new PublishAlbumPictureProcessingEventArguments( + albumId, + bucket, + pictureDestinationKey, + small, + medium, + large)); + + return routingSlipBuilder; + } + } +} diff --git a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs similarity index 96% rename from backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs rename to backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs index 41867e9..2d807b1 100644 --- a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs @@ -4,7 +4,6 @@ using Musify.Application.Events; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Activities.Audio; using Musify.Infrastructure.MassTransit.Activities.Files; -using Musify.Infrastructure.MassTransit.Activities; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Consumers; @@ -58,7 +57,8 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders EndpointHelper.BuildExecuteActivityUri(UpdateTrackAudioActivity.ExecuteEndpointName), new UpdateTrackAudioArguments( message.TrackId, - message.DestinationFolderKey)); + message.DestinationFolderKey, + RoutingSlipVariableNames.Audio.DurationSeconds)); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackAudio); diff --git a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs rename to backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs diff --git a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs similarity index 85% rename from backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs rename to backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs index 4390061..9af5547 100644 --- a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs @@ -37,49 +37,49 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders EndpointHelper.BuildExecuteActivityUri(MarkPlayListAsRemovingActivity.ExecuteEndpointName), new MarkPlayListAsRemovingArguments(playListId)); - if (playList is not null) + if (playList?.Pictures != null) { var bucket = storageConfiguration.Bucket; var routes = playListConfiguration.Routes; - if (playList.OriginalPictureName != routes.PresetOriginalPicture) + if (playList.Pictures.OriginalName is { } originalName) { routingSlipBuilder.AddActivity( ActivityNames.RemovePlayListOriginalPicture, EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - routes.BuildOriginalPicturePath(userId, playList.OriginalPictureName))); + routes.BuildOriginalPicturePath(userId, originalName))); } - if (playList.SmallPictureName != routes.PresetSmallPicture) + if (playList.Pictures.SmallName is { } smallName) { routingSlipBuilder.AddActivity( ActivityNames.RemovePlayListSmallPicture, EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - routes.BuildSmallPicturePath(playList.SmallPictureName))); + routes.BuildSmallPicturePath(smallName))); } - if (playList.MediumPictureName != routes.PresetMediumPicture) + if (playList.Pictures.MediumName is { } mediumName) { routingSlipBuilder.AddActivity( ActivityNames.RemovePlayListMediumPicture, EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - routes.BuildMediumPicturePath(playList.MediumPictureName))); + routes.BuildMediumPicturePath(mediumName))); } - if (playList.LargePictureName != routes.PresetLargePicture) + if (playList.Pictures.LargeName is { } largeName) { routingSlipBuilder.AddActivity( ActivityNames.RemovePlayListLargePicture, EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - routes.BuildLargePicturePath(playList.LargePictureName))); + routes.BuildLargePicturePath(largeName))); } } diff --git a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs similarity index 72% rename from backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs rename to backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs index 2c6a0f2..35b8000 100644 --- a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs @@ -37,56 +37,62 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders EndpointHelper.BuildExecuteActivityUri(MarkTrackAsRemovingActivity.ExecuteEndpointName), new MarkTrackAsRemovingArguments(trackId)); - if (track is not null) + if (track != null) { var bucket = storageConfiguration.Bucket; - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackOriginalPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.OriginalPictureName))); + if (!string.IsNullOrEmpty(track.Pictures.OriginalName)) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveTrackOriginalPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName))); + } - if (track.IsPicturesProcessed) + if (track.Pictures.IsProcessed) { routingSlipBuilder.AddActivity( ActivityNames.RemoveTrackSmallPicture, EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - trackConfiguration.Routes.BuildSmallPicturePath(track.SmallPictureName))); + trackConfiguration.Routes.BuildSmallPicturePath(track.Pictures.SmallName))); routingSlipBuilder.AddActivity( ActivityNames.RemoveTrackMediumPicture, EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - trackConfiguration.Routes.BuildMediumPicturePath(track.MediumPictureName))); + trackConfiguration.Routes.BuildMediumPicturePath(track.Pictures.MediumName))); routingSlipBuilder.AddActivity( ActivityNames.RemoveTrackLargePicture, EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - trackConfiguration.Routes.BuildLargePicturePath(track.LargePictureName))); + trackConfiguration.Routes.BuildLargePicturePath(track.Pictures.LargeName))); } - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackOriginalAudio, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.OriginalAudioName))); + if (!string.IsNullOrEmpty(track.Audio.OriginalName)) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveTrackOriginalAudio, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.Audio.OriginalName))); + } - if (track.IsAudioProcessed) + if (track.Audio.IsProcessed) { routingSlipBuilder.AddActivity( ActivityNames.RemoveTrackProcessedAudio, EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - trackConfiguration.Routes.BuildProcessedAudioPath(track.AudioFolderName))); + trackConfiguration.Routes.BuildProcessedAudioPath(track.Audio.FolderName))); } } diff --git a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs similarity index 82% rename from backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs rename to backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs index b411ac1..34bf4e4 100644 --- a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs @@ -70,6 +70,35 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders return routingSlipBuilder; } + public RoutingSlipBuilder Build(UpdateAlbumPictureEvent message, Guid? correlationId) + { + var routingSlipBuilder = BuildPictureWorkflow( + message.Bucket, + message.SourceKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + routingSlipBuilder.AddActivity( + ActivityNames.UpdateAlbumPicture, + EndpointHelper.BuildExecuteActivityUri(UpdateAlbumPictureActivity.ExecuteEndpointName), + new UpdateAlbumPictureArguments( + message.AlbumId, + message.SourceKey, + RoutingSlipVariableNames.Picture.SmallResizedFilePath, + RoutingSlipVariableNames.Picture.MediumResizedFilePath, + RoutingSlipVariableNames.Picture.LargeResizedFilePath)); + + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.AlbumId); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.AlbumPicture); + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), + RoutingSlipEvents.Faulted); + + return routingSlipBuilder; + } + RoutingSlipBuilder BuildPictureWorkflow( string bucket, string sourceKey, diff --git a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs rename to backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs diff --git a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs similarity index 90% rename from backend/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs rename to backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs index 6549d03..ed2b181 100644 --- a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs @@ -15,12 +15,14 @@ namespace Musify.Infrastructure.MassTransit internal const string TrackPicture = "TrackPicture"; internal const string TrackAudio = "TrackAudio"; internal const string PlayListPicture = "PlayListPicture"; + internal const string AlbumPicture = "AlbumPicture"; } internal static class Audio { internal const string SourceFilePath = "Audio.SourceFilePath"; internal const string TranscodedDirectory = "Audio.TranscodedDirectory"; + internal const string DurationSeconds = "Audio.DurationSeconds"; } internal static class Picture diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs new file mode 100644 index 0000000..8229784 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs @@ -0,0 +1,19 @@ +using MassTransit; + +namespace Musify.Infrastructure.MassTransit.Sagas +{ + public class AlbumProcessingState : SagaStateMachineInstance + { + public Guid CorrelationId { get; set; } + + public string CurrentState { get; set; } = null!; + + public string? Bucket { get; set; } + + public string? PictureKey { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime UpdatedAt { get; set; } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs new file mode 100644 index 0000000..332c90b --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs @@ -0,0 +1,51 @@ +using MassTransit; +using Musify.Application.Events; + +namespace Musify.Infrastructure.MassTransit.Sagas +{ + public sealed class AlbumProcessingStateMachine : MassTransitStateMachine + { + public State Processing { get; private set; } = null!; + public State Failed { get; private set; } = null!; + + public Event ProcessingStarted { get; private set; } = null!; + public Event PictureProcessed { get; private set; } = null!; + public Event PictureFailed { get; private set; } = null!; + + public AlbumProcessingStateMachine() + { + InstanceState(state => state.CurrentState); + + Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.AlbumId)); + Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.AlbumId)); + Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.AlbumId)); + + Initially( + When(ProcessingStarted) + .Then(context => + { + context.Saga.CreatedAt = DateTime.UtcNow; + context.Saga.UpdatedAt = DateTime.UtcNow; + context.Saga.Bucket = context.Message.Bucket; + context.Saga.PictureKey = context.Message.PictureDestinationKey; + } ) + .TransitionTo(Processing)); + + During(Processing, + When(PictureProcessed).Finalize(), + When(PictureFailed) + .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) + .TransitionTo(Failed) + .Publish(context => new AlbumProcessingFailed( + context.Saga.CorrelationId, + context.Saga.Bucket, + context.Saga.PictureKey))); + + During(Failed, + Ignore(PictureProcessed), + Ignore(PictureFailed)); + + SetCompletedWhenFinalized(); + } + } +} diff --git a/backend/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs diff --git a/backend/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs similarity index 100% rename from backend/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs rename to backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs diff --git a/backend/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs similarity index 100% rename from backend/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs rename to backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs diff --git a/backend/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs b/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs similarity index 94% rename from backend/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs rename to backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs index 40c7e69..c1732d2 100644 --- a/backend/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs @@ -21,9 +21,11 @@ namespace Musify.Infrastructure.Persistence private static void ApplyTimestamps(DbContext? context) { - if (context is null) + if (context == null) return; + context.ChangeTracker.DetectChanges(); + var now = DateTime.UtcNow; foreach (var entry in context.ChangeTracker.Entries()) diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs new file mode 100644 index 0000000..ad18df8 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -0,0 +1,212 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using MassTransit; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.MassTransit.Sagas; +using AppIDatabase = Musify.Application.Contracts.IDatabase; +using Musify.Application.Contracts; + +namespace Musify.Infrastructure.Persistence +{ + public class Database(DatabaseConfiguration configuration) + : DbContext, AppIDatabase + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(configuration.ConnectionString, o => o.MapEnum("genre")); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.AddInboxStateEntity(); + modelBuilder.AddOutboxMessageEntity(); + modelBuilder.AddOutboxStateEntity(); + + var trackProcessing = modelBuilder.Entity(); + trackProcessing.HasKey(state => state.CorrelationId); + trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + trackProcessing.Property(state => state.CurrentState).HasMaxLength(64); + + modelBuilder.Entity() + .OwnsOne(track => track.Pictures, pictures => + { + pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); + pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64); + pictures.Property(p => p.ProcessingStatus).HasColumnName("PicturesProcessingStatus"); + }); + modelBuilder.Entity().Navigation(track => track.Pictures).IsRequired(); + + modelBuilder.Entity() + .OwnsOne(track => track.Audio, audio => + { + audio.Property(a => a.OriginalName).HasColumnName("OriginalAudioName").HasMaxLength(64); + audio.Property(a => a.FolderName).HasColumnName("AudioFolderName").HasMaxLength(64); + audio.Property(a => a.TranscodeStatus).HasColumnName("AudioTranscodeProcessingStatus"); + audio.Property(a => a.DownloadRequested).HasColumnName("DownloadRequested"); + audio.Property(a => a.RetryCount).HasColumnName("RetryCount"); + audio.Property(a => a.LastRetryAt).HasColumnName("LastRetryAt"); + }); + modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); + + modelBuilder.Entity() + .HasOne(track => track.Owner) + .WithMany() + .HasForeignKey(track => track.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .Property(playList => playList.Visibility) + .HasDefaultValue(PlaylistVisibility.Private); + + modelBuilder.Entity() + .OwnsOne(playList => playList.Pictures, pictures => + { + pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false); + }); + modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(false); + + modelBuilder.Entity() + .OwnsOne(album => album.Pictures, pictures => + { + pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); + pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64); + }); + modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); + + modelBuilder.Entity() + .HasOne(album => album.Owner) + .WithMany() + .HasForeignKey(album => album.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Album) + .WithMany(album => album.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.AlbumId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Track) + .WithMany(track => track.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(mix => new { mix.UserId, mix.Position }); + + modelBuilder.Entity() + .HasOne(item => item.Mix) + .WithMany(mix => mix.Items) + .HasForeignKey(item => item.MixId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(item => item.Track) + .WithMany() + .HasForeignKey(item => item.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(item => new { item.MixId, item.Position }); + + modelBuilder.Entity() + .HasIndex(like => new { like.UserId, like.TrackId }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(trackTag => trackTag.Track) + .WithMany(track => track.Tags) + .HasForeignKey(trackTag => trackTag.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(follow => follow.Follower) + .WithMany() + .HasForeignKey(follow => follow.FollowerId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(follow => follow.Followed) + .WithMany() + .HasForeignKey(follow => follow.FollowedId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(follow => new { follow.FollowerId, follow.FollowedId }) + .IsUnique(); + + var playListProcessing = modelBuilder.Entity(); + playListProcessing.HasKey(state => state.CorrelationId); + playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + playListProcessing.Property(state => state.CurrentState).HasMaxLength(64); + + var albumProcessing = modelBuilder.Entity(); + albumProcessing.HasKey(state => state.CorrelationId); + albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + albumProcessing.Property(state => state.CurrentState).HasMaxLength(64); + } + + public DbSet Users => Set(); + + public DbSet Tracks => Set(); + + public DbSet PlayLists => Set(); + + public DbSet Albums => Set(); + + public DbSet AlbumHasTracks => Set(); + + public DbSet UserHasTracks => Set(); + + public DbSet PlayListHasTracks => Set(); + + public DbSet Mixes => Set(); + + public DbSet MixItems => Set(); + + public DbSet Uploads => Set(); + + public DbSet UploadIntents => Set(); + + public DbSet ListeningHistories => Set(); + + public DbSet TrackLikes => Set(); + + public DbSet TrackTags => Set(); + + public DbSet UserFollows => Set(); + + public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) + { + var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); + return new DatabaseTransaction(transaction); + } + + private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction + { + public Task CommitAsync(CancellationToken cancellationToken) => + inner.CommitAsync(cancellationToken); + + public ValueTask DisposeAsync() => inner.DisposeAsync(); + } + } +} diff --git a/backend/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs similarity index 97% rename from backend/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs rename to backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs index a7fa1a8..5052808 100644 --- a/backend/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs @@ -1,4 +1,3 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; diff --git a/backend/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs similarity index 90% rename from backend/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs rename to backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs index 18f38a9..2b3cb7d 100644 --- a/backend/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs @@ -11,8 +11,9 @@ namespace Musify.Infrastructure.Persistence { var configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("DesignSettings.json") + .AddJsonFile("DesignSettings.json", optional: true) .AddUserSecrets() + .AddEnvironmentVariables() .Build(); var databaseConfiguration = configuration diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260530020027_InitialCreate.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260530020027_InitialCreate.Designer.cs similarity index 100% rename from backend/Musify.Infrastructure/Persistence/Migrations/20260530020027_InitialCreate.Designer.cs rename to backend/Core/Musify.Infrastructure/Persistence/Migrations/20260530020027_InitialCreate.Designer.cs diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260530020027_InitialCreate.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260530020027_InitialCreate.cs similarity index 100% rename from backend/Musify.Infrastructure/Persistence/Migrations/20260530020027_InitialCreate.cs rename to backend/Core/Musify.Infrastructure/Persistence/Migrations/20260530020027_InitialCreate.cs diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.Designer.cs similarity index 99% rename from backend/Musify.Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.Designer.cs rename to backend/Core/Musify.Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.Designer.cs index 28bd9da..ab1b33a 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.Designer.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.Designer.cs @@ -1,4 +1,4 @@ -// + using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations [Migration("20260531234306_AddUserProfilePictureUrl")] partial class AddUserProfilePictureUrl { - /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.cs similarity index 90% rename from backend/Musify.Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.cs rename to backend/Core/Musify.Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.cs index 5342474..06b0071 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.cs @@ -4,10 +4,10 @@ namespace Musify.Infrastructure.Persistence.Migrations { - /// + public partial class AddUserProfilePictureUrl : Migration { - /// + protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn( @@ -18,7 +18,6 @@ namespace Musify.Infrastructure.Persistence.Migrations nullable: true); } - /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropColumn( diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.Designer.cs similarity index 99% rename from backend/Musify.Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.Designer.cs rename to backend/Core/Musify.Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.Designer.cs index 279f318..6974cbe 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.Designer.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.Designer.cs @@ -1,4 +1,4 @@ -// + using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations [Migration("20260601195757_RenameTimestampsAndAudit")] partial class RenameTimestampsAndAudit { - /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.cs similarity index 95% rename from backend/Musify.Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.cs rename to backend/Core/Musify.Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.cs index 540250e..d049bbd 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.cs @@ -4,10 +4,10 @@ namespace Musify.Infrastructure.Persistence.Migrations { - /// + public partial class RenameTimestampsAndAudit : Migration { - /// + protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.RenameColumn( @@ -31,7 +31,6 @@ namespace Musify.Infrastructure.Persistence.Migrations newName: "CreatedAt"); } - /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.RenameColumn( diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260705123932_AddTrackProcessingStateSaga.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705123932_AddTrackProcessingStateSaga.Designer.cs similarity index 99% rename from backend/Musify.Infrastructure/Persistence/Migrations/20260705123932_AddTrackProcessingStateSaga.Designer.cs rename to backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705123932_AddTrackProcessingStateSaga.Designer.cs index a9244d0..21eb9d0 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/20260705123932_AddTrackProcessingStateSaga.Designer.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705123932_AddTrackProcessingStateSaga.Designer.cs @@ -1,4 +1,4 @@ -// + using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations [Migration("20260705123932_AddTrackProcessingStateSaga")] partial class AddTrackProcessingStateSaga { - /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260705123932_AddTrackProcessingStateSaga.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705123932_AddTrackProcessingStateSaga.cs similarity index 90% rename from backend/Musify.Infrastructure/Persistence/Migrations/20260705123932_AddTrackProcessingStateSaga.cs rename to backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705123932_AddTrackProcessingStateSaga.cs index 2a0c159..78c010d 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/20260705123932_AddTrackProcessingStateSaga.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705123932_AddTrackProcessingStateSaga.cs @@ -1,14 +1,13 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace Musify.Infrastructure.Persistence.Migrations { - /// + public partial class AddTrackProcessingStateSaga : Migration { - /// + protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( @@ -28,7 +27,6 @@ namespace Musify.Infrastructure.Persistence.Migrations }); } - /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260705124807_AddTrackProcessingSagaKeys.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705124807_AddTrackProcessingSagaKeys.Designer.cs similarity index 99% rename from backend/Musify.Infrastructure/Persistence/Migrations/20260705124807_AddTrackProcessingSagaKeys.Designer.cs rename to backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705124807_AddTrackProcessingSagaKeys.Designer.cs index 84db2e7..4441787 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/20260705124807_AddTrackProcessingSagaKeys.Designer.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705124807_AddTrackProcessingSagaKeys.Designer.cs @@ -1,4 +1,4 @@ -// + using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations [Migration("20260705124807_AddTrackProcessingSagaKeys")] partial class AddTrackProcessingSagaKeys { - /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260705124807_AddTrackProcessingSagaKeys.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705124807_AddTrackProcessingSagaKeys.cs similarity index 94% rename from backend/Musify.Infrastructure/Persistence/Migrations/20260705124807_AddTrackProcessingSagaKeys.cs rename to backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705124807_AddTrackProcessingSagaKeys.cs index f1fe6c5..8f72be4 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/20260705124807_AddTrackProcessingSagaKeys.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705124807_AddTrackProcessingSagaKeys.cs @@ -4,10 +4,10 @@ namespace Musify.Infrastructure.Persistence.Migrations { - /// + public partial class AddTrackProcessingSagaKeys : Migration { - /// + protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn( @@ -29,7 +29,6 @@ namespace Musify.Infrastructure.Persistence.Migrations nullable: true); } - /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropColumn( diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260705125356_AddPlayListProcessingStateSaga.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705125356_AddPlayListProcessingStateSaga.Designer.cs similarity index 99% rename from backend/Musify.Infrastructure/Persistence/Migrations/20260705125356_AddPlayListProcessingStateSaga.Designer.cs rename to backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705125356_AddPlayListProcessingStateSaga.Designer.cs index 1a2ac56..45eab6e 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/20260705125356_AddPlayListProcessingStateSaga.Designer.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705125356_AddPlayListProcessingStateSaga.Designer.cs @@ -1,4 +1,4 @@ -// + using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence.Migrations [Migration("20260705125356_AddPlayListProcessingStateSaga")] partial class AddPlayListProcessingStateSaga { - /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260705125356_AddPlayListProcessingStateSaga.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705125356_AddPlayListProcessingStateSaga.cs similarity index 90% rename from backend/Musify.Infrastructure/Persistence/Migrations/20260705125356_AddPlayListProcessingStateSaga.cs rename to backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705125356_AddPlayListProcessingStateSaga.cs index bb964d8..6945174 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/20260705125356_AddPlayListProcessingStateSaga.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260705125356_AddPlayListProcessingStateSaga.cs @@ -1,14 +1,13 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace Musify.Infrastructure.Persistence.Migrations { - /// + public partial class AddPlayListProcessingStateSaga : Migration { - /// + protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( @@ -28,7 +27,6 @@ namespace Musify.Infrastructure.Persistence.Migrations }); } - /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260713234847_AddListeningHistory.Designer.cs similarity index 93% rename from backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs rename to backend/Core/Musify.Infrastructure/Persistence/Migrations/20260713234847_AddListeningHistory.Designer.cs index 2bddd69..5671bab 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260713234847_AddListeningHistory.Designer.cs @@ -1,7 +1,8 @@ -// + 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; @@ -11,9 +12,11 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace Musify.Infrastructure.Persistence.Migrations { [DbContext(typeof(Database))] - partial class DatabaseModelSnapshot : ModelSnapshot + [Migration("20260713234847_AddListeningHistory")] + partial class AddListeningHistory { - protected override void BuildModel(ModelBuilder modelBuilder) + + protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder @@ -190,6 +193,30 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("OutboxState"); }); + 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.PlayList", b => { b.Property("Id") @@ -559,6 +586,25 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasPrincipalKey("MessageId", "ConsumerId"); }); + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", 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.PlayList", b => { b.HasOne("Musify.Domain.Entities.User", "User") diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260713234847_AddListeningHistory.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260713234847_AddListeningHistory.cs new file mode 100644 index 0000000..dcb70f1 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260713234847_AddListeningHistory.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddListeningHistory : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ListeningHistory", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false), + TrackId = table.Column(type: "uuid", nullable: false), + ListenedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ListeningHistory", x => x.Id); + table.ForeignKey( + name: "FK_ListeningHistory_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ListeningHistory_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ListeningHistory_TrackId", + table: "ListeningHistory", + column: "TrackId"); + + migrationBuilder.CreateIndex( + name: "IX_ListeningHistory_UserId", + table: "ListeningHistory", + column: "UserId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ListeningHistory"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260714114537_AddTrackDuration.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260714114537_AddTrackDuration.Designer.cs new file mode 100644 index 0000000..d2ac3b8 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260714114537_AddTrackDuration.Designer.cs @@ -0,0 +1,696 @@ + +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("20260714114537_AddTrackDuration")] + partial class AddTrackDuration + { + + 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.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.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LargePictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SmallPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + 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("AudioFolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTranscodeProcessingStatus") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("LargePictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalAudioName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PicturesProcessingStatus") + .HasColumnType("integer"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SmallPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + }); + + 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.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.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.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.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + 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/20260714114537_AddTrackDuration.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260714114537_AddTrackDuration.cs new file mode 100644 index 0000000..5f973c6 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260714114537_AddTrackDuration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddTrackDuration : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Duration", + table: "Tracks", + type: "integer", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Duration", + table: "Tracks"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260715193951_AddTrackSourceArtistExternalId.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260715193951_AddTrackSourceArtistExternalId.Designer.cs new file mode 100644 index 0000000..97d0c1a --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260715193951_AddTrackSourceArtistExternalId.Designer.cs @@ -0,0 +1,713 @@ + +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("20260715193951_AddTrackSourceArtistExternalId")] + partial class AddTrackSourceArtistExternalId + { + + 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.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.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LargePictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SmallPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + 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("Artist") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AudioFolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTranscodeProcessingStatus") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LargePictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalAudioName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PicturesProcessingStatus") + .HasColumnType("integer"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SmallPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Source", "ExternalId") + .IsUnique() + .HasFilter("\"ExternalId\" <> ''"); + + b.ToTable("Tracks"); + }); + + 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.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.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.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.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + 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/20260715193951_AddTrackSourceArtistExternalId.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260715193951_AddTrackSourceArtistExternalId.cs new file mode 100644 index 0000000..e7a9e0f --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260715193951_AddTrackSourceArtistExternalId.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddTrackSourceArtistExternalId : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Artist", + table: "Tracks", + type: "character varying(200)", + maxLength: 200, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "ExternalId", + table: "Tracks", + type: "character varying(16)", + maxLength: 16, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Source", + table: "Tracks", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_Tracks_Source_ExternalId", + table: "Tracks", + columns: new[] { "Source", "ExternalId" }, + unique: true, + filter: "\"ExternalId\" <> ''"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Tracks_Source_ExternalId", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "Artist", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "ExternalId", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "Source", + table: "Tracks"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260715230836_AddTrackDownloadRequested.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260715230836_AddTrackDownloadRequested.Designer.cs new file mode 100644 index 0000000..24a665d --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260715230836_AddTrackDownloadRequested.Designer.cs @@ -0,0 +1,716 @@ + +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("20260715230836_AddTrackDownloadRequested")] + partial class AddTrackDownloadRequested + { + + 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.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.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LargePictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SmallPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + 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("Artist") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AudioFolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTranscodeProcessingStatus") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadRequested") + .HasColumnType("boolean"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LargePictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalAudioName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PicturesProcessingStatus") + .HasColumnType("integer"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SmallPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Source", "ExternalId") + .IsUnique() + .HasFilter("\"ExternalId\" <> ''"); + + b.ToTable("Tracks"); + }); + + 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.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.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.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.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + 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/20260715230836_AddTrackDownloadRequested.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260715230836_AddTrackDownloadRequested.cs new file mode 100644 index 0000000..2abef22 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260715230836_AddTrackDownloadRequested.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddTrackDownloadRequested : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DownloadRequested", + table: "Tracks", + type: "boolean", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DownloadRequested", + table: "Tracks"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716214442_MakeTrackMediaAndExternalIdNullable.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716214442_MakeTrackMediaAndExternalIdNullable.Designer.cs new file mode 100644 index 0000000..3bcaab0 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716214442_MakeTrackMediaAndExternalIdNullable.Designer.cs @@ -0,0 +1,711 @@ + +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("20260716214442_MakeTrackMediaAndExternalIdNullable")] + partial class MakeTrackMediaAndExternalIdNullable + { + + 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.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.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LargePictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SmallPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + 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("Artist") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AudioFolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTranscodeProcessingStatus") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadRequested") + .HasColumnType("boolean"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("ExternalId") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LargePictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalAudioName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PicturesProcessingStatus") + .HasColumnType("integer"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SmallPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("Tracks"); + }); + + 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.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.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.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.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + 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/20260716214442_MakeTrackMediaAndExternalIdNullable.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716214442_MakeTrackMediaAndExternalIdNullable.cs new file mode 100644 index 0000000..8d45113 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716214442_MakeTrackMediaAndExternalIdNullable.cs @@ -0,0 +1,131 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class MakeTrackMediaAndExternalIdNullable : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Tracks_Source_ExternalId", + table: "Tracks"); + + migrationBuilder.AlterColumn( + name: "OriginalPictureName", + table: "Tracks", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + + migrationBuilder.AlterColumn( + name: "OriginalAudioName", + table: "Tracks", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + + migrationBuilder.AlterColumn( + name: "ExternalId", + table: "Tracks", + type: "character varying(16)", + maxLength: 16, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(16)", + oldMaxLength: 16); + + migrationBuilder.AlterColumn( + name: "Artist", + table: "Tracks", + type: "character varying(200)", + maxLength: 200, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(200)", + oldMaxLength: 200); + + migrationBuilder.Sql("UPDATE \"Tracks\" SET \"ExternalId\" = NULL WHERE \"ExternalId\" = '';"); + migrationBuilder.Sql("UPDATE \"Tracks\" SET \"Artist\" = NULL WHERE \"Artist\" = '';"); + migrationBuilder.Sql("UPDATE \"Tracks\" SET \"OriginalPictureName\" = NULL WHERE \"OriginalPictureName\" = '';"); + migrationBuilder.Sql("UPDATE \"Tracks\" SET \"OriginalAudioName\" = NULL WHERE \"OriginalAudioName\" = '';"); + + migrationBuilder.CreateIndex( + name: "IX_Tracks_Source_ExternalId", + table: "Tracks", + columns: new[] { "Source", "ExternalId" }, + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Tracks_Source_ExternalId", + table: "Tracks"); + + migrationBuilder.AlterColumn( + name: "OriginalPictureName", + table: "Tracks", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "OriginalAudioName", + table: "Tracks", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "ExternalId", + table: "Tracks", + type: "character varying(16)", + maxLength: 16, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(16)", + oldMaxLength: 16, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Artist", + table: "Tracks", + type: "character varying(200)", + maxLength: 200, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(200)", + oldMaxLength: 200, + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Tracks_Source_ExternalId", + table: "Tracks", + columns: new[] { "Source", "ExternalId" }, + unique: true, + filter: "\"ExternalId\" <> ''"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716214752_AddArtistsAndTrackOwnership.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716214752_AddArtistsAndTrackOwnership.Designer.cs new file mode 100644 index 0000000..6a999d6 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716214752_AddArtistsAndTrackOwnership.Designer.cs @@ -0,0 +1,826 @@ + +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("20260716214752_AddArtistsAndTrackOwnership")] + partial class AddArtistsAndTrackOwnership + { + + 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.Artist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .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.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasFilter("\"ExternalId\" IS NULL AND \"UserId\" IS NULL"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Artists"); + }); + + 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.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LargePictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SmallPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + 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("Artist") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AudioFolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTranscodeProcessingStatus") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadRequested") + .HasColumnType("boolean"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("ExternalId") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LargePictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalAudioName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("PicturesProcessingStatus") + .HasColumnType("integer"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SmallPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("Tracks", t => + { + t.HasCheckConstraint("CK_Tracks_OwnerUserId_Source", "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)"); + }); + }); + + 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.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.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.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.Artist", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + 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.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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); + + b.Navigation("Owner"); + }); + + 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.Track", "Track") + .WithMany("TrackArtists") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + + b.Navigation("Track"); + }); + + 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.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.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("TrackArtists"); + + 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/20260716214752_AddArtistsAndTrackOwnership.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716214752_AddArtistsAndTrackOwnership.cs new file mode 100644 index 0000000..70eaaf7 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716214752_AddArtistsAndTrackOwnership.cs @@ -0,0 +1,182 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddArtistsAndTrackOwnership : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OwnerUserId", + table: "Tracks", + type: "bigint", + nullable: true); + + migrationBuilder.CreateTable( + name: "Artists", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + NormalizedName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + UserId = table.Column(type: "bigint", nullable: true), + ExternalId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Artists", x => x.Id); + table.ForeignKey( + name: "FK_Artists_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id"); + }); + + 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_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Tracks_OwnerUserId", + table: "Tracks", + column: "OwnerUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Artists_ExternalId", + table: "Artists", + column: "ExternalId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Artists_NormalizedName", + table: "Artists", + column: "NormalizedName", + unique: true, + filter: "\"ExternalId\" IS NULL AND \"UserId\" IS NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Artists_UserId", + table: "Artists", + column: "UserId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TrackArtist_ArtistId", + table: "TrackArtist", + column: "ArtistId"); + + migrationBuilder.AddForeignKey( + name: "FK_Tracks_User_OwnerUserId", + table: "Tracks", + column: "OwnerUserId", + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.Sql( + """ + UPDATE "Tracks" t SET "OwnerUserId" = ut."UserId" + FROM (SELECT "TrackId", MIN("UserId") AS "UserId" FROM "UserHasTrack" GROUP BY "TrackId") ut + WHERE t."Source" = 0 AND ut."TrackId" = t."Id"; + """); + + migrationBuilder.Sql( + """ + DELETE FROM "Tracks" WHERE "Source" = 0 AND "OwnerUserId" IS NULL; + """); + + migrationBuilder.Sql( + """ + INSERT INTO "Artists" ("Id", "Name", "NormalizedName", "UserId", "ExternalId", "CreatedAt", "UpdatedAt") + SELECT gen_random_uuid(), u."Name", u."NormalizedName", u."Id", NULL, now(), now() + FROM "User" u + WHERE EXISTS (SELECT 1 FROM "Tracks" t WHERE t."OwnerUserId" = u."Id"); + """); + + migrationBuilder.Sql( + """ + INSERT INTO "Artists" ("Id", "Name", "NormalizedName", "UserId", "ExternalId", "CreatedAt", "UpdatedAt") + SELECT gen_random_uuid(), s."Artist", upper(s."Artist"), NULL, NULL, now(), now() + FROM ( + SELECT DISTINCT ON (upper("Artist")) "Artist" + FROM "Tracks" + WHERE "Source" = 1 AND "Artist" IS NOT NULL + ORDER BY upper("Artist"), "Artist" + ) s; + """); + + migrationBuilder.Sql( + """ + INSERT INTO "TrackArtist" ("TrackId", "ArtistId", "Position") + SELECT t."Id", a."Id", 0 FROM "Tracks" t + JOIN "Artists" a ON a."UserId" = t."OwnerUserId" + WHERE t."Source" = 0; + """); + + migrationBuilder.Sql( + """ + INSERT INTO "TrackArtist" ("TrackId", "ArtistId", "Position") + SELECT t."Id", a."Id", 0 FROM "Tracks" t + JOIN "Artists" a ON a."UserId" IS NULL AND a."ExternalId" IS NULL AND a."NormalizedName" = upper(t."Artist") + WHERE t."Source" = 1 AND t."Artist" IS NOT NULL; + """); + + migrationBuilder.AddCheckConstraint( + name: "CK_Tracks_OwnerUserId_Source", + table: "Tracks", + sql: "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Tracks_User_OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropTable( + name: "TrackArtist"); + + migrationBuilder.DropTable( + name: "Artists"); + + migrationBuilder.DropIndex( + name: "IX_Tracks_OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropCheckConstraint( + name: "CK_Tracks_OwnerUserId_Source", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "OwnerUserId", + table: "Tracks"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716220047_DropTrackArtistColumn.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716220047_DropTrackArtistColumn.Designer.cs new file mode 100644 index 0000000..3b27cd9 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716220047_DropTrackArtistColumn.Designer.cs @@ -0,0 +1,822 @@ + +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("20260716220047_DropTrackArtistColumn")] + partial class DropTrackArtistColumn + { + + 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.Artist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .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.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasFilter("\"ExternalId\" IS NULL AND \"UserId\" IS NULL"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Artists"); + }); + + 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.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LargePictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SmallPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + 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("AudioFolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTranscodeProcessingStatus") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadRequested") + .HasColumnType("boolean"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("ExternalId") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LargePictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalAudioName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("PicturesProcessingStatus") + .HasColumnType("integer"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SmallPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("Tracks", t => + { + t.HasCheckConstraint("CK_Tracks_OwnerUserId_Source", "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)"); + }); + }); + + 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.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.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.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.Artist", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + 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.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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); + + b.Navigation("Owner"); + }); + + 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.Track", "Track") + .WithMany("TrackArtists") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + + b.Navigation("Track"); + }); + + 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.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.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("TrackArtists"); + + 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/20260716220047_DropTrackArtistColumn.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716220047_DropTrackArtistColumn.cs new file mode 100644 index 0000000..046837a --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260716220047_DropTrackArtistColumn.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class DropTrackArtistColumn : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Artist", + table: "Tracks"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Artist", + table: "Tracks", + type: "character varying(200)", + maxLength: 200, + nullable: true); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717164632_MovePlayListPicturesToOwnedType.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717164632_MovePlayListPicturesToOwnedType.Designer.cs new file mode 100644 index 0000000..2174b94 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717164632_MovePlayListPicturesToOwnedType.Designer.cs @@ -0,0 +1,885 @@ + +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("20260717164632_MovePlayListPicturesToOwnedType")] + partial class MovePlayListPicturesToOwnedType + { + + 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.Artist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .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.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasFilter("\"ExternalId\" IS NULL AND \"UserId\" IS NULL"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Artists"); + }); + + 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.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .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.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("Duration") + .HasColumnType("integer"); + + b.Property("ExternalId") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("Tracks", t => + { + t.HasCheckConstraint("CK_Tracks_OwnerUserId_Source", "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)"); + }); + }); + + 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.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.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.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.Artist", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + 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.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") + .IsRequired(); + + 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); + + 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.TrackArtist", b => + { + b.HasOne("Musify.Domain.Entities.Artist", "Artist") + .WithMany("TrackArtists") + .HasForeignKey("ArtistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("TrackArtists") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + + b.Navigation("Track"); + }); + + 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.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.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("TrackArtists"); + + 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/20260717164632_MovePlayListPicturesToOwnedType.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717164632_MovePlayListPicturesToOwnedType.cs new file mode 100644 index 0000000..d368789 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717164632_MovePlayListPicturesToOwnedType.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class MovePlayListPicturesToOwnedType : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717164812_RenameTrackDurationToDurationSeconds.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717164812_RenameTrackDurationToDurationSeconds.Designer.cs new file mode 100644 index 0000000..f14319d --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717164812_RenameTrackDurationToDurationSeconds.Designer.cs @@ -0,0 +1,885 @@ + +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("20260717164812_RenameTrackDurationToDurationSeconds")] + partial class RenameTrackDurationToDurationSeconds + { + + 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.Artist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .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.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasFilter("\"ExternalId\" IS NULL AND \"UserId\" IS NULL"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Artists"); + }); + + 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.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .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.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("integer"); + + b.Property("ExternalId") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("Tracks", t => + { + t.HasCheckConstraint("CK_Tracks_OwnerUserId_Source", "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)"); + }); + }); + + 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.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.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.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.Artist", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + 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.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") + .IsRequired(); + + 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); + + 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.TrackArtist", b => + { + b.HasOne("Musify.Domain.Entities.Artist", "Artist") + .WithMany("TrackArtists") + .HasForeignKey("ArtistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("TrackArtists") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + + b.Navigation("Track"); + }); + + 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.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.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("TrackArtists"); + + 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/20260717164812_RenameTrackDurationToDurationSeconds.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717164812_RenameTrackDurationToDurationSeconds.cs new file mode 100644 index 0000000..e62cbc7 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717164812_RenameTrackDurationToDurationSeconds.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class RenameTrackDurationToDurationSeconds : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Duration", + table: "Tracks", + newName: "DurationSeconds"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "DurationSeconds", + table: "Tracks", + newName: "Duration"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717172507_SplitTrackIntoLocalAndExternal.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717172507_SplitTrackIntoLocalAndExternal.Designer.cs new file mode 100644 index 0000000..5598bd6 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717172507_SplitTrackIntoLocalAndExternal.Designer.cs @@ -0,0 +1,903 @@ + +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("20260717172507_SplitTrackIntoLocalAndExternal")] + partial class SplitTrackIntoLocalAndExternal + { + + 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.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") + .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.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .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.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("integer"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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.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.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.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("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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + 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.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") + .IsRequired(); + + 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.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("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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + 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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717172507_SplitTrackIntoLocalAndExternal.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717172507_SplitTrackIntoLocalAndExternal.cs new file mode 100644 index 0000000..2aa2dce --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717172507_SplitTrackIntoLocalAndExternal.cs @@ -0,0 +1,272 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class SplitTrackIntoLocalAndExternal : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_TrackArtist_Tracks_TrackId", + table: "TrackArtist"); + + migrationBuilder.DropForeignKey( + name: "FK_Tracks_User_OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropIndex( + name: "IX_Tracks_OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropIndex( + name: "IX_Tracks_Source_ExternalId", + table: "Tracks"); + + migrationBuilder.DropCheckConstraint( + name: "CK_Tracks_OwnerUserId_Source", + table: "Tracks"); + + migrationBuilder.CreateTable( + name: "ExternalTracks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Source = table.Column(type: "integer", nullable: false), + ExternalId = table.Column(type: "character varying(64)", maxLength: 64, 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.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.Sql( + """ + INSERT INTO "LocalTracks" ("Id", "OwnerUserId") + SELECT "Id", "OwnerUserId" FROM "Tracks" WHERE "Source" = 0; + """); + + migrationBuilder.Sql( + """ + INSERT INTO "ExternalTracks" ("Id", "Source", "ExternalId") + SELECT "Id", "Source", "ExternalId" FROM "Tracks" WHERE "Source" <> 0; + """); + + migrationBuilder.Sql( + """ + DELETE FROM "TrackArtist" tra + USING "Tracks" t + WHERE tra."TrackId" = t."Id" AND t."Source" = 0; + """); + + migrationBuilder.Sql( + """ + DELETE FROM "Artists" WHERE "UserId" IS NOT NULL; + """); + + migrationBuilder.AddForeignKey( + name: "FK_TrackArtist_ExternalTracks_TrackId", + table: "TrackArtist", + column: "TrackId", + principalTable: "ExternalTracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.DropColumn( + name: "ExternalId", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "Source", + table: "Tracks"); + + migrationBuilder.DropIndex( + name: "IX_Artists_NormalizedName", + table: "Artists"); + + migrationBuilder.DropIndex( + name: "IX_Artists_UserId", + table: "Artists"); + + migrationBuilder.DropForeignKey( + name: "FK_Artists_User_UserId", + table: "Artists"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Artists"); + + migrationBuilder.AlterColumn( + name: "ExternalId", + table: "Artists", + type: "character varying(64)", + maxLength: 64, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_TrackArtist_ExternalTracks_TrackId", + table: "TrackArtist"); + + migrationBuilder.AddColumn( + name: "ExternalId", + table: "Tracks", + type: "character varying(16)", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "OwnerUserId", + table: "Tracks", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "Source", + table: "Tracks", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.Sql( + """ + UPDATE "Tracks" t SET "OwnerUserId" = lt."OwnerUserId", "Source" = 0 + FROM "LocalTracks" lt WHERE lt."Id" = t."Id"; + """); + + migrationBuilder.Sql( + """ + UPDATE "Tracks" t SET "ExternalId" = et."ExternalId", "Source" = et."Source" + FROM "ExternalTracks" et WHERE et."Id" = t."Id"; + """); + + migrationBuilder.DropTable( + name: "ExternalTracks"); + + migrationBuilder.DropTable( + name: "LocalTracks"); + + migrationBuilder.AlterColumn( + name: "ExternalId", + table: "Artists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + + migrationBuilder.AddColumn( + name: "UserId", + table: "Artists", + type: "bigint", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Tracks_OwnerUserId", + table: "Tracks", + column: "OwnerUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Tracks_Source_ExternalId", + table: "Tracks", + columns: new[] { "Source", "ExternalId" }, + unique: true); + + migrationBuilder.AddCheckConstraint( + name: "CK_Tracks_OwnerUserId_Source", + table: "Tracks", + sql: "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)"); + + migrationBuilder.CreateIndex( + name: "IX_Artists_NormalizedName", + table: "Artists", + column: "NormalizedName", + unique: true, + filter: "\"ExternalId\" IS NULL AND \"UserId\" IS NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Artists_UserId", + table: "Artists", + column: "UserId", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Artists_User_UserId", + table: "Artists", + column: "UserId", + principalTable: "User", + principalColumn: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_TrackArtist_Tracks_TrackId", + table: "TrackArtist", + column: "TrackId", + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Tracks_User_OwnerUserId", + table: "Tracks", + column: "OwnerUserId", + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717223358_MakePlayListPicturesNullable.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717223358_MakePlayListPicturesNullable.Designer.cs new file mode 100644 index 0000000..903f4d1 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717223358_MakePlayListPicturesNullable.Designer.cs @@ -0,0 +1,898 @@ + +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("20260717223358_MakePlayListPicturesNullable")] + partial class MakePlayListPicturesNullable + { + + 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.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") + .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.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .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.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("integer"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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.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.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.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("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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + 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.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") + .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("SmallName") + .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.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("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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + 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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717223358_MakePlayListPicturesNullable.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717223358_MakePlayListPicturesNullable.cs new file mode 100644 index 0000000..62e12b9 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260717223358_MakePlayListPicturesNullable.cs @@ -0,0 +1,105 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class MakePlayListPicturesNullable : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SmallPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + + migrationBuilder.AlterColumn( + name: "OriginalPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + + migrationBuilder.AlterColumn( + name: "MediumPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + + migrationBuilder.AlterColumn( + name: "LargePictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SmallPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "OriginalPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "MediumPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "LargePictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720180629_WidenTrackTitle.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720180629_WidenTrackTitle.Designer.cs new file mode 100644 index 0000000..a0bf78b --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720180629_WidenTrackTitle.Designer.cs @@ -0,0 +1,898 @@ + +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("20260720180629_WidenTrackTitle")] + partial class WidenTrackTitle + { + + 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.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") + .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.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .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.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("integer"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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.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.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.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("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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + 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.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") + .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("SmallName") + .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.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("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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + 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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720180629_WidenTrackTitle.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720180629_WidenTrackTitle.cs new file mode 100644 index 0000000..2c5e0c7 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720180629_WidenTrackTitle.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class WidenTrackTitle : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Title", + table: "Tracks", + type: "character varying(200)", + maxLength: 200, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(50)", + oldMaxLength: 50); + + migrationBuilder.AlterColumn( + name: "NormalizedTitle", + table: "Tracks", + type: "character varying(200)", + maxLength: 200, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(50)", + oldMaxLength: 50); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Title", + table: "Tracks", + type: "character varying(50)", + maxLength: 50, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(200)", + oldMaxLength: 200); + + migrationBuilder.AlterColumn( + name: "NormalizedTitle", + table: "Tracks", + type: "character varying(50)", + maxLength: 50, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(200)", + oldMaxLength: 200); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720180747_AddAlbums.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720180747_AddAlbums.Designer.cs new file mode 100644 index 0000000..3575cbb --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720180747_AddAlbums.Designer.cs @@ -0,0 +1,1058 @@ + +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("20260720180747_AddAlbums")] + partial class AddAlbums + { + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.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") + .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.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .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.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("integer"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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.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.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.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("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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .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("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + 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.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") + .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("SmallName") + .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.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("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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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.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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720180747_AddAlbums.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720180747_AddAlbums.cs new file mode 100644 index 0000000..664a1f2 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720180747_AddAlbums.cs @@ -0,0 +1,115 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddAlbums : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Albums", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + NormalizedTitle = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ReleaseYear = table.Column(type: "integer", nullable: true), + OriginalPictureName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + SmallPictureName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + MediumPictureName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + LargePictureName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + LifeCycleStatus = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Albums", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AlbumHasTrack", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + AlbumId = table.Column(type: "uuid", nullable: false), + TrackId = table.Column(type: "uuid", nullable: false), + TrackNumber = table.Column(type: "integer", nullable: false), + AddedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AlbumHasTrack", x => x.Id); + table.ForeignKey( + name: "FK_AlbumHasTrack_Albums_AlbumId", + column: x => x.AlbumId, + principalTable: "Albums", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AlbumHasTrack_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + 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.CreateIndex( + name: "IX_AlbumHasTrack_AlbumId_TrackId", + table: "AlbumHasTrack", + columns: new[] { "AlbumId", "TrackId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AlbumHasTrack_TrackId", + table: "AlbumHasTrack", + column: "TrackId"); + + migrationBuilder.CreateIndex( + name: "IX_UserAlbums_OwnerUserId", + table: "UserAlbums", + column: "OwnerUserId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AlbumHasTrack"); + + migrationBuilder.DropTable( + name: "UserAlbums"); + + migrationBuilder.DropTable( + name: "Albums"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720184656_AddUserAlbumExternalId.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720184656_AddUserAlbumExternalId.Designer.cs new file mode 100644 index 0000000..352ae90 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720184656_AddUserAlbumExternalId.Designer.cs @@ -0,0 +1,1063 @@ + +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("20260720184656_AddUserAlbumExternalId")] + partial class AddUserAlbumExternalId + { + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.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") + .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.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .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.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("integer"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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.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.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.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("Musify.Domain.Entities.UserAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("ExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId", "ExternalId") + .IsUnique(); + + 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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .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("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + 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.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") + .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("SmallName") + .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.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("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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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.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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720184656_AddUserAlbumExternalId.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720184656_AddUserAlbumExternalId.cs new file mode 100644 index 0000000..32c0e4f --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260720184656_AddUserAlbumExternalId.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddUserAlbumExternalId : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UserAlbums_OwnerUserId", + table: "UserAlbums"); + + migrationBuilder.AddColumn( + name: "ExternalId", + table: "UserAlbums", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_UserAlbums_OwnerUserId_ExternalId", + table: "UserAlbums", + columns: new[] { "OwnerUserId", "ExternalId" }, + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UserAlbums_OwnerUserId_ExternalId", + table: "UserAlbums"); + + migrationBuilder.DropColumn( + name: "ExternalId", + table: "UserAlbums"); + + migrationBuilder.CreateIndex( + name: "IX_UserAlbums_OwnerUserId", + table: "UserAlbums", + column: "OwnerUserId"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721155102_AddMixes.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721155102_AddMixes.Designer.cs new file mode 100644 index 0000000..04aa6e2 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721155102_AddMixes.Designer.cs @@ -0,0 +1,1183 @@ + +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("20260721155102_AddMixes")] + partial class AddMixes + { + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.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") + .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("GeneratedOn") + .HasColumnType("date"); + + 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("Artist") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + 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") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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") + .IsRequired() + .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.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("integer"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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.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.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.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("Musify.Domain.Entities.UserAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("ExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId", "ExternalId") + .IsUnique(); + + 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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .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("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + 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); + + 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") + .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("SmallName") + .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.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("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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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"); + }); + + 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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721155102_AddMixes.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721155102_AddMixes.cs new file mode 100644 index 0000000..f243468 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721155102_AddMixes.cs @@ -0,0 +1,95 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddMixes : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Mixes", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false), + Title = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Subtitle = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + GeneratedOn = table.Column(type: "date", nullable: false), + Position = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Mixes", x => x.Id); + table.ForeignKey( + name: "FK_Mixes_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "MixItems", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MixId = table.Column(type: "uuid", nullable: false), + Position = table.Column(type: "integer", nullable: false), + Source = table.Column(type: "integer", nullable: false), + TrackId = table.Column(type: "uuid", nullable: true), + VideoId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + Title = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + Artist = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + ThumbnailUrl = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + DurationSeconds = table.Column(type: "integer", nullable: false), + IsExplicit = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MixItems", x => x.Id); + table.ForeignKey( + name: "FK_MixItems_Mixes_MixId", + column: x => x.MixId, + principalTable: "Mixes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_MixItems_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Mixes_UserId_Position", + table: "Mixes", + columns: new[] { "UserId", "Position" }); + + migrationBuilder.CreateIndex( + name: "IX_MixItems_MixId_Position", + table: "MixItems", + columns: new[] { "MixId", "Position" }); + + migrationBuilder.CreateIndex( + name: "IX_MixItems_TrackId", + table: "MixItems", + column: "TrackId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MixItems"); + + migrationBuilder.DropTable( + name: "Mixes"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721161653_RemoveUserAlbumExternalId.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721161653_RemoveUserAlbumExternalId.Designer.cs new file mode 100644 index 0000000..184595e --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721161653_RemoveUserAlbumExternalId.Designer.cs @@ -0,0 +1,1178 @@ + +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("20260721161653_RemoveUserAlbumExternalId")] + partial class RemoveUserAlbumExternalId + { + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.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") + .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("GeneratedOn") + .HasColumnType("date"); + + 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("Artist") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + 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") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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") + .IsRequired() + .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.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("integer"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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.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.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.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("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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .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("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + 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); + + 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") + .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("SmallName") + .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.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("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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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"); + }); + + 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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721161653_RemoveUserAlbumExternalId.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721161653_RemoveUserAlbumExternalId.cs new file mode 100644 index 0000000..8ab867f --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721161653_RemoveUserAlbumExternalId.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class RemoveUserAlbumExternalId : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UserAlbums_OwnerUserId_ExternalId", + table: "UserAlbums"); + + migrationBuilder.DropColumn( + name: "ExternalId", + table: "UserAlbums"); + + migrationBuilder.CreateIndex( + name: "IX_UserAlbums_OwnerUserId", + table: "UserAlbums", + column: "OwnerUserId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UserAlbums_OwnerUserId", + table: "UserAlbums"); + + migrationBuilder.AddColumn( + name: "ExternalId", + table: "UserAlbums", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_UserAlbums_OwnerUserId_ExternalId", + table: "UserAlbums", + columns: new[] { "OwnerUserId", "ExternalId" }, + unique: true); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721162102_DropMixGeneratedOn.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721162102_DropMixGeneratedOn.Designer.cs new file mode 100644 index 0000000..c6f8c69 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721162102_DropMixGeneratedOn.Designer.cs @@ -0,0 +1,1175 @@ + +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("20260721162102_DropMixGeneratedOn")] + partial class DropMixGeneratedOn + { + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.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") + .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("Artist") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + 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") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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") + .IsRequired() + .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.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("integer"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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.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.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.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("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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .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("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + 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); + + 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") + .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("SmallName") + .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.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("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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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"); + }); + + 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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721162102_DropMixGeneratedOn.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721162102_DropMixGeneratedOn.cs new file mode 100644 index 0000000..ef315d1 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721162102_DropMixGeneratedOn.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class DropMixGeneratedOn : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GeneratedOn", + table: "Mixes"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GeneratedOn", + table: "Mixes", + type: "date", + nullable: false, + defaultValue: new DateOnly(1, 1, 1)); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721190735_MakePlayListDescriptionOptional.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721190735_MakePlayListDescriptionOptional.Designer.cs new file mode 100644 index 0000000..839cd88 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721190735_MakePlayListDescriptionOptional.Designer.cs @@ -0,0 +1,1174 @@ + +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("20260721190735_MakePlayListDescriptionOptional")] + partial class MakePlayListDescriptionOptional + { + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.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") + .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("Artist") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + 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") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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.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("integer"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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.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.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.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("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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .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("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + 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); + + 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") + .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("SmallName") + .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.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("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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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"); + }); + + 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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721190735_MakePlayListDescriptionOptional.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721190735_MakePlayListDescriptionOptional.cs new file mode 100644 index 0000000..f494ead --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260721190735_MakePlayListDescriptionOptional.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class MakePlayListDescriptionOptional : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Description", + table: "PlayLists", + type: "character varying(256)", + maxLength: 256, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(256)", + oldMaxLength: 256); + + migrationBuilder.Sql( + """ + UPDATE "PlayLists" SET "Description" = NULL + WHERE "Description" = 'No description was provided.'; + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Description", + table: "PlayLists", + type: "character varying(256)", + maxLength: 256, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(256)", + oldMaxLength: 256, + oldNullable: true); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724170051_DurationAsDouble.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724170051_DurationAsDouble.Designer.cs new file mode 100644 index 0000000..8f61078 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724170051_DurationAsDouble.Designer.cs @@ -0,0 +1,1178 @@ + +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("20260724170051_DurationAsDouble")] + partial class DurationAsDouble + { + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.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") + .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("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") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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.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("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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.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.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.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("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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .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("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + 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); + + 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.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("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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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"); + }); + + 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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724170051_DurationAsDouble.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724170051_DurationAsDouble.cs new file mode 100644 index 0000000..2429024 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724170051_DurationAsDouble.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class DurationAsDouble : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724181012_AddExternalAlbums.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724181012_AddExternalAlbums.Designer.cs new file mode 100644 index 0000000..67a342f --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724181012_AddExternalAlbums.Designer.cs @@ -0,0 +1,1205 @@ + +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("20260724181012_AddExternalAlbums")] + partial class AddExternalAlbums + { + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.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") + .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("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") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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.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("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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.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.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.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("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.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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .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("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + 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); + + 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.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("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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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"); + }); + + 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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724181012_AddExternalAlbums.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724181012_AddExternalAlbums.cs new file mode 100644 index 0000000..cfe5fef --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724181012_AddExternalAlbums.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddExternalAlbums : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ExternalAlbums", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Source = table.Column(type: "integer", nullable: false), + ExternalId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExternalAlbums", x => x.Id); + table.ForeignKey( + name: "FK_ExternalAlbums_Albums_Id", + column: x => x.Id, + principalTable: "Albums", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ExternalAlbums_Source_ExternalId", + table: "ExternalAlbums", + columns: new[] { "Source", "ExternalId" }, + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ExternalAlbums"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724182659_AddExternalAlbumThumbnail.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724182659_AddExternalAlbumThumbnail.Designer.cs new file mode 100644 index 0000000..021c586 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724182659_AddExternalAlbumThumbnail.Designer.cs @@ -0,0 +1,1209 @@ + +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("20260724182659_AddExternalAlbumThumbnail")] + partial class AddExternalAlbumThumbnail + { + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.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") + .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("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") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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.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("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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.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.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.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("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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .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("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + 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); + + 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.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("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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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"); + }); + + 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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724182659_AddExternalAlbumThumbnail.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724182659_AddExternalAlbumThumbnail.cs new file mode 100644 index 0000000..8499835 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260724182659_AddExternalAlbumThumbnail.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddExternalAlbumThumbnail : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ThumbnailUrl", + table: "ExternalAlbums", + type: "character varying(512)", + maxLength: 512, + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ThumbnailUrl", + table: "ExternalAlbums"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260910125022_AddAlbumProcessingStateSaga.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260910125022_AddAlbumProcessingStateSaga.Designer.cs new file mode 100644 index 0000000..61d1c9d --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260910125022_AddAlbumProcessingStateSaga.Designer.cs @@ -0,0 +1,1240 @@ + +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("20260910125022_AddAlbumProcessingStateSaga")] + partial class AddAlbumProcessingStateSaga + { + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.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") + .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("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") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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.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("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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.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.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("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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + 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("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); + + 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.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("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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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"); + }); + + 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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260910125022_AddAlbumProcessingStateSaga.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260910125022_AddAlbumProcessingStateSaga.cs new file mode 100644 index 0000000..065bd46 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260910125022_AddAlbumProcessingStateSaga.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddAlbumProcessingStateSaga : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AlbumProcessingState", + columns: table => new + { + CorrelationId = table.Column(type: "uuid", nullable: false), + CurrentState = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Bucket = table.Column(type: "text", nullable: true), + PictureKey = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AlbumProcessingState", x => x.CorrelationId); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AlbumProcessingState"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914144913_AddPlayListVisibility.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914144913_AddPlayListVisibility.Designer.cs new file mode 100644 index 0000000..6e5fe77 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914144913_AddPlayListVisibility.Designer.cs @@ -0,0 +1,1245 @@ + +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("20260914144913_AddPlayListVisibility")] + partial class AddPlayListVisibility + { + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.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") + .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("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") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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.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.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("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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + 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("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); + + 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.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("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.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.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"); + }); + + 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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914144913_AddPlayListVisibility.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914144913_AddPlayListVisibility.cs new file mode 100644 index 0000000..3ea6295 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914144913_AddPlayListVisibility.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddPlayListVisibility : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Visibility", + table: "PlayLists", + type: "integer", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Visibility", + table: "PlayLists"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914150045_AddTrackLikes.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914150045_AddTrackLikes.Designer.cs new file mode 100644 index 0000000..7420ec1 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914150045_AddTrackLikes.Designer.cs @@ -0,0 +1,1289 @@ + +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("20260914150045_AddTrackLikes")] + partial class AddTrackLikes + { + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.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") + .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("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") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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 => + { + 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.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("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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + 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("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); + + 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.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("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") + .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.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.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"); + }); + + 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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914150045_AddTrackLikes.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914150045_AddTrackLikes.cs new file mode 100644 index 0000000..64ae90d --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914150045_AddTrackLikes.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddTrackLikes : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TrackLikes", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false), + TrackId = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrackLikes", x => x.Id); + table.ForeignKey( + name: "FK_TrackLikes_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TrackLikes_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TrackLikes_TrackId", + table: "TrackLikes", + column: "TrackId"); + + migrationBuilder.CreateIndex( + name: "IX_TrackLikes_UserId_TrackId", + table: "TrackLikes", + columns: new[] { "UserId", "TrackId" }, + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TrackLikes"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914151900_AddUserFollows.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914151900_AddUserFollows.Designer.cs new file mode 100644 index 0000000..62cb1f0 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914151900_AddUserFollows.Designer.cs @@ -0,0 +1,1333 @@ + +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("20260914151900_AddUserFollows")] + partial class AddUserFollows + { + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.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") + .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("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") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + 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 => + { + 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("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) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + 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("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); + + 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.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("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") + .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.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"); + }); + + 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"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914151900_AddUserFollows.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914151900_AddUserFollows.cs new file mode 100644 index 0000000..5d9a85a --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914151900_AddUserFollows.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddUserFollows : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserFollows", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FollowerId = table.Column(type: "bigint", nullable: false), + FollowedId = table.Column(type: "bigint", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserFollows", x => x.Id); + table.ForeignKey( + name: "FK_UserFollows_User_FollowedId", + column: x => x.FollowedId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_UserFollows_User_FollowerId", + column: x => x.FollowerId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserFollows_FollowedId", + table: "UserFollows", + column: "FollowedId"); + + migrationBuilder.CreateIndex( + name: "IX_UserFollows_FollowerId_FollowedId", + table: "UserFollows", + columns: new[] { "FollowerId", "FollowedId" }, + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserFollows"); + } + } +} 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..bca8584 --- /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..a496709 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.cs @@ -0,0 +1,371 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class RemoveYouTubeIntegration : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + + migrationBuilder.Sql("""DELETE FROM "Tracks" WHERE "Id" IN (SELECT "Id" FROM "ExternalTracks");"""); + migrationBuilder.Sql("""DELETE FROM "Albums" WHERE "Id" IN (SELECT "Id" FROM "ExternalAlbums");"""); + + 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/20260916153700_AddTrackTags.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260916153700_AddTrackTags.Designer.cs new file mode 100644 index 0000000..b6ee299 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260916153700_AddTrackTags.Designer.cs @@ -0,0 +1,1168 @@ + +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260916153700_AddTrackTags")] + partial class AddTrackTags + { + + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + 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.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + 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.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + 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("Tags"); + + 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/20260916153700_AddTrackTags.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260916153700_AddTrackTags.cs new file mode 100644 index 0000000..104b260 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260916153700_AddTrackTags.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Musify.Domain.ValueObjects; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + + public partial class AddTrackTags : Migration + { + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance"); + + migrationBuilder.CreateTable( + name: "TrackTags", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TrackId = table.Column(type: "uuid", nullable: false), + Tag = table.Column(type: "genre", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrackTags", x => x.Id); + table.ForeignKey( + name: "FK_TrackTags_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TrackTags_TrackId_Tag", + table: "TrackTags", + columns: new[] { "TrackId", "Tag" }, + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TrackTags"); + + migrationBuilder.AlterDatabase() + .OldAnnotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs new file mode 100644 index 0000000..91f3231 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -0,0 +1,1165 @@ + +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + partial class DatabaseModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + + 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", (string)null); + + 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", (string)null); + + 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", (string)null); + + 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.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + 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("Tags"); + + 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/Musify.Infrastructure/Services/AudioTranscoderService.cs b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs similarity index 77% rename from backend/Musify.Infrastructure/Services/AudioTranscoderService.cs rename to backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs index 93cad6f..dfe75e0 100644 --- a/backend/Musify.Infrastructure/Services/AudioTranscoderService.cs +++ b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs @@ -2,16 +2,21 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; using System.Diagnostics; +using System.Globalization; +using System.Text.RegularExpressions; namespace Musify.Infrastructure.Services { - public class AudioTranscoderService(ILogger logger, - AudioTranscoderConfiguration audioTranscoderConfiguration) + public partial class AudioService(ILogger logger, + AudioConfiguration audioConfiguration) : IAudioTranscoderService { record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError); - public async Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken) + [GeneratedRegex(@"Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)", RegexOptions.IgnoreCase)] + private static partial Regex DurationRegex(); + + public async Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken) { try { @@ -20,7 +25,7 @@ namespace Musify.Infrastructure.Services logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath); var executionResult = await ExecuteFfmpegWithInputAsync( - BuildAudioFileArguments(destinationPath, audioTranscoderConfiguration.Ffmpeg), + BuildAudioFileArguments(destinationPath, audioConfiguration.Ffmpeg), destinationPath, audioStream, "TranscodeToAudioFile", @@ -30,17 +35,18 @@ namespace Musify.Infrastructure.Services { logger.LogError("Audio transcoding failed with exit code {ExitCode}. Output: {StandardOutput}, Error: {StandardError}", executionResult.ExitCode, executionResult.StandardOutput, executionResult.StandardError); - } - else - { - logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath); + return new AudioTranscodeResult(executionResult.ExitCode, TimeSpan.Zero); } - return executionResult.ExitCode; + logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath); + + var outputPath = Path.Combine(destinationPath, audioConfiguration.Ffmpeg.OutputFileName); + var duration = await GetAudioDurationAsync(outputPath, cancellationToken); + return new AudioTranscodeResult(executionResult.ExitCode, duration); } catch (TimeoutException timeoutException) { - logger.LogError(timeoutException, "Audio transcoding timed out after {Timeout} for {DestinationPath}", audioTranscoderConfiguration.TranscodingTimeout, destinationPath); + logger.LogError(timeoutException, "Audio transcoding timed out after {Timeout} for {DestinationPath}", audioConfiguration.TranscodingTimeout, destinationPath); throw; } catch (Exception exception) @@ -50,6 +56,25 @@ namespace Musify.Infrastructure.Services } } + async Task GetAudioDurationAsync(string filePath, CancellationToken cancellationToken) + { + var executionResult = await ExecuteFfmpegAsync( + BuildProbeDurationArguments(filePath), + Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, + "ProbeAudioDuration", + cancellationToken); + + var match = DurationRegex().Match(executionResult.StandardError); + if (!match.Success) + throw new InvalidOperationException($"Could not determine audio duration for {filePath}."); + + var hours = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var minutes = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture); + var seconds = double.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture); + + return new TimeSpan(hours, minutes, 0) + TimeSpan.FromSeconds(seconds); + } + public async Task IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken) { if (!File.Exists(filePath)) @@ -137,7 +162,7 @@ namespace Musify.Infrastructure.Services { StartInfo = new ProcessStartInfo { - FileName = audioTranscoderConfiguration.Ffmpeg.ExecutableName, + FileName = audioConfiguration.Ffmpeg.ExecutableName, WorkingDirectory = workingDirectory, Arguments = arguments, RedirectStandardInput = redirectStandardInput, @@ -156,7 +181,7 @@ namespace Musify.Infrastructure.Services CancellationToken cancellationToken) { using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - linkedCancellationTokens.CancelAfter(audioTranscoderConfiguration.TranscodingTimeout); + linkedCancellationTokens.CancelAfter(audioConfiguration.TranscodingTimeout); try { @@ -165,10 +190,10 @@ namespace Musify.Infrastructure.Services catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { logger.LogError("[{Operation}] ffmpeg timed out after {Timeout} (PID: {ProcessId} )", - operationName, audioTranscoderConfiguration.TranscodingTimeout, process.Id); + operationName, audioConfiguration.TranscodingTimeout, process.Id); TryKillProcess(process); - throw new TimeoutException($"ffmpeg execution exceeded timeout {audioTranscoderConfiguration.TranscodingTimeout}."); + throw new TimeoutException($"ffmpeg execution exceeded timeout {audioConfiguration.TranscodingTimeout}."); } var standardError = await errorTask; @@ -216,5 +241,8 @@ namespace Musify.Infrastructure.Services static string BuildValidateAudioArguments(string filePath) => $"-v error -i \"{filePath}\" -map 0:a:0 -f null -"; + + static string BuildProbeDurationArguments(string filePath) + => $"-hide_banner -i \"{filePath}\" -f null -"; } } \ No newline at end of file diff --git a/backend/Musify.Infrastructure/Services/PictureService.cs b/backend/Core/Musify.Infrastructure/Services/PictureService.cs similarity index 100% rename from backend/Musify.Infrastructure/Services/PictureService.cs rename to backend/Core/Musify.Infrastructure/Services/PictureService.cs diff --git a/backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs similarity index 79% rename from backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs rename to backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index ae79471..109d631 100644 --- a/backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -1,6 +1,9 @@ using Amazon.S3; +using Hangfire; +using Hangfire.PostgreSql; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Musify.Application.Configuration; using Musify.Infrastructure.Configuration; @@ -60,6 +63,21 @@ namespace Musify.Infrastructure.Services return serviceDescriptors; } + public static IServiceCollection AddDailyMixGenerationJob(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors.AddHangfire((serviceProvider, hangfireConfiguration) => + { + var databaseConfiguration = serviceProvider.GetRequiredService(); + hangfireConfiguration.UsePostgreSqlStorage(options => + options.UseNpgsqlConnection(databaseConfiguration.ConnectionString)); + }); + + serviceDescriptors.AddHangfireServer(); + serviceDescriptors.AddScoped(); + + return serviceDescriptors; + } + public static IServiceCollection AddStreamTicketService(this IServiceCollection serviceDescriptors, IConfiguration configuration) { serviceDescriptors @@ -77,14 +95,14 @@ namespace Musify.Infrastructure.Services public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration) { serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(AudioTranscoderConfiguration.SectionName)) + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(AudioConfiguration.SectionName)) .ValidateDataAnnotations(); serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); + serviceProvider.GetRequiredService>().Value); - serviceDescriptors.AddScoped(); + serviceDescriptors.AddScoped(); return serviceDescriptors; } } diff --git a/backend/Core/Musify.Infrastructure/Services/SingleFlightCache.cs b/backend/Core/Musify.Infrastructure/Services/SingleFlightCache.cs new file mode 100644 index 0000000..97693d1 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Services/SingleFlightCache.cs @@ -0,0 +1,41 @@ +using System.Collections.Concurrent; +using ErrorOr; +using Microsoft.Extensions.Caching.Memory; + +namespace Musify.Infrastructure.Services +{ + public sealed class SingleFlightCache(IMemoryCache cache) + { + private readonly ConcurrentDictionary inFlight = new(); + + public async Task> GetOrCreateAsync( + string key, + TimeSpan duration, + Func>> factory) + { + if (duration <= TimeSpan.Zero) + return await factory(); + + if (cache.TryGetValue(key, out TValue? cached) && cached != null) + return cached; + + var pending = (Lazy>>)inFlight.GetOrAdd( + key, + _ => new Lazy>>(factory)); + + try + { + var result = await pending.Value; + + if (!result.IsError) + cache.Set(key, result.Value, duration); + + return result; + } + finally + { + inFlight.TryRemove(key, out _); + } + } + } +} diff --git a/backend/Musify.Infrastructure/Services/StorageService.cs b/backend/Core/Musify.Infrastructure/Services/StorageService.cs similarity index 97% rename from backend/Musify.Infrastructure/Services/StorageService.cs rename to backend/Core/Musify.Infrastructure/Services/StorageService.cs index 9fb1076..04a7034 100644 --- a/backend/Musify.Infrastructure/Services/StorageService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StorageService.cs @@ -276,7 +276,7 @@ namespace Musify.Infrastructure.Services { listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken); - if (listResponse.S3Objects is null or { Count: 0 } ) + if (listResponse.S3Objects == null|| listResponse.S3Objects.Count == 0) break; var deleteRequest = new DeleteObjectsRequest @@ -289,9 +289,10 @@ namespace Musify.Infrastructure.Services var deleteResponse = await amazonS3.DeleteObjectsAsync(deleteRequest, cancellationToken); - if (deleteResponse.DeleteErrors.Count > 0) + var deleteErrors = deleteResponse.DeleteErrors ?? []; + if (deleteErrors.Count > 0) { - var errors = string.Join(", ", deleteResponse.DeleteErrors.Select(e => $"{e.Key}: {e.Message}")); + var errors = string.Join(", ", deleteErrors.Select(e => $"{e.Key}: {e.Message}")); throw new InvalidOperationException($"Failed to delete some objects in folder {folderKey}: {errors}"); } diff --git a/backend/Musify.Infrastructure/Services/StreamTicketService.cs b/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs similarity index 80% rename from backend/Musify.Infrastructure/Services/StreamTicketService.cs rename to backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs index 10f26d4..67d6c16 100644 --- a/backend/Musify.Infrastructure/Services/StreamTicketService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs @@ -24,10 +24,16 @@ namespace Musify.Infrastructure.Services signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); } - public StreamTicket IssueTicket(long userId, string keyPrefix) + public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null) { var now = DateTimeOffset.UtcNow; + var claims = new Dictionary { ["prefix"] = keyPrefix }; + if (userId != null) + claims["sub"] = userId.Value.ToString(); + if (maxBytes != null) + claims["maxBytes"] = maxBytes.Value.ToString(); + var descriptor = new SecurityTokenDescriptor { Issuer = configuration.Issuer, @@ -35,11 +41,7 @@ namespace Musify.Infrastructure.Services IssuedAt = now.UtcDateTime, NotBefore = now.UtcDateTime, Expires = now.AddSeconds(configuration.TicketTtlSeconds).UtcDateTime, - Claims = new Dictionary - { - ["sub"] = userId.ToString(), - ["prefix"] = keyPrefix, - }, + Claims = claims, SigningCredentials = signingCredentials, }; diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 2e34c09..aa0fa86 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -1,51 +1,65 @@ - true - - + - - - + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + \ No newline at end of file diff --git a/backend/Musify.Api/Musify.Api.csproj b/backend/Hosts/Musify.Api/Api.csproj similarity index 81% rename from backend/Musify.Api/Musify.Api.csproj rename to backend/Hosts/Musify.Api/Api.csproj index f9cb76c..b693556 100644 --- a/backend/Musify.Api/Musify.Api.csproj +++ b/backend/Hosts/Musify.Api/Api.csproj @@ -1,6 +1,8 @@ + Musify.Api + Musify.Api 1c3fa41c-6f07-433b-af79-1f67fad338fd @@ -17,6 +19,7 @@ + @@ -31,9 +34,9 @@ - - - + + + diff --git a/backend/Musify.Api/AppSettings.Development.json b/backend/Hosts/Musify.Api/AppSettings.Development.json similarity index 54% rename from backend/Musify.Api/AppSettings.Development.json rename to backend/Hosts/Musify.Api/AppSettings.Development.json index 430b1ac..9ddf45d 100644 --- a/backend/Musify.Api/AppSettings.Development.json +++ b/backend/Hosts/Musify.Api/AppSettings.Development.json @@ -6,21 +6,14 @@ } }, "Authentication": { - "MetadataAddress": "http://192.168.0.120:8080/.well-known/openid-configuration", - "IssuerAddress": "http://192.168.0.120:8080", - "AudienceAddress": "375710146774499331", - "ClientId": "375710146774499331", - "AuthorizationEndpoint": "http://192.168.0.120:8080/oauth/v2/authorize?prompt=login", - "TokenEndpoint": "http://192.168.0.120:8080/oauth/v2/token", "Scopes": [ "openid", "profile", "email" ], - "ScalarRedirectUri": "http://192.168.0.120:5111/scalar/", "RequireHttpsMetadata": false }, "Database": { "ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres" }, "MassTransit": { - "Address": "rabbitmq://127.0.0.1:5672", + "Address": "rabbitmq://localhost:5672", "Username": "guest", "Password": "guest" }, @@ -35,21 +28,17 @@ "Bucket": "webapi-storage" }, "StreamGateway": { - "PublicBaseUrl": "http://localhost:8081", - "ManifestFileName": "manifest.mpd" + "PublicBaseUrl": "http://localhost:8081" }, "StreamTicket": { - "PrivateKeyPath": "D:\\weed\\stream_private.pem", - "Audience": "media-gateway", - "Issuer": "musify-webapi", - "TicketTtlSeconds": 3600 + "PrivateKeyPath": "../../../deploy/keys/stream_private.pem" }, "OpenTelemetry": { "OtlpEndpoint": "http://localhost:4317" }, "Workers": { "Routes": { - "TemporaryFilesDirectory": "D:\\tempsFilesDev" + "TemporaryFilesDirectory": "../../../deploy/.tmp" } } } diff --git a/backend/Musify.Api/AppSettings.json b/backend/Hosts/Musify.Api/AppSettings.json similarity index 81% rename from backend/Musify.Api/AppSettings.json rename to backend/Hosts/Musify.Api/AppSettings.json index ed3bd07..4c0c5aa 100644 --- a/backend/Musify.Api/AppSettings.json +++ b/backend/Hosts/Musify.Api/AppSettings.json @@ -48,6 +48,14 @@ "OpenTelemetry": { "OtlpEndpoint": "" }, + "Playback": { + "AllowAnonymousListening": false, + "AnonymousFragmentSeconds": 0, + "EstimatedAudioBytesPerSecond": 16000 + }, + "Mix": { + "ItemsPerMix": 12 + }, "PlayList": { "Routes": { "UploadsFolder": "uploads", @@ -55,11 +63,25 @@ "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", "MediumPicturesFolder": "MediumPictures", - "LargePicturesFolder": "LargePictures", - "PresetOriginalPicture": "PresetOriginalPicture.webp", - "PresetSmallPicture": "PresetSmallPicture.webp", - "PresetMediumPicture": "PresetMediumPicture.webp", - "PresetLargePicture": "PresetLargePicture.webp" + "LargePicturesFolder": "LargePictures" + }, + "PicturesSizes": { + "SmallPictureWidth": 128, + "SmallPictureHeight": 128, + "MediumPictureWidth": 256, + "MediumPictureHeight": 256, + "LargePictureWidth": 512, + "LargePictureHeight": 512 + } + }, + "Album": { + "Routes": { + "UploadsFolder": "uploads", + "ParentFolders": "Albums", + "OriginalPicturesFolder": "OriginalPictures", + "SmallPicturesFolder": "SmallPictures", + "MediumPicturesFolder": "MediumPictures", + "LargePicturesFolder": "LargePictures" }, "PicturesSizes": { "SmallPictureWidth": 128, diff --git a/backend/Musify.Api/Authentication/AuthenticationConfiguration.cs b/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs similarity index 100% rename from backend/Musify.Api/Authentication/AuthenticationConfiguration.cs rename to backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs diff --git a/backend/Musify.Api/Authentication/AuthenticationExtensions.cs b/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs similarity index 96% rename from backend/Musify.Api/Authentication/AuthenticationExtensions.cs rename to backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs index 2f8416b..45a31b9 100644 --- a/backend/Musify.Api/Authentication/AuthenticationExtensions.cs +++ b/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs @@ -18,6 +18,7 @@ public static class AuthenticationExtensions serviceProvider.GetRequiredService>().Value); services.AddMemoryCache(); + services.AddHttpClient(); services.AddScoped(); services.ConfigureOptions(); diff --git a/backend/Musify.Api/Authentication/CurrentUser.cs b/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs similarity index 90% rename from backend/Musify.Api/Authentication/CurrentUser.cs rename to backend/Hosts/Musify.Api/Authentication/CurrentUser.cs index 3505fdc..04ea670 100644 --- a/backend/Musify.Api/Authentication/CurrentUser.cs +++ b/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs @@ -8,8 +8,7 @@ public sealed class CurrentUser : IBindableFromHttpContext private CurrentUser(ClaimsPrincipal principal) { Principal = principal; - var isAuthenticated = principal.Identity?.IsAuthenticated; - IsAuthenticated = isAuthenticated.HasValue && isAuthenticated.Value; + IsAuthenticated = principal.Identity?.IsAuthenticated ?? false; var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); if (long.TryParse(rawId, out var id) && id != 0) diff --git a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs new file mode 100644 index 0000000..b8cd402 --- /dev/null +++ b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs @@ -0,0 +1,127 @@ +using Mediator; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Caching.Memory; +using Musify.Application.Users; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text.Json.Serialization; + +namespace Musify.Api.Authentication; + +public sealed class JwtBearerEventsHandler( + IMediator mediator, + IMemoryCache cache, + IHttpClientFactory httpClientFactory, + AuthenticationConfiguration authenticationConfiguration, + ILogger logger) : JwtBearerEvents +{ + private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15); + + private sealed record UserInfoResponse( + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("preferred_username")] string? PreferredUsername, + [property: JsonPropertyName("email")] string? Email, + [property: JsonPropertyName("given_name")] string? GivenName, + [property: JsonPropertyName("family_name")] string? FamilyName, + [property: JsonPropertyName("picture")] string? Picture); + + public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext) + { + var principal = tokenValidatedContext.Principal!; + + var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (!long.TryParse(rawId, out var userId)) + { + logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId); + return; + } + + var cacheKey = $"user-synced:{userId}"; + if (cache.TryGetValue(cacheKey, out _)) + return; + + var userInfo = await FetchUserInfoAsync(tokenValidatedContext, tokenValidatedContext.HttpContext.RequestAborted); + + var username = userInfo?.Name + ?? userInfo?.PreferredUsername + ?? principal.FindFirstValue("name") + ?? principal.FindFirstValue(ClaimTypes.Name) + ?? principal.FindFirstValue("preferred_username") + ?? userInfo?.Email + ?? principal.FindFirstValue("email") + ?? principal.FindFirstValue(ClaimTypes.Email) + ?? rawId!; + + var firstName = userInfo?.GivenName + ?? principal.FindFirstValue("given_name") + ?? principal.FindFirstValue(ClaimTypes.GivenName); + var lastName = userInfo?.FamilyName + ?? principal.FindFirstValue("family_name") + ?? principal.FindFirstValue(ClaimTypes.Surname); + var profilePictureUrl = userInfo?.Picture ?? principal.FindFirstValue("picture"); + + try + { + await mediator.Send( + new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl), + tokenValidatedContext.HttpContext.RequestAborted); + + cache.Set(cacheKey, true, UserSyncCacheTtl); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId); + } + } + + private async Task FetchUserInfoAsync( + TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) + { + var authorizationHeader = tokenValidatedContext.HttpContext.Request.Headers.Authorization.ToString(); + if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return null; + + var accessToken = authorizationHeader["Bearer ".Length..].Trim(); + if (accessToken.Length == 0) + return null; + + try + { + var client = httpClientFactory.CreateClient(); + var request = new HttpRequestMessage( + HttpMethod.Get, + $"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) } + }; + + var response = await client.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode); + return null; + } + + return await response.Content.ReadFromJsonAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to fetch userinfo from identity provider"); + return null; + } + } + + public override Task AuthenticationFailed(AuthenticationFailedContext context) + { + logger.LogWarning(context.Exception, "JWT authentication failed"); + return Task.CompletedTask; + } + + public override Task Challenge(JwtBearerChallengeContext context) + { + if (context.AuthenticateFailure != null) + logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription); + return Task.CompletedTask; + } +} diff --git a/backend/Musify.Api/Authentication/JwtBearerOptionsSetup.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs similarity index 100% rename from backend/Musify.Api/Authentication/JwtBearerOptionsSetup.cs rename to backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs new file mode 100644 index 0000000..fa3292e --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs @@ -0,0 +1,7 @@ +namespace Musify.Api.DataTransferObjects.Albums; + +public record CreateAlbumRequest( + string Title, + string? Description, + int? ReleaseYear, + Guid PictureIntentId); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/RequestAlbumPictureUploadRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/RequestAlbumPictureUploadRequest.cs new file mode 100644 index 0000000..a1fb913 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/RequestAlbumPictureUploadRequest.cs @@ -0,0 +1,6 @@ +namespace Musify.Api.DataTransferObjects.Albums; + +public record RequestAlbumPictureUploadRequest( + string FileType, + string ContentType, + long? ExpectedSizeBytes = null); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs new file mode 100644 index 0000000..d01812e --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs @@ -0,0 +1,7 @@ +namespace Musify.Api.DataTransferObjects.Albums; + +public record UpdateAlbumRequest( + string NewTitle, + string? NewDescription, + int? NewReleaseYear, + Guid? NewPictureIntentId); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs new file mode 100644 index 0000000..2ce34e2 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs @@ -0,0 +1,3 @@ +namespace Musify.Api.DataTransferObjects.Likes; + +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 new file mode 100644 index 0000000..d000ed6 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs @@ -0,0 +1,3 @@ +namespace Musify.Api.DataTransferObjects.PlayLists; + +public record AddPlayListTrackRequest(Guid TrackId); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs new file mode 100644 index 0000000..bc5910e --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs @@ -0,0 +1,9 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Api.DataTransferObjects.PlayLists; + +public record CreatePlayListRequest( + string Name, + string? Description, + Guid? PictureIntentId, + PlaylistVisibility Visibility = PlaylistVisibility.Private); diff --git a/backend/Musify.Api/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs similarity index 100% rename from backend/Musify.Api/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs rename to backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/RequestPlayListPictureUploadRequest.cs diff --git a/backend/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs similarity index 55% rename from backend/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs rename to backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs index 5ac5700..0ccf8fc 100644 --- a/backend/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs @@ -1,6 +1,9 @@ +using Musify.Domain.ValueObjects; + namespace Musify.Api.DataTransferObjects.PlayLists; public record UpdatePlayListRequest( string? NewName, string? NewDescription, - Guid? NewPictureIntentId); + Guid? NewPictureIntentId, + PlaylistVisibility? NewVisibility = null); diff --git a/backend/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs similarity index 56% rename from backend/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs rename to backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs index 4922e18..6623318 100644 --- a/backend/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs @@ -1,6 +1,9 @@ +using Musify.Domain.ValueObjects; + namespace Musify.Api.DataTransferObjects.Tracks; public record CreateTrackRequest( string Title, Guid PictureIntentId, - Guid AudioIntentId); + Guid AudioIntentId, + IReadOnlyCollection Tags); diff --git a/backend/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs similarity index 100% rename from backend/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs rename to backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs diff --git a/backend/Musify.Api/DataTransferObjects/Users/CreateUserRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Users/CreateUserRequest.cs similarity index 100% rename from backend/Musify.Api/DataTransferObjects/Users/CreateUserRequest.cs rename to backend/Hosts/Musify.Api/DataTransferObjects/Users/CreateUserRequest.cs diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs new file mode 100644 index 0000000..fb02cb4 --- /dev/null +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -0,0 +1,262 @@ +using Mediator; +using Musify.Api.Authentication; +using Musify.Api.DataTransferObjects.Albums; +using Musify.Api.Extensions; +using Musify.Api.Filters; +using Musify.Application.Albums; +using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; + +namespace Musify.Api.Endpoints; + +public static class AlbumEndpoints +{ + public static IEndpointRouteBuilder MapAlbumEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/albums") + .WithTags("Albums"); + + group.MapGet("/", GetAlbums) + .WithName("GetAlbums") + .WithSummary("Get Paginated Albums, Optionally Filtered By Title.") + .Produces(); + + group.MapGet("/{id}", GetAlbumById) + .WithName("GetAlbumById") + .WithSummary("Get An Album By Id.") + .Produces() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/users/{userId}", GetAlbumsByUserId) + .WithName("GetAlbumsByUserId") + .WithSummary("Get Paginated Albums For A User.") + .Produces>() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/recent", GetRecentlyListenedAlbums) + .WithName("GetRecentlyListenedAlbums") + .WithSummary("Get The Albums The Current User Listened To Most Recently.") + .RequireAuthorization() + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); + + group.MapGet("/{albumId}/tracks", GetAlbumTracks) + .WithName("GetAlbumTracks") + .WithSummary("Get The Tracks Of An Album Ordered By Track Number.") + .Produces>() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/{id}/cover", GetAlbumCover) + .WithName("GetAlbumCover") + .WithSummary("Get An Album Cover Image.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + + group.MapPost("/upload-picture", RequestAlbumPictureUpload) + .WithName("RequestAlbumPictureUpload") + .WithSummary("Request A Pre-Signed URL To Upload An Album Picture.") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + group.MapPost("/", CreateAlbum) + .WithName("CreateAlbum") + .WithSummary("Create A New Album.") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + group.MapPut("/{albumId}", UpdateAlbum) + .WithName("UpdateAlbum") + .WithSummary("Update An Album.") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + group.MapDelete("/{albumId}", DeleteAlbum) + .WithName("DeleteAlbum") + .WithSummary("Delete An Album.") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + group.MapPost("/{albumId}/tracks/{trackId}", AddTrackToAlbum) + .WithName("AddTrackToAlbum") + .WithSummary("Add One Of Your Uploaded Tracks To An Album.") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); + + group.MapDelete("/{albumId}/tracks/{trackId}", RemoveTrackFromAlbum) + .WithName("RemoveTrackFromAlbum") + .WithSummary("Remove A Track From An Album.") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + return app; + } + + private static async Task GetAlbums( + IMediator mediator, + CancellationToken cancellationToken, + string? title, + int pageNumber = 1, + int pageSize = 10) + { + var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetAlbumById( + IMediator mediator, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumByIdQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetAlbumsByUserId( + IMediator mediator, + long userId, + CancellationToken cancellationToken, + int pageNumber = 1, + int pageSize = 10) + { + var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, pageNumber, pageSize), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetRecentlyListenedAlbums( + IMediator mediator, + CurrentUser currentUser, + CancellationToken cancellationToken, + int limit = 12) + { + var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetAlbumTracks( + IMediator mediator, + Guid albumId, + CancellationToken cancellationToken, + int pageNumber = 1, + int pageSize = 50) + { + var result = await mediator.Send(new GetAlbumTracksQuery(albumId, pageNumber, pageSize), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetAlbumCover( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + Guid id, + CancellationToken cancellationToken, + string size = "medium") + { + var result = await mediator.Send(new GetAlbumCoverQuery(id, size), cancellationToken); + if (result.IsError) + return Results.NotFound(); + + var location = result.Value; + var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); + response.Headers.CacheControl = "public, max-age=31536000, immutable"; + return Results.Stream(stream, location.ContentType); + } + + private static async Task RequestAlbumPictureUpload( + IMediator mediator, + CurrentUser currentUser, + RequestAlbumPictureUploadRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RequestAlbumPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), + cancellationToken); + + return result.ToHttpResult(); + } + + private static async Task CreateAlbum( + IMediator mediator, + CurrentUser currentUser, + CreateAlbumRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), + cancellationToken); + + return result.ToCreatedResult(album => $"/albums/{album.Id}"); + } + + private static async Task UpdateAlbum( + IMediator mediator, + CurrentUser currentUser, + Guid albumId, + UpdateAlbumRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new UpdateAlbumCommand(currentUser.RequiredId, albumId, request.NewTitle, request.NewDescription, request.NewReleaseYear, request.NewPictureIntentId), + cancellationToken); + + return result.ToHttpResult(); + } + + private static async Task DeleteAlbum( + IMediator mediator, + CurrentUser currentUser, + Guid albumId, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeleteAlbumCommand(currentUser.RequiredId, albumId), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task AddTrackToAlbum( + IMediator mediator, + CurrentUser currentUser, + Guid albumId, + Guid trackId, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new AddTrackToAlbumCommand(currentUser.RequiredId, albumId, trackId), + cancellationToken); + + return result.ToHttpResult(); + } + + private static async Task RemoveTrackFromAlbum( + IMediator mediator, + CurrentUser currentUser, + Guid albumId, + Guid trackId, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RemoveTrackFromAlbumCommand(currentUser.RequiredId, albumId, trackId), + cancellationToken); + + return result.ToHttpResult(); + } +} diff --git a/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs new file mode 100644 index 0000000..ed4220a --- /dev/null +++ b/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs @@ -0,0 +1,28 @@ +using Mediator; +using Musify.Application.Configuration; +using Musify.Application.Configuration.Responses; + +namespace Musify.Api.Endpoints; + +public static class ConfigEndpoints +{ + public static IEndpointRouteBuilder MapConfigEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/config") + .WithTags("Config"); + + group.MapGet("/playback", GetPlaybackPublicConfig) + .WithName("GetPlaybackPublicConfig") + .WithSummary("Get The Public Playback Configuration (Whether Anonymous Listening Is Allowed). Works Anonymously.") + .Produces(); + + return app; + } + + private static async Task GetPlaybackPublicConfig( + IMediator mediator, + CancellationToken cancellationToken) + { + return await mediator.Send(new GetPlaybackPublicConfigQuery(), cancellationToken); + } +} diff --git a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs new file mode 100644 index 0000000..ac74df6 --- /dev/null +++ b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs @@ -0,0 +1,59 @@ +using Mediator; +using Musify.Application.Likes; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; +using Musify.Api.Authentication; +using Musify.Api.DataTransferObjects.Likes; +using Musify.Api.Extensions; +using Musify.Api.Filters; + +namespace Musify.Api.Endpoints; + +public static class LikeEndpoints +{ + public static IEndpointRouteBuilder MapLikeEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/likes") + .WithTags("Likes") + .RequireAuthorization(); + + group.MapGet("/", GetLikedTracks) + .WithName("GetLikedTracks") + .WithSummary("Get The Current User'S Liked Tracks.") + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); + + group.MapPost("/toggle", ToggleTrackLike) + .WithName("ToggleTrackLike") + .WithSummary("Like Or Unlike A Track By Id.") + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + return app; + } + + private static async Task GetLikedTracks( + IMediator mediator, + CurrentUser currentUser, + CancellationToken cancellationToken, + int pageNumber = 1, + int pageSize = 10) + { + var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, pageNumber, pageSize), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ToggleTrackLike( + IMediator mediator, + CurrentUser currentUser, + ToggleLikeRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId), + cancellationToken); + + return result.ToHttpResult(); + } +} diff --git a/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs new file mode 100644 index 0000000..41a79cd --- /dev/null +++ b/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs @@ -0,0 +1,51 @@ +using Mediator; +using Musify.Api.Authentication; +using Musify.Api.Extensions; +using Musify.Application.Mixes; +using Musify.Application.Mixes.Responses; + +namespace Musify.Api.Endpoints; + +public static class MixEndpoints +{ + public static IEndpointRouteBuilder MapMixEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/mixes") + .WithTags("Mixes") + .RequireAuthorization(); + + group.MapGet("/", GetMixes) + .WithName("GetMixes") + .WithSummary("Get The Server-Generated Mixes Of The Current User.") + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); + + group.MapGet("/{id}", GetMixById) + .WithName("GetMixById") + .WithSummary("Get A Mix With All Of Its Songs.") + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + return app; + } + + private static async Task GetMixes( + IMediator mediator, + CurrentUser currentUser, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetMixById( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMixByIdQuery(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } +} diff --git a/backend/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs similarity index 79% rename from backend/Musify.Api/Endpoints/PlayListEndpoints.cs rename to backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index 134b7a9..8ab6dc2 100644 --- a/backend/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -1,8 +1,10 @@ using Mediator; using Musify.Application.PlayLists; using Musify.Application.PlayLists.Responses; +using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Musify.Application.Shared; +using Musify.Application.Contracts; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.Extensions; @@ -39,6 +41,12 @@ public static class PlayListEndpoints .Produces>() .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id}/cover", GetPlayListCover) + .WithName("GetPlayListCover") + .WithSummary("Get A PlayList Cover Image.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + group.MapPost("/", CreatePlayList) .WithName("CreatePlayList") .WithSummary("Create A New PlayList.") @@ -77,11 +85,11 @@ public static class PlayListEndpoints .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); - group.MapPost("/{playlistId}/tracks/{trackId}", AddTrackToPlayList) - .WithName("AddTrackToPlayList") - .WithSummary("Add A Track To A PlayList.") + group.MapPost("/{playlistId}/tracks", AddPlayListTrack) + .WithName("AddPlayListTrack") + .WithSummary("Add One Of Your Tracks To A PlayList.") .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); @@ -109,10 +117,11 @@ public static class PlayListEndpoints private static async Task GetPlayListById( IMediator mediator, + CurrentUser currentUser, Guid id, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetPlayListByIdQuery(id), cancellationToken); + var result = await mediator.Send(new GetPlayListByIdQuery(id, currentUser.Id), cancellationToken); return result.ToHttpResult(); } @@ -122,9 +131,10 @@ public static class PlayListEndpoints CancellationToken cancellationToken, string? name, int pageNumber = 1, - int pageSize = 10) + int pageSize = 10, + bool onlyPublic = false) { - var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize, onlyPublic), cancellationToken); return result.ToHttpResult(); } @@ -139,6 +149,24 @@ public static class PlayListEndpoints return result.ToHttpResult(); } + private static async Task GetPlayListCover( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + Guid id, + CancellationToken cancellationToken, + string size = "medium") + { + var result = await mediator.Send(new GetPlayListCoverQuery(id, size), cancellationToken); + if (result.IsError) + return Results.NotFound(); + + var location = result.Value; + var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); + response.Headers.CacheControl = "public, max-age=31536000, immutable"; + return Results.Stream(stream, location.ContentType); + } + private static async Task CreatePlayList( IMediator mediator, CurrentUser currentUser, @@ -146,7 +174,7 @@ public static class PlayListEndpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId), + new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId, request.Visibility), cancellationToken); return result.ToCreatedResult(playList => $"/playlists/{playList.Id}"); @@ -160,7 +188,7 @@ public static class PlayListEndpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new UpdatePlayListCommand(currentUser.RequiredId, playlistId, request.NewName, request.NewDescription, request.NewPictureIntentId), + new UpdatePlayListCommand(currentUser.RequiredId, playlistId, request.NewName, request.NewDescription, request.NewPictureIntentId, request.NewVisibility), cancellationToken); return result.ToHttpResult(); @@ -189,18 +217,21 @@ public static class PlayListEndpoints return result.ToHttpResult(); } - private static async Task AddTrackToPlayList( + private static async Task AddPlayListTrack( IMediator mediator, CurrentUser currentUser, Guid playlistId, - Guid trackId, + AddPlayListTrackRequest request, CancellationToken cancellationToken) { - var result = await mediator.Send( - new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, trackId), + var addResult = await mediator.Send( + new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, request.TrackId), cancellationToken); + if (addResult.IsError) + return addResult.ToHttpResult(); - return result.ToHttpResult(); + 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/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs similarity index 79% rename from backend/Musify.Api/Endpoints/TrackEndpoints.cs rename to backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 5ed80d3..b285245 100644 --- a/backend/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -2,6 +2,7 @@ using Mediator; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Musify.Application.Shared; +using Musify.Application.Contracts; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; @@ -18,8 +19,8 @@ public static class TrackEndpoints group.MapGet("/", GetTracks) .WithName("GetTracks") - .WithSummary("Get Paginated Tracks.") - .Produces>(); + .WithSummary("Get Paginated Tracks, Optionally Filtered By Name.") + .Produces(); group.MapGet("/{id}", GetTrackById) .WithName("GetTrackById") @@ -27,10 +28,15 @@ public static class TrackEndpoints .Produces() .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id}/cover", GetTrackCover) + .WithName("GetTrackCover") + .WithSummary("Get A Track Cover Image.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id}/stream", GetTrackStream) .WithName("GetTrackStream") - .WithSummary("Get A Streaming Manifest URL And Ticket For A Track.") - .RequireAuthorization() + .WithSummary("Get A Streaming Manifest URL And Ticket For A Track. Works Anonymously When The Playback Configuration Allows It.") .Produces() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); @@ -77,10 +83,11 @@ public static class TrackEndpoints private static async Task GetTracks( IMediator mediator, CancellationToken cancellationToken, + string? name, int pageNumber = 1, int pageSize = 10) { - var result = await mediator.Send(new GetTracksQuery(pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken); return result.ToHttpResult(); } @@ -93,13 +100,31 @@ public static class TrackEndpoints return result.ToHttpResult(); } + private static async Task GetTrackCover( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + Guid id, + CancellationToken cancellationToken, + string size = "medium") + { + var result = await mediator.Send(new GetTrackCoverQuery(id, size), cancellationToken); + if (result.IsError) + return Results.NotFound(); + + var location = result.Value; + var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); + response.Headers.CacheControl = "public, max-age=31536000, immutable"; + return Results.Stream(stream, location.ContentType); + } + private static async Task GetTrackStream( IMediator mediator, CurrentUser currentUser, Guid id, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.RequiredId), cancellationToken); + var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.Id), cancellationToken); return result.ToHttpResult(); } @@ -122,7 +147,7 @@ public static class TrackEndpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId), + new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags), cancellationToken); return result.ToCreatedResult(track => $"/tracks/{track.Id}"); diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs new file mode 100644 index 0000000..c1c97f3 --- /dev/null +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -0,0 +1,183 @@ +using Mediator; +using Musify.Api.Authentication; +using Musify.Api.DataTransferObjects.Users; +using Musify.Api.Extensions; +using Musify.Api.Filters; +using Musify.Application.Shared; +using Musify.Application.Tracks; +using Musify.Application.Tracks.Responses; +using Musify.Application.Users; +using Musify.Application.Users.Responses; + +namespace Musify.Api.Endpoints; + +public static class UserEndpoints +{ + public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/users") + .WithTags("Users"); + + group.MapGet("/", GetUsers) + .WithName("GetUsers") + .WithSummary("Get Paginated Users.") + .Produces>(); + + group.MapGet("/{id}", GetUserById) + .WithName("GetUserById") + .WithSummary("Get A User By Id.") + .Produces() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/{id}/listening-history", GetListeningHistory) + .WithName("GetListeningHistory") + .WithSummary("Get A User'S Listening History.") + .Produces>() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/{id}/listening-stats", GetListeningStats) + .WithName("GetListeningStats") + .WithSummary("Get A User'S Listening Stats For The Current Week And Streak.") + .Produces(); + + group.MapGet("/{id}/last-listened-track", GetLastTrackListenedByUserId) + .WithName("GetLastTrackListenedByUserId") + .WithSummary("Get The Last Track A User Listened To.") + .Produces() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/{id}/profile", GetUserProfile) + .WithName("GetUserProfile") + .WithSummary("Get A User'S Public Profile, Including Follower Counts.") + .Produces() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/{id}/is-following", IsFollowing) + .WithName("IsFollowingUser") + .WithSummary("Check Whether The Current User Follows Another User.") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized); + + group.MapPost("/{id}/follow", FollowUser) + .WithName("FollowUser") + .WithSummary("Follow A User.") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .ProducesValidationProblem(); + + group.MapDelete("/{id}/follow", UnfollowUser) + .WithName("UnfollowUser") + .WithSummary("Unfollow A User.") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized); + + group.MapPost("/", CreateUser) + .WithName("CreateUser") + .WithSummary("Create A New User.") + .AddEndpointFilter>() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .Produces(StatusCodes.Status409Conflict); + + return app; + } + + private static async Task GetUsers( + IMediator mediator, + CancellationToken cancellationToken, + string? usernameSearch, + int pageNumber = 1, + int pageSize = 10) + { + var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetUserById( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task> GetListeningHistory( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + return await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken); + } + + private static async Task GetListeningStats( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + return await mediator.Send(new GetListeningStatsQuery(id), cancellationToken); + } + + private static async Task GetLastTrackListenedByUserId( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetUserProfile( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserProfileQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task IsFollowing( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + return await mediator.Send(new IsFollowingUserQuery(currentUser.RequiredId, id), cancellationToken); + } + + private static async Task FollowUser( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new FollowUserCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UnfollowUser( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreateUser( + IMediator mediator, + CreateUserRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName), + cancellationToken); + + return result.ToCreatedResult(user => $"/users/{user.Id}"); + } +} diff --git a/backend/Musify.Api/Extensions/ErrorOrHttpExtensions.cs b/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs similarity index 100% rename from backend/Musify.Api/Extensions/ErrorOrHttpExtensions.cs rename to backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs diff --git a/backend/Musify.Api/Extensions/ServiceCollectionExtensions.cs b/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs similarity index 100% rename from backend/Musify.Api/Extensions/ServiceCollectionExtensions.cs rename to backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs diff --git a/backend/Musify.Api/Filters/ValidationFilter.cs b/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs similarity index 95% rename from backend/Musify.Api/Filters/ValidationFilter.cs rename to backend/Hosts/Musify.Api/Filters/ValidationFilter.cs index 65e743b..cd66124 100644 --- a/backend/Musify.Api/Filters/ValidationFilter.cs +++ b/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs @@ -8,7 +8,7 @@ public sealed class ValidationFilter(IValidator validator) : IEndpointFilt { var argument = context.Arguments.OfType().FirstOrDefault(); - if (argument is null) + if (argument == null) return await next(context); var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted); diff --git a/backend/Musify.Api/OpenApi/OpenApiExtensions.cs b/backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs similarity index 88% rename from backend/Musify.Api/OpenApi/OpenApiExtensions.cs rename to backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs index 6a7b6cc..6f00c38 100644 --- a/backend/Musify.Api/OpenApi/OpenApiExtensions.cs +++ b/backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs @@ -1,5 +1,3 @@ -using Microsoft.AspNetCore.OpenApi; - namespace Musify.Api.OpenApi; public static class OpenApiExtensions diff --git a/backend/Musify.Api/OpenApi/OpenApiOptionsSetup.cs b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs similarity index 85% rename from backend/Musify.Api/OpenApi/OpenApiOptionsSetup.cs rename to backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs index 24e0bab..ed9a5af 100644 --- a/backend/Musify.Api/OpenApi/OpenApiOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.Options; using Microsoft.OpenApi; using Musify.Api.Authentication; +using Musify.Application.Serialization; namespace Musify.Api.OpenApi; @@ -38,6 +39,17 @@ public sealed class OpenApiOptionsSetup(IOptions op return Task.CompletedTask; } ); + options.AddSchemaTransformer((schema, context, _) => + { + if (context.JsonPropertyInfo?.CustomConverter is LongAsStringConverter or NullableLongAsStringConverter) + { + schema.Type = JsonSchemaType.String; + schema.Format = null; + } + + return Task.CompletedTask; + } ); + options.AddOperationTransformer((operation, context, _) => { var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata diff --git a/backend/Musify.Api/Program.cs b/backend/Hosts/Musify.Api/Program.cs similarity index 74% rename from backend/Musify.Api/Program.cs rename to backend/Hosts/Musify.Api/Program.cs index c7ab2bd..e31b5e0 100644 --- a/backend/Musify.Api/Program.cs +++ b/backend/Hosts/Musify.Api/Program.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Musify.Infrastructure.Observability; using Scalar.AspNetCore; using Musify.Api.Endpoints; @@ -5,6 +6,9 @@ using Musify.Api.Extensions; var builder = WebApplication.CreateBuilder(); +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); + const string devCorsPolicy = "dev-cors"; builder.Services @@ -34,8 +38,14 @@ app.UseAuthentication(); app.UseAuthorization(); app + .MapAlbumEndpoints() + .MapConfigEndpoints() + .MapLikeEndpoints() + .MapMixEndpoints() .MapPlayListEndpoints() .MapTrackEndpoints() .MapUserEndpoints(); -app.Run(); \ No newline at end of file +app.Run(); + +public partial class Program; \ No newline at end of file diff --git a/backend/Musify.Api/Properties/launchSettings.json b/backend/Hosts/Musify.Api/Properties/launchSettings.json similarity index 84% rename from backend/Musify.Api/Properties/launchSettings.json rename to backend/Hosts/Musify.Api/Properties/launchSettings.json index 31f06a4..574e4ed 100644 --- a/backend/Musify.Api/Properties/launchSettings.json +++ b/backend/Hosts/Musify.Api/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://192.168.0.120:5111", + "applicationUrl": "http://localhost:5111", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/backend/Musify.Api/Scalar/ScalarExtensions.cs b/backend/Hosts/Musify.Api/Scalar/ScalarExtensions.cs similarity index 100% rename from backend/Musify.Api/Scalar/ScalarExtensions.cs rename to backend/Hosts/Musify.Api/Scalar/ScalarExtensions.cs diff --git a/backend/Musify.Api/Scalar/ScalarOptionsSetup.cs b/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs similarity index 100% rename from backend/Musify.Api/Scalar/ScalarOptionsSetup.cs rename to backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs diff --git a/backend/Hosts/Musify.Api/Validators/Albums/AlbumReleaseYear.cs b/backend/Hosts/Musify.Api/Validators/Albums/AlbumReleaseYear.cs new file mode 100644 index 0000000..222c8be --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Albums/AlbumReleaseYear.cs @@ -0,0 +1,8 @@ +namespace Musify.Api.Validators.Albums; + +public static class AlbumReleaseYear +{ + public const int Earliest = 1877; + + public static int Latest => DateTime.UtcNow.Year + 1; +} diff --git a/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs new file mode 100644 index 0000000..10550eb --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.Albums; + +namespace Musify.Api.Validators.Albums; + +public sealed class CreateAlbumRequestValidator : AbstractValidator +{ + public CreateAlbumRequestValidator() + { + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(200); + + RuleFor(x => x.Description) + .MaximumLength(256); + + RuleFor(x => x.ReleaseYear) + .InclusiveBetween(AlbumReleaseYear.Earliest, AlbumReleaseYear.Latest) + .When(x => x.ReleaseYear.HasValue); + + RuleFor(x => x.PictureIntentId) + .NotEmpty(); + } +} diff --git a/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs new file mode 100644 index 0000000..d747056 --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.Albums; + +namespace Musify.Api.Validators.Albums; + +public sealed class RequestAlbumPictureUploadRequestValidator : AbstractValidator +{ + public RequestAlbumPictureUploadRequestValidator() + { + RuleFor(x => x.FileType) + .NotEmpty(); + + RuleFor(x => x.ContentType) + .NotEmpty(); + + RuleFor(x => x.ExpectedSizeBytes) + .GreaterThan(0) + .When(x => x.ExpectedSizeBytes.HasValue); + } +} diff --git a/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs new file mode 100644 index 0000000..122a906 --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.Albums; + +namespace Musify.Api.Validators.Albums; + +public sealed class UpdateAlbumRequestValidator : AbstractValidator +{ + public UpdateAlbumRequestValidator() + { + RuleFor(x => x.NewTitle) + .NotEmpty() + .MaximumLength(200); + + RuleFor(x => x.NewDescription) + .MaximumLength(256); + + RuleFor(x => x.NewReleaseYear) + .InclusiveBetween(AlbumReleaseYear.Earliest, AlbumReleaseYear.Latest) + .When(x => x.NewReleaseYear.HasValue); + } +} diff --git a/backend/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs similarity index 94% rename from backend/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs rename to backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs index 0664e82..b0003b7 100644 --- a/backend/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs @@ -12,7 +12,6 @@ public sealed class CreatePlayListRequestValidator : AbstractValidator x.Description) - .NotEmpty() .MaximumLength(256); } } diff --git a/backend/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs similarity index 100% rename from backend/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs rename to backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs diff --git a/backend/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs similarity index 77% rename from backend/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs rename to backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs index defc147..c886937 100644 --- a/backend/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs @@ -8,11 +8,12 @@ public sealed class UpdatePlayListRequestValidator : AbstractValidator x.NewName) + .NotEmpty() .MaximumLength(50) - .When(x => x.NewName is not null); + .When(x => x.NewName != null); RuleFor(x => x.NewDescription) .MaximumLength(256) - .When(x => x.NewDescription is not null); + .When(x => x.NewDescription != null); } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs new file mode 100644 index 0000000..6584cff --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs @@ -0,0 +1,34 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.Tracks; +using Musify.Domain.ValueObjects; + +namespace Musify.Api.Validators.Tracks; + +public sealed class CreateTrackRequestValidator : AbstractValidator +{ + public CreateTrackRequestValidator() + { + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(200); + + RuleFor(x => x.PictureIntentId) + .NotEmpty(); + + RuleFor(x => x.AudioIntentId) + .NotEmpty(); + + RuleFor(x => x.Tags) + .NotEmpty() + .WithMessage("At least one tag is required."); + + RuleFor(x => x.Tags) + .Must(tags => GenreCompatibility.FindConflicts(tags).Count == 0) + .When(x => x.Tags is { Count: > 0 }) + .WithMessage(x => + { + var conflict = GenreCompatibility.FindConflicts(x.Tags).First(); + return $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."; + }); + } +} diff --git a/backend/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs similarity index 100% rename from backend/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs rename to backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs diff --git a/backend/Musify.Api/Validators/Users/CreateUserRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Users/CreateUserRequestValidator.cs similarity index 79% rename from backend/Musify.Api/Validators/Users/CreateUserRequestValidator.cs rename to backend/Hosts/Musify.Api/Validators/Users/CreateUserRequestValidator.cs index 107ea85..bd8de92 100644 --- a/backend/Musify.Api/Validators/Users/CreateUserRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Users/CreateUserRequestValidator.cs @@ -16,10 +16,10 @@ public sealed class CreateUserRequestValidator : AbstractValidator x.FirstName) .MaximumLength(48) - .When(x => x.FirstName is not null); + .When(x => !string.IsNullOrWhiteSpace(x.FirstName)); RuleFor(x => x.SecondName) .MaximumLength(48) - .When(x => x.SecondName is not null); + .When(x => !string.IsNullOrWhiteSpace(x.SecondName)); } } diff --git a/backend/Musify.Api/WebApi.http b/backend/Hosts/Musify.Api/WebApi.http similarity index 100% rename from backend/Musify.Api/WebApi.http rename to backend/Hosts/Musify.Api/WebApi.http diff --git a/backend/Musify.StreamingGateway/Authentication/TicketValidator.cs b/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs similarity index 68% rename from backend/Musify.StreamingGateway/Authentication/TicketValidator.cs rename to backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs index 765f057..b2d3a59 100644 --- a/backend/Musify.StreamingGateway/Authentication/TicketValidator.cs +++ b/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs @@ -6,6 +6,13 @@ using Musify.StreamingGateway.Configuration; namespace Musify.StreamingGateway.Authentication; +public sealed record TicketValidationResult(string Prefix, long? MaxBytes) +{ + public static readonly TicketValidationResult Invalid = new(string.Empty, null); + + public bool IsValid => Prefix.Length > 0; +} + public sealed class TicketValidator : IDisposable { private readonly StreamTicketValidationOptions _options; @@ -34,17 +41,23 @@ public sealed class TicketValidator : IDisposable }; } - public async Task ValidateTicketAsync(string? token) + public async Task ValidateTicketAsync(string? token) { if (string.IsNullOrWhiteSpace(token)) - return string.Empty; + return TicketValidationResult.Invalid; var result = await _handler.ValidateTokenAsync(token, _validationParameters); if (!result.IsValid) - return string.Empty; + return TicketValidationResult.Invalid; var prefix = result.ClaimsIdentity?.FindFirst("prefix")?.Value; - return string.IsNullOrEmpty(prefix)? string.Empty : prefix; + if (string.IsNullOrEmpty(prefix)) + return TicketValidationResult.Invalid; + + var maxBytesClaim = result.ClaimsIdentity?.FindFirst("maxBytes")?.Value; + var maxBytes = long.TryParse(maxBytesClaim, out var parsed) ? parsed : (long?)null; + + return new TicketValidationResult(prefix, maxBytes); } public void Dispose() => _rsa.Dispose(); diff --git a/backend/Musify.StreamingGateway/Configuration/StreamTicketValidationOptions.cs b/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationOptions.cs similarity index 100% rename from backend/Musify.StreamingGateway/Configuration/StreamTicketValidationOptions.cs rename to backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationOptions.cs diff --git a/backend/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs similarity index 63% rename from backend/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs rename to backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs index 39df49a..94b9644 100644 --- a/backend/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs +++ b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; using Musify.StreamingGateway.Authentication; using Musify.StreamingGateway.Configuration; @@ -28,15 +30,16 @@ public sealed class TicketValidationMiddleware( token = context.Request.Headers[options.HeaderName].ToString(); } - var prefix = await ticketValidator.ValidateTicketAsync(token); + var validation = await ticketValidator.ValidateTicketAsync(token); - if (string.IsNullOrEmpty(prefix)) + if (!validation.IsValid) { logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path); context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } + var prefix = validation.Prefix; var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty; if (HasTraversalSegment(objectKey)) @@ -55,10 +58,42 @@ public sealed class TicketValidationMiddleware( return; } + if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes)) + { + logger.LogInformation( + "Rejected media request {ObjectKey}: requested range starts beyond the {MaxBytes}-byte preview limit", + objectKey, maxBytes); + context.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; + context.Response.Headers[HeaderNames.ContentRange] = $"bytes */{maxBytes}"; + return; + } + StripQueryParameter(context, options.QueryParameterName); await next(context); } + private static bool TryClampRange(HttpContext context, long maxBytes) + { + var rangeHeader = context.Request.Headers.Range; + if (StringValues.IsNullOrEmpty(rangeHeader)) + { + context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; + return true; + } + + if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range) || range.Ranges.Count != 1) + return true; + + var requested = range.Ranges.Single(); + var start = requested.From ?? 0; + if (start >= maxBytes) + return false; + + var end = requested.To is { } requestedEnd ? Math.Min(requestedEnd, maxBytes - 1) : maxBytes - 1; + context.Request.Headers.Range = $"bytes={start}-{end}"; + return true; + } + private static bool HasTraversalSegment(string objectKey) => objectKey.Split('/').Any(segment => segment is ".." or "."); diff --git a/backend/Musify.StreamingGateway/Program.cs b/backend/Hosts/Musify.StreamingGateway/Program.cs similarity index 100% rename from backend/Musify.StreamingGateway/Program.cs rename to backend/Hosts/Musify.StreamingGateway/Program.cs diff --git a/backend/Musify.StreamingGateway/Properties/launchSettings.json b/backend/Hosts/Musify.StreamingGateway/Properties/launchSettings.json similarity index 100% rename from backend/Musify.StreamingGateway/Properties/launchSettings.json rename to backend/Hosts/Musify.StreamingGateway/Properties/launchSettings.json diff --git a/backend/Musify.StreamingGateway/Musify.StreamingGateway.csproj b/backend/Hosts/Musify.StreamingGateway/StreamingGateway.csproj similarity index 73% rename from backend/Musify.StreamingGateway/Musify.StreamingGateway.csproj rename to backend/Hosts/Musify.StreamingGateway/StreamingGateway.csproj index 93ec808..3a33033 100644 --- a/backend/Musify.StreamingGateway/Musify.StreamingGateway.csproj +++ b/backend/Hosts/Musify.StreamingGateway/StreamingGateway.csproj @@ -1,6 +1,8 @@ + Musify.StreamingGateway + Musify.StreamingGateway media-gateway-2f1c9b6a-7d3e-4c0a-9a21-0b8e5d4c3f10 diff --git a/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json b/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json new file mode 100644 index 0000000..0bac6c4 --- /dev/null +++ b/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json @@ -0,0 +1,5 @@ +{ + "StreamTicket": { + "PublicKeyPath": "../../../deploy/keys/stream_public.pem" + } +} diff --git a/backend/Musify.StreamingGateway/appsettings.Development.json b/backend/Hosts/Musify.StreamingGateway/appsettings.json similarity index 72% rename from backend/Musify.StreamingGateway/appsettings.Development.json rename to backend/Hosts/Musify.StreamingGateway/appsettings.json index f18846a..a2eeaa5 100644 --- a/backend/Musify.StreamingGateway/appsettings.Development.json +++ b/backend/Hosts/Musify.StreamingGateway/appsettings.json @@ -1,6 +1,15 @@ { + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Yarp": "Information" + } + }, + "AllowedHosts": "*", + "StreamTicket": { - "PublicKeyPath": "D:\\weed\\stream_public.pem", + "PublicKeyPath": "", "Audience": "media-gateway", "Issuer": "musify-webapi", "QueryParameterName": "t", @@ -8,7 +17,7 @@ }, "Cors": { - "AllowedOrigins": [ "http://localhost:5173", "http://localhost:3000" ] + "AllowedOrigins": [] }, "ReverseProxy": { diff --git a/backend/Hosts/Musify.Worker/AppSettings.Development.json b/backend/Hosts/Musify.Worker/AppSettings.Development.json new file mode 100644 index 0000000..3b8dee5 --- /dev/null +++ b/backend/Hosts/Musify.Worker/AppSettings.Development.json @@ -0,0 +1,39 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Database": { + "ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres" + }, + "MassTransit": { + "Address": "rabbitmq://localhost:5672", + "Username": "guest", + "Password": "guest" + }, + "OpenTelemetry": { + "OtlpEndpoint": "http://localhost:4317" + }, + "InfrastructureStorage": { + "Address": "http://localhost:8333", + "AccessKey": "admin_access_key", + "SecretAccessKey": "admin_secret_key", + "ForcePathStyle": true, + "UseHttp": true + }, + "ApplicationStorage": { + "Bucket": "webapi-storage" + }, + "AudioTranscoder": { + "Routes": { + "WorkingDirectory": "../../../deploy/.tmp" + } + }, + "Workers": { + "Routes": { + "TemporaryFilesDirectory": "../../../deploy/.tmp" + } + } +} diff --git a/backend/Musify.Worker/AppSettings.json b/backend/Hosts/Musify.Worker/AppSettings.json similarity index 69% rename from backend/Musify.Worker/AppSettings.json rename to backend/Hosts/Musify.Worker/AppSettings.json index 7f58644..5a93fc4 100644 --- a/backend/Musify.Worker/AppSettings.json +++ b/backend/Hosts/Musify.Worker/AppSettings.json @@ -5,35 +5,29 @@ "Microsoft.Hosting.Lifetime": "Information" } }, - "ConnectionStrings": { - "Default": "" - }, "Database": { - "ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres" - }, - "Keycloak": { - "BaseUrl": "1", - "Realm": "1", - "Username": "1", - "Password": "1" + "ConnectionString": "" }, "MassTransit": { - "Address": "rabbitmq://127.0.0.1:5672", - "Username": "guest", - "Password": "guest" + "Address": "", + "Username": "", + "Password": "" }, "OpenTelemetry": { - "OtlpEndpoint": "http://localhost:4317" - }, - "ApplicationStorage": { - "Bucket": "webapi-storage" + "OtlpEndpoint": "" }, "InfrastructureStorage": { - "Address": "http://localhost:8333", - "AccessKey": "admin_access_key", - "SecretAccessKey": "admin_secret_key", - "ForcePathStyle": true, - "UseHttp": true + "Address": "", + "AccessKey": "", + "SecretAccessKey": "", + "ForcePathStyle": false, + "UseHttp": false + }, + "ApplicationStorage": { + "Bucket": "" + }, + "Mix": { + "ItemsPerMix": 12 }, "PlayList": { "Routes": { @@ -41,11 +35,7 @@ "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", "MediumPicturesFolder": "MediumPictures", - "LargePicturesFolder": "LargePictures", - "PresetOriginalPicture": "PresetOriginalPicture.webp", - "PresetSmallPicture": "PresetSmallPicture.webp", - "PresetMediumPicture": "PresetMediumPicture.webp", - "PresetLargePicture": "PresetLargePicture.webp" + "LargePicturesFolder": "LargePictures" }, "PicturesSizes": { "SmallPictureWidth": 128, @@ -81,13 +71,13 @@ }, "AudioTranscoder": { "Routes": { - "WorkingDirectory": "D:\\tempsFilesDev" + "WorkingDirectory": "" }, "TranscodingTimeout": "00:05:00" }, "Workers": { "Routes": { - "TemporaryFilesDirectory": "D:\\tempsFilesDev" + "TemporaryFilesDirectory": "" } }, "UploadIntent": { @@ -103,4 +93,4 @@ "TempUploadsRetentionDays": 3, "TempCleanupJobIntervalSeconds": 3600 } -} \ No newline at end of file +} diff --git a/backend/Musify.Worker/Program.cs b/backend/Hosts/Musify.Worker/Program.cs similarity index 53% rename from backend/Musify.Worker/Program.cs rename to backend/Hosts/Musify.Worker/Program.cs index 45e76c9..d86b01f 100644 --- a/backend/Musify.Worker/Program.cs +++ b/backend/Hosts/Musify.Worker/Program.cs @@ -1,10 +1,18 @@ +using Hangfire; using Musify.Application.Configuration; +using Musify.Infrastructure.Jobs; using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.Persistence; using Musify.Infrastructure.Services; var builder = Host.CreateApplicationBuilder(); +builder.ConfigureContainer(new DefaultServiceProviderFactory(new ServiceProviderOptions +{ + ValidateOnBuild = false, + ValidateScopes = true +})); + var configuration = builder.Configuration; var services = builder.Services; @@ -12,13 +20,27 @@ var services = builder.Services; services.AddValidatedOptions(configuration, ApplicationStorageConfiguration.SectionName); services.AddValidatedOptions(configuration, PlayListConfiguration.SectionName); services.AddValidatedOptions(configuration, TrackConfiguration.SectionName); +services.AddValidatedOptions(configuration, MixConfiguration.SectionName); services.AddStorageService(configuration); services.AddAudioTranscoder(configuration); +services.AddHttpClient(); services.AddPictureService(configuration); services.AddDatabase(configuration); services.AddMassTransitConsumers(configuration); services.AddUploadIntentJobs(configuration); +services.AddMediator(); +services.AddDailyMixGenerationJob(configuration); var host = builder.Build(); -host.Run(); \ No newline at end of file +using (var scope = host.Services.CreateScope()) +{ + var recurringJobManager = scope.ServiceProvider.GetRequiredService(); + recurringJobManager.AddOrUpdate( + "daily-mix-generation", + job => job.RunAsync(CancellationToken.None), + Cron.Daily(), + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); +} + +host.Run(); diff --git a/backend/Musify.Worker/Properties/launchSettings.json b/backend/Hosts/Musify.Worker/Properties/launchSettings.json similarity index 100% rename from backend/Musify.Worker/Properties/launchSettings.json rename to backend/Hosts/Musify.Worker/Properties/launchSettings.json diff --git a/backend/Musify.Worker/Musify.Worker.csproj b/backend/Hosts/Musify.Worker/Worker.csproj similarity index 68% rename from backend/Musify.Worker/Musify.Worker.csproj rename to backend/Hosts/Musify.Worker/Worker.csproj index 7e13d15..e264aa3 100644 --- a/backend/Musify.Worker/Musify.Worker.csproj +++ b/backend/Hosts/Musify.Worker/Worker.csproj @@ -1,12 +1,13 @@ + Musify.Worker + Musify.Worker dotnet-PictureWorker-1dfff89f-0602-41b7-8eaf-4da2c3358789 - @@ -14,14 +15,15 @@ + - - - + + + diff --git a/backend/Musify.Api.Dockerfile b/backend/Musify.Api.Dockerfile index 1afac7f..f27651e 100644 --- a/backend/Musify.Api.Dockerfile +++ b/backend/Musify.Api.Dockerfile @@ -1,17 +1,17 @@ -# Build context: backend/ (docker compose -f deploy/docker-compose.apps.yml build) +# Build context: backend/ (docker compose -f deploy/compose.yml --profile apps build) FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src # Restore: copy central config + csproj files first to cache the layer. COPY Directory.Build.props Directory.Packages.props nuget.config ./ -COPY Musify.Domain/Musify.Domain.csproj Musify.Domain/ -COPY Musify.Application/Musify.Application.csproj Musify.Application/ -COPY Musify.Infrastructure/Musify.Infrastructure.csproj Musify.Infrastructure/ -COPY Musify.Api/Musify.Api.csproj Musify.Api/ -RUN dotnet restore Musify.Api/Musify.Api.csproj +COPY Core/Musify.Domain/Domain.csproj Core/Musify.Domain/ +COPY Core/Musify.Application/Application.csproj Core/Musify.Application/ +COPY Core/Musify.Infrastructure/Infrastructure.csproj Core/Musify.Infrastructure/ +COPY Hosts/Musify.Api/Api.csproj Hosts/Musify.Api/ +RUN dotnet restore Hosts/Musify.Api/Api.csproj COPY . . -RUN dotnet publish Musify.Api/Musify.Api.csproj -c Release -o /app --no-restore +RUN dotnet publish Hosts/Musify.Api/Api.csproj -c Release -o /app --no-restore # Config files are AppSettings*.json (PascalCase); on case-sensitive Linux the host # looks for appsettings*.json. Add lowercase copies so they get loaded. RUN cp /app/AppSettings.json /app/appsettings.json \ diff --git a/backend/Musify.Api/Authentication/JwtBearerEventsHandler.cs b/backend/Musify.Api/Authentication/JwtBearerEventsHandler.cs deleted file mode 100644 index 02c091c..0000000 --- a/backend/Musify.Api/Authentication/JwtBearerEventsHandler.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Mediator; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.Caching.Memory; -using Musify.Application.Users; -using System.Security.Claims; - -namespace Musify.Api.Authentication; - -public sealed class JwtBearerEventsHandler( - IMediator mediator, - IMemoryCache cache, - ILogger logger) : JwtBearerEvents -{ - private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15); - - public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext) - { - var principal = tokenValidatedContext.Principal!; - - var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); - if (!long.TryParse(rawId, out var userId)) - { - logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId); - return; - } - - var cacheKey = $"user-synced:{userId}"; - if (cache.TryGetValue(cacheKey, out _)) - return; - - var username = principal.FindFirstValue(ClaimTypes.Name) - ?? principal.FindFirstValue("preferred_username") - ?? principal.FindFirstValue(ClaimTypes.Email) - ?? rawId!; - - var firstName = principal.FindFirstValue(ClaimTypes.GivenName); - var lastName = principal.FindFirstValue(ClaimTypes.Surname); - var profilePictureUrl = principal.FindFirstValue("picture"); - - try - { - await mediator.Send( - new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl), - tokenValidatedContext.HttpContext.RequestAborted); - - cache.Set(cacheKey, true, UserSyncCacheTtl); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId); - } - } - - public override Task AuthenticationFailed(AuthenticationFailedContext context) - { - logger.LogWarning(context.Exception, "JWT authentication failed"); - return Task.CompletedTask; - } - - public override Task Challenge(JwtBearerChallengeContext context) - { - if (context.AuthenticateFailure is not null) - logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription); - return Task.CompletedTask; - } -} diff --git a/backend/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs b/backend/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs deleted file mode 100644 index 17d5cf0..0000000 --- a/backend/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Api.DataTransferObjects.PlayLists; - -public record CreatePlayListRequest( - string Name, - string Description, - Guid? PictureIntentId); diff --git a/backend/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Musify.Api/Endpoints/UserEndpoints.cs deleted file mode 100644 index 7e47543..0000000 --- a/backend/Musify.Api/Endpoints/UserEndpoints.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Mediator; -using Musify.Application.Users; -using Musify.Application.Users.Responses; -using Musify.Application.Shared; -using Musify.Api.DataTransferObjects.Users; -using Musify.Api.Extensions; -using Musify.Api.Filters; - -namespace Musify.Api.Endpoints; - -public static class UserEndpoints -{ - public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup("/users") - .WithTags("Users"); - - group.MapGet("/", GetUsers) - .WithName("GetUsers") - .WithSummary("Get Paginated Users.") - .Produces>(); - - group.MapGet("/{id}", GetUserById) - .WithName("GetUserById") - .WithSummary("Get A User By Id.") - .Produces() - .Produces(StatusCodes.Status404NotFound); - - group.MapPost("/", CreateUser) - .WithName("CreateUser") - .WithSummary("Create A New User.") - .AddEndpointFilter>() - .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() - .Produces(StatusCodes.Status409Conflict); - - return app; - } - - private static async Task GetUsers( - IMediator mediator, - CancellationToken cancellationToken, - string? usernameSearch, - int pageNumber = 1, - int pageSize = 10) - { - var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task GetUserById( - IMediator mediator, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task CreateUser( - IMediator mediator, - CreateUserRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName), - cancellationToken); - - return result.ToCreatedResult(user => $"/users/{user.Id}"); - } -} diff --git a/backend/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs b/backend/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs deleted file mode 100644 index 682e374..0000000 --- a/backend/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentValidation; -using Musify.Api.DataTransferObjects.Tracks; - -namespace Musify.Api.Validators.Tracks; - -public sealed class CreateTrackRequestValidator : AbstractValidator -{ - public CreateTrackRequestValidator() - { - RuleFor(x => x.Title) - .NotEmpty() - .MaximumLength(50); - - RuleFor(x => x.PictureIntentId) - .NotEmpty(); - - RuleFor(x => x.AudioIntentId) - .NotEmpty(); - } -} diff --git a/backend/Musify.Application/PlayLists/GetPlayListById.cs b/backend/Musify.Application/PlayLists/GetPlayListById.cs deleted file mode 100644 index 7e658d3..0000000 --- a/backend/Musify.Application/PlayLists/GetPlayListById.cs +++ /dev/null @@ -1,29 +0,0 @@ -using ErrorOr; -using Microsoft.EntityFrameworkCore; -using Musify.Application.PlayLists.Responses; -using Mediator; -using Musify.Application.Contracts; - -namespace Musify.Application.PlayLists -{ - public record GetPlayListByIdQuery(Guid Id) - : IQuery>; - - public class GetPlayListByIdQueryHandler(IDatabase database) - : IQueryHandler> - { - public async ValueTask> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) - { - var playList = await database.PlayLists - .AsNoTracking() - .Where(p => p.Id == request.Id) - .Select(p => PlayListApplicationResponse.FromEntity(p)) - .SingleOrDefaultAsync(cancellationToken); - - if (playList is null) - return Error.NotFound(); - - return playList; - } - } -} diff --git a/backend/Musify.Application/PlayLists/GetPlayLists.cs b/backend/Musify.Application/PlayLists/GetPlayLists.cs deleted file mode 100644 index 70c1a8d..0000000 --- a/backend/Musify.Application/PlayLists/GetPlayLists.cs +++ /dev/null @@ -1,30 +0,0 @@ -using ErrorOr; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Shared; -using Musify.Application.PlayLists.Responses; -using X.PagedList.EF; -using Mediator; -using Musify.Application.Contracts; - -namespace Musify.Application.PlayLists -{ - public record GetPlayListsQuery(int PageNumber = 1, int PageSize = 10) - : IQuery> >; - - public class GetPlayListsQueryHandler(IDatabase database) - : IQueryHandler >> - { - public async ValueTask >> Handle(GetPlayListsQuery request, CancellationToken cancellationToken) - { - var totalCount = await database.PlayLists.CountAsync(cancellationToken); - - var pagedPlayLists = await database.PlayLists - .AsNoTracking() - .OrderBy(p => p.Id) - .Select(p => PlayListApplicationResponse.FromEntity(p)) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - return PaginatedResponse.FromPagedList(pagedPlayLists); - } - } -} diff --git a/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs deleted file mode 100644 index 4d21c1b..0000000 --- a/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ /dev/null @@ -1,43 +0,0 @@ -using ErrorOr; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Shared; -using Musify.Application.PlayLists.Responses; -using X.PagedList.EF; -using Mediator; -using Musify.Application.Contracts; - -namespace Musify.Application.PlayLists -{ - public record GetPlayListsByUserIdQuery( - long UserId, - string? Name, - int PageNumber = 1, - int PageSize = 10) - : IQuery> >; - - public class GetPlayListsByUserIdQueryHandler(IDatabase database) - : IQueryHandler > > - { - public async ValueTask >> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) - { - var playListsQuery = database.PlayLists - .AsNoTracking() - .Where(p => p.UserId == request.UserId); - - if (request.Name is not null) - { - var normalizedName = request.Name.Trim().ToUpperInvariant(); - playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName)); - } - - var totalCount = await playListsQuery.CountAsync(cancellationToken); - - var pagedPlayLists = await playListsQuery - .OrderBy(p => p.CreatedAt) - .Select(p => PlayListApplicationResponse.FromEntity(p)) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - return PaginatedResponse.FromPagedList(pagedPlayLists); - } - } -} diff --git a/backend/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs deleted file mode 100644 index 6a577b4..0000000 --- a/backend/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Musify.Domain.Entities; - -namespace Musify.Application.PlayLists.Responses -{ - public record PlayListApplicationResponse( - Guid Id, - string Name, - string Description, - string SmallImageKeyName, - string MediumImageKeyName, - string LargeImageKeyName, - DateTime CreatedAt, - DateTime UpdatedAt) - { - public static PlayListApplicationResponse FromEntity(PlayList playList) - { - return new PlayListApplicationResponse( - playList.Id, - playList.Name, - playList.Description, - playList.SmallPictureName, - playList.MediumPictureName, - playList.LargePictureName, - playList.CreatedAt, - playList.UpdatedAt); - } - } -} \ No newline at end of file diff --git a/backend/Musify.Application/Tracks/GetTracks.cs b/backend/Musify.Application/Tracks/GetTracks.cs deleted file mode 100644 index eb5e213..0000000 --- a/backend/Musify.Application/Tracks/GetTracks.cs +++ /dev/null @@ -1,31 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Shared; -using Musify.Application.Tracks.Responses; -using X.PagedList.EF; -using Musify.Application.Contracts; - -namespace Musify.Application.Tracks -{ - public record GetTracksQuery(int PageNumber = 1, int PageSize = 10) - : IQuery >>; - - public class GetTracksQueryHandler(IDatabase database) - : IQueryHandler> > - { - public async ValueTask >> Handle(GetTracksQuery request, CancellationToken cancellationToken) - { - var tracksQuery = database.UserHasTracks - .AsNoTracking() - .OrderBy(t => t.Id) - .Include(ut => ut.Track) - .Select(t => TrackApplicationResponse.FromEntity(t.Track)); - - var totalCount = await tracksQuery.CountAsync(cancellationToken); - var pagedTracks = await tracksQuery.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - return PaginatedResponse.FromPagedList(pagedTracks); - } - } -} diff --git a/backend/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs deleted file mode 100644 index 83d3fa7..0000000 --- a/backend/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Musify.Domain.Entities; - -namespace Musify.Application.Tracks.Responses -{ - public record TrackApplicationResponse( - Guid Id, - string Title, - DateTime CreatedAt, - DateTime UpdatedAt) - { - public static TrackApplicationResponse FromEntity(Track track) - { - return new TrackApplicationResponse( - track.Id, - track.Title, - track.CreatedAt, - track.UpdatedAt); - } - } -} \ No newline at end of file diff --git a/backend/Musify.Domain/Entities/Track.cs b/backend/Musify.Domain/Entities/Track.cs deleted file mode 100644 index 8299c3d..0000000 --- a/backend/Musify.Domain/Entities/Track.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using System.Diagnostics.CodeAnalysis; - -using Musify.Domain.Abstractions; -using Musify.Domain.ValueObjects; - -namespace Musify.Domain.Entities -{ -#pragma warning disable CS8618 - public class Track : IAuditable - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); - - [Required] - [MaxLength(50)] - public required string Title { get; set; } - - [Required] - [MaxLength(50)] - public required string NormalizedTitle { get; set; } - - [Required] - [MaxLength(64)] - public required string OriginalPictureName { get; set; } - - [MaxLength(64)] - public string? SmallPictureName { get; set; } - - [MaxLength(64)] - public string? MediumPictureName { get; set; } - - [MaxLength(64)] - public string? LargePictureName { get; set; } - - [Required] - [MaxLength(64)] - public required string OriginalAudioName { get; set; } - - [MaxLength(64)] - public string? AudioFolderName { get; set; } - - [Required] - public ProcessingStatus PicturesProcessingStatus { get; set; } = ProcessingStatus.Pending; - - [Required] - public ProcessingStatus AudioTranscodeProcessingStatus { get; set; } = ProcessingStatus.Pending; - - [Required] - public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - - [Required] - public int RetryCount { get; set; } = 0; - - public DateTime? LastRetryAt { get; set; } - - [Required] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - [Required] - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - - public ICollection UserTracks { get; set; } = new List(); - - public ICollection PlayListTracks { get; set; } = new List(); - - [NotMapped] - [MemberNotNullWhen(true, nameof(SmallPictureName)) ] - [MemberNotNullWhen(true, nameof(MediumPictureName)) ] - [MemberNotNullWhen(true, nameof(LargePictureName)) ] - public bool IsPicturesProcessed => PicturesProcessingStatus == ProcessingStatus.Completed; - - [NotMapped] - [MemberNotNullWhen(true, nameof(AudioFolderName)) ] - public bool IsAudioProcessed => AudioTranscodeProcessingStatus == ProcessingStatus.Completed; - } -} \ No newline at end of file diff --git a/backend/Musify.Domain/Musify.Domain.csproj b/backend/Musify.Domain/Musify.Domain.csproj deleted file mode 100644 index 61718a1..0000000 --- a/backend/Musify.Domain/Musify.Domain.csproj +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/backend/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs b/backend/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs deleted file mode 100644 index a634c2f..0000000 --- a/backend/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Logs -{ - public record UpdateTrackPictureLog( - Guid TrackId, - string PreviousOriginalPictureKey, - string? PreviousSmallPictureKey, - string? PreviousMediumPictureKey, - string? PreviousLargePictureKey); -} diff --git a/backend/Musify.Infrastructure/Persistence/Database.cs b/backend/Musify.Infrastructure/Persistence/Database.cs deleted file mode 100644 index e3dbc84..0000000 --- a/backend/Musify.Infrastructure/Persistence/Database.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; -using MassTransit; -using Musify.Domain.Entities; -using Musify.Infrastructure.Configuration; -using Musify.Infrastructure.MassTransit.Sagas; -using AppIDatabase = Musify.Application.Contracts.IDatabase; -using Musify.Application.Contracts; - -namespace Musify.Infrastructure.Persistence -{ - public class Database(DatabaseConfiguration configuration) - : DbContext, AppIDatabase - { - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(configuration.ConnectionString); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.AddInboxStateEntity(); - modelBuilder.AddOutboxMessageEntity(); - modelBuilder.AddOutboxStateEntity(); - - var trackProcessing = modelBuilder.Entity(); - trackProcessing.HasKey(state => state.CorrelationId); - trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); - trackProcessing.Property(state => state.CurrentState).HasMaxLength(64); - - var playListProcessing = modelBuilder.Entity(); - playListProcessing.HasKey(state => state.CorrelationId); - playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); - playListProcessing.Property(state => state.CurrentState).HasMaxLength(64); - } - - public DbSet Users => Set(); - - public DbSet Tracks => Set(); - - public DbSet PlayLists => Set(); - - public DbSet UserHasTracks => Set(); - - public DbSet PlayListHasTracks => Set(); - - public DbSet Uploads => Set(); - - public DbSet UploadIntents => Set(); - - public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) - { - var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); - return new DatabaseTransaction(transaction); - } - - private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction - { - public Task CommitAsync(CancellationToken cancellationToken = default) => - inner.CommitAsync(cancellationToken); - - public ValueTask DisposeAsync() => inner.DisposeAsync(); - } - } -} diff --git a/backend/Musify.Migrator.Dockerfile b/backend/Musify.Migrator.Dockerfile new file mode 100644 index 0000000..d7a1c70 --- /dev/null +++ b/backend/Musify.Migrator.Dockerfile @@ -0,0 +1,25 @@ +# Build context: backend/ (docker compose -f deploy/compose.yml up migrate) +# +# Applies EF Core migrations, then exits. Musify.Infrastructure is both the +# migrations and the design-time startup project: it owns the +# IDesignTimeDbContextFactory and the EFCore.Design package. The connection +# string comes from the Database__ConnectionString env var (compose sets it), +# read via DatabaseDesignTimeFactory's AddEnvironmentVariables(). +FROM mcr.microsoft.com/dotnet/sdk:10.0 +WORKDIR /src + +COPY Directory.Build.props Directory.Packages.props nuget.config ./ +COPY Core/Musify.Domain/Domain.csproj Core/Musify.Domain/ +COPY Core/Musify.Application/Application.csproj Core/Musify.Application/ +COPY Core/Musify.Infrastructure/Infrastructure.csproj Core/Musify.Infrastructure/ +RUN dotnet restore Core/Musify.Infrastructure/Infrastructure.csproj + +COPY Core/Musify.Domain Core/Musify.Domain/ +COPY Core/Musify.Application Core/Musify.Application/ +COPY Core/Musify.Infrastructure Core/Musify.Infrastructure/ + +RUN dotnet tool install --global dotnet-ef --version 10.0.8 +ENV PATH="$PATH:/root/.dotnet/tools" + +WORKDIR /src/Core/Musify.Infrastructure +ENTRYPOINT ["dotnet", "ef", "database", "update", "--context", "Database"] diff --git a/backend/Musify.StreamingGateway.Dockerfile b/backend/Musify.StreamingGateway.Dockerfile index 1e20496..9da3ab5 100644 --- a/backend/Musify.StreamingGateway.Dockerfile +++ b/backend/Musify.StreamingGateway.Dockerfile @@ -3,11 +3,11 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY Directory.Build.props Directory.Packages.props nuget.config ./ -COPY Musify.StreamingGateway/Musify.StreamingGateway.csproj Musify.StreamingGateway/ -RUN dotnet restore Musify.StreamingGateway/Musify.StreamingGateway.csproj +COPY Hosts/Musify.StreamingGateway/StreamingGateway.csproj Hosts/Musify.StreamingGateway/ +RUN dotnet restore Hosts/Musify.StreamingGateway/StreamingGateway.csproj COPY . . -RUN dotnet publish Musify.StreamingGateway/Musify.StreamingGateway.csproj -c Release -o /app --no-restore +RUN dotnet publish Hosts/Musify.StreamingGateway/StreamingGateway.csproj -c Release -o /app --no-restore FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app diff --git a/backend/Musify.StreamingGateway/appsettings.json b/backend/Musify.StreamingGateway/appsettings.json deleted file mode 100644 index c382627..0000000 --- a/backend/Musify.StreamingGateway/appsettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Yarp": "Information" - } - }, - "AllowedHosts": "*" -} diff --git a/backend/Musify.Worker.Dockerfile b/backend/Musify.Worker.Dockerfile index b65f627..b254969 100644 --- a/backend/Musify.Worker.Dockerfile +++ b/backend/Musify.Worker.Dockerfile @@ -3,23 +3,23 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY Directory.Build.props Directory.Packages.props nuget.config ./ -COPY Musify.Domain/Musify.Domain.csproj Musify.Domain/ -COPY Musify.Application/Musify.Application.csproj Musify.Application/ -COPY Musify.Infrastructure/Musify.Infrastructure.csproj Musify.Infrastructure/ -COPY Musify.Worker/Musify.Worker.csproj Musify.Worker/ -RUN dotnet restore Musify.Worker/Musify.Worker.csproj +COPY Core/Musify.Domain/Domain.csproj Core/Musify.Domain/ +COPY Core/Musify.Application/Application.csproj Core/Musify.Application/ +COPY Core/Musify.Infrastructure/Infrastructure.csproj Core/Musify.Infrastructure/ +COPY Hosts/Musify.Worker/Worker.csproj Hosts/Musify.Worker/ +RUN dotnet restore Hosts/Musify.Worker/Worker.csproj COPY . . -RUN dotnet publish Musify.Worker/Musify.Worker.csproj -c Release -o /app --no-restore +RUN dotnet publish Hosts/Musify.Worker/Worker.csproj -c Release -o /app --no-restore # AppSettings*.json (PascalCase) -> lowercase copies for case-sensitive Linux. RUN cp /app/AppSettings.json /app/appsettings.json \ && cp /app/AppSettings.Development.json /app/appsettings.Development.json FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app -# ffmpeg: the Worker transcodes audio to .m4a. +# ffmpeg: the Worker transcodes uploaded audio to .m4a. RUN apt-get update \ - && apt-get install -y --no-install-recommends ffmpeg \ + && 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/Musify.Worker/AppSettings.Development.json b/backend/Musify.Worker/AppSettings.Development.json deleted file mode 100644 index 88a9ac0..0000000 --- a/backend/Musify.Worker/AppSettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" - } - } -} diff --git a/backend/Musify.slnx b/backend/Musify.slnx index bb79146..db6c6b3 100644 --- a/backend/Musify.slnx +++ b/backend/Musify.slnx @@ -1,12 +1,19 @@ - - - + + + - - - + + + + + + + + + + diff --git a/backend/Tests/Musify.Api.Tests/Api.Tests.csproj b/backend/Tests/Musify.Api.Tests/Api.Tests.csproj new file mode 100644 index 0000000..5835725 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Api.Tests.csproj @@ -0,0 +1,35 @@ + + + + Musify.Api.Tests + Musify.Api.Tests + false + true + Exe + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs new file mode 100644 index 0000000..aecba63 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs @@ -0,0 +1,85 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Musify.Api.Authentication; +using Xunit; + +namespace Musify.Api.Tests.Authentication +{ + public sealed class CurrentUserTests + { + private static async Task BindAsync(ClaimsPrincipal principal) + { + var context = new DefaultHttpContext { User = principal }; + var bound = await CurrentUser.BindAsync(context, parameter: null!); + return bound!; + } + + private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) => + new(new ClaimsIdentity(claims, authenticationType: "TestAuth")); + + [Fact] + public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField() + { + var principal = AuthenticatedPrincipal( + new Claim(ClaimTypes.NameIdentifier, "42"), + new Claim(ClaimTypes.Name, "jdoe"), + new Claim(ClaimTypes.GivenName, "Jane"), + new Claim(ClaimTypes.Surname, "Doe")); + + var user = await BindAsync(principal); + + Assert.True(user.IsAuthenticated); + Assert.Equal(42, user.Id); + Assert.Equal(42, user.RequiredId); + Assert.Equal("jdoe", user.Username); + Assert.Equal("Jane", user.FirstName); + Assert.Equal("Doe", user.LastName); + } + + [Fact] + public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId() + { + var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); + + Assert.False(user.IsAuthenticated); + Assert.Null(user.Id); + } + + [Theory] + [InlineData("not-a-number")] + [InlineData("0")] + [InlineData("")] + public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId) + { + var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId))); + + Assert.Null(user.Id); + } + + [Fact] + public async Task RequiredId_MissingId_Throws() + { + var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); + + Assert.Throws(() => user.RequiredId); + } + + [Fact] + public async Task HasClaim_ExistingClaimType_ReturnsTrue() + { + var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin"))); + + Assert.True(user.HasClaim("role")); + Assert.False(user.HasClaim("missing")); + } + + [Fact] + public async Task FindClaim_ExistingClaimType_ReturnsItsValue() + { + var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png"))); + + Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture")); + Assert.Null(user.FindClaim("missing")); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs new file mode 100644 index 0000000..9230f5b --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -0,0 +1,136 @@ +using System.Net; +using System.Net.Http.Json; +using Musify.Api.DataTransferObjects.Albums; +using Musify.Api.DataTransferObjects.Users; +using Musify.Api.Tests.TestSupport; +using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; +using NSubstitute; +using Xunit; + +namespace Musify.Api.Tests.Endpoints +{ + [Collection(ApiCollection.Name)] + public sealed class AlbumEndpointsTests(ApiTestFixture fixture) + { + private async Task CreateUserAsync() + { + var userId = Random.Shared.NextInt64(1, long.MaxValue); + var response = await fixture.CreateAnonymousClient() + .PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null)); + response.EnsureSuccessStatusCode(); + return userId; + } + + private async Task CreatePictureIntentAsync(HttpClient client) + { + fixture.StorageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); + fixture.StorageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); + + var response = await client.PostAsJsonAsync( + "/albums/upload-picture", + new RequestAlbumPictureUploadRequest("webp", "image/webp"), + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + return body!.IntentId; + } + + [Fact] + public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient() + .PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var pictureIntentId = await CreatePictureIntentAsync(client); + + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(body); + Assert.Equal("My Album", body.Title); + Assert.Equal(userId, body.OwnerUserId); + } + + [Fact] + public async Task PostAlbums_BlankTitle_ReturnsValidationProblem() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostAlbums_MissingPictureIntent_ReturnsValidationProblem() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("No Picture", null, null, Guid.Empty), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetAlbumById_AfterCreate_ReturnsIt() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var pictureIntentId = await CreatePictureIntentAsync(client); + var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) + .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized() + { + var owner = await CreateUserAsync(); + var stranger = await CreateUserAsync(); + var ownerClient = fixture.CreateAuthenticatedClient(owner); + var pictureIntentId = await CreatePictureIntentAsync(ownerClient); + var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken)) + .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var pictureIntentId = await CreatePictureIntentAsync(client); + var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) + .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); + var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs new file mode 100644 index 0000000..41ec394 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs @@ -0,0 +1,25 @@ +using System.Net; +using System.Net.Http.Json; +using Musify.Api.Tests.TestSupport; +using Musify.Application.Configuration.Responses; +using Xunit; + +namespace Musify.Api.Tests.Endpoints +{ + [Collection(ApiCollection.Name)] + public sealed class ConfigEndpointsTests(ApiTestFixture fixture) + { + [Fact] + public async Task GetPlaybackConfig_WithoutAuthentication_ReturnsConfiguredValues() + { + var client = fixture.CreateAnonymousClient(); + + var response = await client.GetAsync("/config/playback", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(body); + Assert.False(body.AllowAnonymousListening); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs new file mode 100644 index 0000000..e398f2e --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs @@ -0,0 +1,136 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Musify.Api.DataTransferObjects.Users; +using Musify.Api.Tests.TestSupport; +using Musify.Application.Tracks.Responses; +using Musify.Application.Users.Responses; +using Musify.Domain.Entities; +using Musify.Infrastructure.Persistence; +using Xunit; + +namespace Musify.Api.Tests.Endpoints +{ + [Collection(ApiCollection.Name)] + public sealed class UserEndpointsTests(ApiTestFixture fixture) + { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + private async Task SeedListenAsync(long userId, string trackTitle, DateTime listenedAt) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var owner = await database.Users.SingleAsync(u => u.Id == userId); + var track = new Track + { + Title = trackTitle, + NormalizedTitle = trackTitle.ToUpperInvariant(), + OwnerUserId = owner.Id, + Owner = owner + }; + database.Tracks.Add(track); + database.ListeningHistories.Add(new ListeningHistory + { + UserId = userId, + TrackId = track.Id, + ListenedAt = listenedAt + }); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + + var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Jane Doe", "Jane", "Doe"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(body); + Assert.Equal("Jane Doe", body.Name); + } + + [Fact] + public async Task PostUsers_BlankName_ReturnsValidationProblem() + { + var client = fixture.CreateAnonymousClient(); + + var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostUsers_DuplicateId_ReturnsConflict() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null), TestContext.Current.CancellationToken); + + var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task GetUserById_ExistingUser_ReturnsIt() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), TestContext.Current.CancellationToken); + + var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("Findable User", body?.Name); + } + + [Fact] + public async Task GetUserById_MissingUser_ReturnsNotFound() + { + var client = fixture.CreateAnonymousClient(); + + var response = await client.GetAsync($"/users/{Random.Shared.NextInt64(1, long.MaxValue)}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetLastTrackListenedByUserId_HasHistory_ReturnsMostRecentTrack() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); + await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10)); + await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow); + + var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); + Assert.Equal("Newer listen", body?.Title); + } + + [Fact] + public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNotFound() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); + + var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs b/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs new file mode 100644 index 0000000..773c1ca --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs @@ -0,0 +1,136 @@ +using ErrorOr; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Musify.Api.Extensions; +using Xunit; + +namespace Musify.Api.Tests.Extensions +{ + public sealed class ErrorOrHttpExtensionsTests + { + private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider(); + + private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result) + { + var context = new DefaultHttpContext { RequestServices = Services }; + var bodyStream = new MemoryStream(); + context.Response.Body = bodyStream; + + await result.ExecuteAsync(context); + + bodyStream.Position = 0; + using var reader = new StreamReader(bodyStream); + return (context.Response.StatusCode, await reader.ReadToEndAsync()); + } + + [Fact] + public async Task ToHttpResult_Success_ReturnsNoContent() + { + ErrorOr result = new Success(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status204NoContent, statusCode); + } + + [Fact] + public async Task ToHttpResult_Value_ReturnsOkWithTheValue() + { + ErrorOr result = "hello"; + + var (statusCode, body) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status200OK, statusCode); + Assert.Contains("hello", body); + } + + [Fact] + public async Task ToHttpResult_NotFoundError_ReturnsNotFound() + { + ErrorOr result = Error.NotFound(description: "not here"); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status404NotFound, statusCode); + } + + [Fact] + public async Task ToHttpResult_UnauthorizedError_ReturnsUnauthorized() + { + ErrorOr result = Error.Unauthorized(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status401Unauthorized, statusCode); + } + + [Fact] + public async Task ToHttpResult_ForbiddenError_ReturnsForbidden() + { + ErrorOr result = Error.Forbidden(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status403Forbidden, statusCode); + } + + [Fact] + public async Task ToHttpResult_ConflictError_ReturnsConflict() + { + ErrorOr result = Error.Conflict(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status409Conflict, statusCode); + } + + [Fact] + public async Task ToHttpResult_FailureError_ReturnsProblem500() + { + ErrorOr result = Error.Failure(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status500InternalServerError, statusCode); + } + + [Fact] + public async Task ToHttpResult_ValidationErrors_ReturnsValidationProblem() + { + ErrorOr result = new List + { + Error.Validation("Name", "Name is required"), + Error.Validation("Name", "Name is too long"), + Error.Validation("Age", "Age must be positive"), + }; + + var (statusCode, body) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status400BadRequest, statusCode); + Assert.Contains("Name is required", body); + Assert.Contains("Age must be positive", body); + } + + [Fact] + public async Task ToCreatedResult_Success_ReturnsCreatedAtTheGivenLocation() + { + ErrorOr result = "the-id"; + + var context = new DefaultHttpContext { RequestServices = Services, Response = { Body = new MemoryStream() } }; + await result.ToCreatedResult(value => $"/things/{value}").ExecuteAsync(context); + + Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode); + Assert.Equal("/things/the-id", context.Response.Headers.Location); + } + + [Fact] + public async Task ToCreatedResult_Error_ReturnsTheMappedErrorStatus() + { + ErrorOr result = Error.Conflict(); + + var (statusCode, _) = await ExecuteAsync(result.ToCreatedResult(value => $"/things/{value}")); + + Assert.Equal(StatusCodes.Status409Conflict, statusCode); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs b/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs new file mode 100644 index 0000000..8b16cfe --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs @@ -0,0 +1,62 @@ +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Musify.Api.Filters; +using NSubstitute; +using Xunit; + +namespace Musify.Api.Tests.Filters +{ + public sealed class ValidationFilterTests + { + public sealed record Payload(string Name); + + private static EndpointFilterInvocationContext CreateContext(T argument) => + EndpointFilterInvocationContext.Create(new DefaultHttpContext(), argument); + + [Fact] + public async Task InvokeAsync_ValidPayload_CallsNext() + { + var validator = Substitute.For>(); + validator.ValidateAsync(Arg.Any(), Arg.Any()).Returns(new ValidationResult()); + var filter = new ValidationFilter(validator); + var context = CreateContext(new Payload("ok")); + + var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); + + Assert.Equal("next-called", result); + await validator.Received(1).ValidateAsync(Arg.Is(p => p.Name == "ok"), Arg.Any()); + } + + [Fact] + public async Task InvokeAsync_InvalidPayload_ReturnsValidationProblemWithoutCallingNext() + { + var validator = Substitute.For>(); + validator.ValidateAsync(Arg.Any(), Arg.Any()) + .Returns(new ValidationResult([new ValidationFailure("Name", "Name is required")])); + var filter = new ValidationFilter(validator); + var context = CreateContext(new Payload("")); + var nextCalled = false; + + var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult(null); }); + + Assert.False(nextCalled); + var problem = Assert.IsType(result); + Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); + } + + [Fact] + public async Task InvokeAsync_NoMatchingArgument_CallsNextWithoutValidating() + { + var validator = Substitute.For>(); + var filter = new ValidationFilter(validator); + var context = CreateContext(42); + + var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); + + Assert.Equal("next-called", result); + await validator.DidNotReceiveWithAnyArgs().ValidateAsync(Arg.Any(), Arg.Any()); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs new file mode 100644 index 0000000..c0272af --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Musify.Application.Contracts; +using NSubstitute; +using Testcontainers.PostgreSql; +using Xunit; + +namespace Musify.Api.Tests.TestSupport +{ + public sealed class ApiTestFixture : WebApplicationFactory, IAsyncLifetime + { + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("musify_api_test") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + public IStorageService StorageService { get; } = Substitute.For(); + + public IEventBus EventBus { get; } = Substitute.For(); + + public IStreamTicketService StreamTicketService { get; } = Substitute.For(); + + public async ValueTask InitializeAsync() + { + await postgres.StartAsync(); + + using var scope = Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + await database.Database.MigrateAsync(); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + await postgres.DisposeAsync(); + } + + public HttpClient CreateAnonymousClient() => CreateClient(); + + public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user") + { + var client = CreateClient(); + client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString()); + client.DefaultRequestHeaders.Add("X-Test-User-Name", userName); + return client; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Database:ConnectionString"] = postgres.GetConnectionString(), + ["Authentication:MetadataAddress"] = "https://auth.musify.test/.well-known/openid-configuration", + ["Authentication:IssuerAddress"] = "https://auth.musify.test", + ["Authentication:AudienceAddress"] = "musify-api-test", + ["Authentication:ClientId"] = "musify-api-test", + ["Authentication:AuthorizationEndpoint"] = "https://auth.musify.test/authorize", + ["Authentication:TokenEndpoint"] = "https://auth.musify.test/token", + ["Authentication:Scopes:0"] = "openid", + ["StreamTicket:PrivateKeyPath"] = "unused-in-tests.pem", + ["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317", + ["MassTransit:Address"] = "rabbitmq://localhost:5672", + ["MassTransit:Username"] = "guest", + ["MassTransit:Password"] = "guest", + ["InfrastructureStorage:Address"] = "http://localhost:8333", + ["InfrastructureStorage:AccessKey"] = "unused-in-tests", + ["InfrastructureStorage:SecretAccessKey"] = "unused-in-tests", + ["InfrastructureStorage:ForcePathStyle"] = "true", + ["InfrastructureStorage:UseHttp"] = "true", + ["ApplicationStorage:Bucket"] = "musify-api-test", + ["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test", + }); + }); + + builder.ConfigureTestServices(services => + { + services.Replace(ServiceDescriptor.Singleton(StorageService)); + services.Replace(ServiceDescriptor.Scoped(_ => EventBus)); + services.Replace(ServiceDescriptor.Singleton(StreamTicketService)); + + services + .AddAuthentication(FakeAuthenticationHandler.SchemeName) + .AddScheme(FakeAuthenticationHandler.SchemeName, _ => { }); + }); + } + } + + [CollectionDefinition(Name)] + public sealed class ApiCollection : ICollectionFixture + { + public const string Name = "Api"; + } +} diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs b/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs new file mode 100644 index 0000000..497bc7b --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs @@ -0,0 +1,32 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Musify.Api.Tests.TestSupport +{ + public sealed class FakeAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) + { + public const string SchemeName = "Test"; + public const string UserIdHeader = "X-Test-User-Id"; + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId)) + return Task.FromResult(AuthenticateResult.NoResult()); + + var claims = new List { new(ClaimTypes.NameIdentifier, userId!) }; + if (Request.Headers.TryGetValue("X-Test-User-Name", out var userName) && !string.IsNullOrEmpty(userName)) + claims.Add(new Claim(ClaimTypes.Name, userName!)); + + var identity = new ClaimsIdentity(claims, SchemeName); + var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs new file mode 100644 index 0000000..c2d3ee1 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs @@ -0,0 +1,85 @@ +using Musify.Api.DataTransferObjects.Albums; +using Musify.Api.Validators.Albums; +using Xunit; + +namespace Musify.Api.Tests.Validators +{ + public sealed class CreateAlbumRequestValidatorTests + { + private readonly CreateAlbumRequestValidator validator = new(); + + [Fact] + public void Validate_ValidRequest_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024, Guid.NewGuid())); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_ValidRequestWithoutOptionalFields_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("My Album", null, null, Guid.NewGuid())); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_BlankTitle_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("", null, null, Guid.NewGuid())); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title)); + } + + [Fact] + public void Validate_TitleOverMaxLength_Fails() + { + var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null, Guid.NewGuid())); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1, Guid.NewGuid())); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_ReleaseYearAtTheEarliestBoundary_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest, Guid.NewGuid())); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_ReleaseYearAtTheLatestBoundary_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest, Guid.NewGuid())); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_ReleaseYearPastTheLatestBoundary_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1, Guid.NewGuid())); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_EmptyPictureIntentId_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, null, Guid.Empty)); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.PictureIntentId)); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs new file mode 100644 index 0000000..fffb0bf --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs @@ -0,0 +1,52 @@ +using Musify.Api.DataTransferObjects.Users; +using Musify.Api.Validators.Users; +using Xunit; + +namespace Musify.Api.Tests.Validators +{ + public sealed class CreateUserRequestValidatorTests + { + private readonly CreateUserRequestValidator validator = new(); + + [Fact] + public void Validate_ValidRequest_Passes() + { + var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe")); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_ZeroId_Fails() + { + var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null)); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id)); + } + + [Fact] + public void Validate_BlankName_Fails() + { + var result = validator.Validate(new CreateUserRequest(1, "", null, null)); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_NameOverMaxLength_Fails() + { + var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null)); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_OptionalNamesOmitted_Passes() + { + var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null)); + + Assert.True(result.IsValid); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs new file mode 100644 index 0000000..3d78fd9 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs @@ -0,0 +1,112 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase + { + private AddTrackToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_FirstTrack_AddsItAtNumberOne() + { + var owner = TestEntities.User(); + 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); + + Assert.False(result.IsError); + var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal(1, link.TrackNumber); + } + + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + 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); + + 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 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); + + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } + + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + 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); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_TrackOwnedByAnotherUser_ReturnsUnauthorized() + { + var owner = TestEntities.User(1, "owner"); + var otherOwner = TestEntities.User(2, "other"); + 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); + + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } + + [Fact] + public async Task Handle_AlreadyInAlbum_ReturnsConflict() + { + var owner = TestEntities.User(); + 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); + + var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } + + [Fact] + public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber() + { + var owner = TestEntities.User(); + 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); + + var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, secondTrack.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken); + Assert.Equal(4, newLink.TrackNumber); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs new file mode 100644 index 0000000..b242565 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs @@ -0,0 +1,77 @@ +using ErrorOr; +using Musify.Application.Albums; +using Musify.Application.Contracts; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase + { + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); + + private CreateAlbumCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.Album(), + TestConfigurations.UploadIntent()); + + [Fact] + public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); + + var owner = TestEntities.User(); + var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "cover.webp"); + await SeedAsync(owner, intent); + + var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024, intent.Id); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("My Album", result.Value.Title); + Assert.Equal("A description", result.Value.Description); + Assert.Equal(2024, result.Value.ReleaseYear); + Assert.Equal(owner.Id, result.Value.OwnerUserId); + Assert.Equal(0, result.Value.TrackCount); + + 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); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_UserDoesNotExist_ReturnsNotFound() + { + var command = new CreateAlbumCommand(404, "Orphan Album", null, null, Guid.NewGuid()); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_PictureIntentNotFound_ReturnsError() + { + var owner = TestEntities.User(); + await SeedAsync(owner); + + var command = new CreateAlbumCommand(owner.Id, "My Album", null, null, Guid.NewGuid()); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs new file mode 100644 index 0000000..6d2a371 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs @@ -0,0 +1,47 @@ +using ErrorOr; +using Musify.Application.Albums; +using Musify.Application.Tests.TestSupport; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase + { + private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_Owner_RemovesAlbum() + { + var owner = TestEntities.User(); + 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.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_NotOwner_ReturnsUnauthorizedAndKeepsAlbum() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + 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.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 new file mode 100644 index 0000000..bb21b98 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs @@ -0,0 +1,37 @@ +using ErrorOr; +using Musify.Application.Albums; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class GetAlbumByIdQueryHandlerTests : HandlerTestBase + { + private GetAlbumByIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers() + { + var owner = TestEntities.User(); + 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); + + Assert.False(result.IsError); + Assert.Equal(album.Id, result.Value.Id); + Assert.Equal(1, result.Value.TrackCount); + Assert.Equal([track.Id], result.Value.CoverTrackIds); + } + + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetAlbumByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs new file mode 100644 index 0000000..4a7610a --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs @@ -0,0 +1,38 @@ +using ErrorOr; +using Musify.Application.Albums; +using Musify.Application.Tests.TestSupport; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class GetAlbumCoverQueryHandlerTests : HandlerTestBase + { + private GetAlbumCoverQueryHandler CreateHandler() => + new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Album()); + + [Theory] + [InlineData("small", "small.webp")] + [InlineData("large", "large.webp")] + [InlineData("medium", "medium.webp")] + public async Task Handle_ExistingAlbum_ReturnsRequestedSize(string size, string expectedFileName) + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, album); + + var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, size), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("covers-bucket", result.Value.Bucket); + Assert.EndsWith(expectedFileName, result.Value.Key); + } + + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetAlbumCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs new file mode 100644 index 0000000..955d7ab --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs @@ -0,0 +1,39 @@ +using ErrorOr; +using Musify.Application.Albums; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class GetAlbumTracksQueryHandlerTests : HandlerTestBase + { + private GetAlbumTracksQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber() + { + var owner = TestEntities.User(); + 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 }, + new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 }); + + var result = await CreateHandler().Handle(new GetAlbumTracksQuery(album.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); + } + + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetAlbumTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs new file mode 100644 index 0000000..93be0ed --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs @@ -0,0 +1,34 @@ +using ErrorOr; +using Musify.Application.Albums; +using Musify.Application.Tests.TestSupport; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class GetAlbumsByUserIdQueryHandlerTests : HandlerTestBase + { + private GetAlbumsByUserIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_UserWithAlbums_ReturnsOnlyTheirAlbums() + { + var owner = TestEntities.User(1, "owner"); + var other = TestEntities.User(2, "other"); + 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); + + Assert.False(result.IsError); + var album = Assert.Single(result.Value.Items); + Assert.Equal("Mine", album.Title); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(404, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs new file mode 100644 index 0000000..42f2484 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs @@ -0,0 +1,50 @@ +using Musify.Application.Albums; +using Musify.Application.Tests.TestSupport; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase + { + private GetAlbumsQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged() + { + var owner = TestEntities.User(); + 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)); + } + + [Fact] + public async Task Handle_TitleFilter_ReturnsMatchingAlbumsOnly() + { + var owner = TestEntities.User(); + 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.Equal("Rock Classics", item.Album.Title); + } + + [Fact] + public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage() + { + var owner = TestEntities.User(); + 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); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.Items.Count); + Assert.True(result.Value.HasNextPage); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs new file mode 100644 index 0000000..8898711 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs @@ -0,0 +1,61 @@ +using Musify.Application.Albums; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase + { + private GetRecentlyListenedAlbumsQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst() + { + var owner = TestEntities.User(); + 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 }, + new AlbumHasTrack { AlbumId = newerAlbum.Id, TrackId = newerTrack.Id, TrackNumber = 1 }, + TestEntities.ListeningHistory(owner.Id, olderTrack.Id, DateTime.UtcNow.AddDays(-2)), + TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1))); + + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(["Newer", "Older"], result.Value.Select(album => album.Title)); + } + + [Fact] + public async Task Handle_NoListeningHistory_ReturnsEmptyList() + { + var owner = TestEntities.User(); + await SeedAsync(owner); + + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Empty(result.Value); + } + + [Fact] + public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums() + { + var owner = TestEntities.User(); + 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]); + + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.Count); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs new file mode 100644 index 0000000..62516f2 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs @@ -0,0 +1,73 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase + { + private RemoveTrackFromAlbumCommandHandler CreateHandler() => + new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest() + { + var owner = TestEntities.User(); + 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 }, + new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 }, + new AlbumHasTrack { AlbumId = album.Id, TrackId = third.Id, TrackNumber = 3 }); + + var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, second.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var remaining = await Database.AlbumHasTracks + .Where(link => link.AlbumId == album.Id) + .OrderBy(link => link.TrackNumber) + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId)); + Assert.Equal([1, 2], remaining.Select(link => link.TrackNumber)); + } + + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(1, Guid.NewGuid(), Guid.NewGuid()), 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 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); + + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } + + [Fact] + public async Task Handle_TrackNotInAlbum_ReturnsNotFound() + { + var owner = TestEntities.User(); + 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); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs new file mode 100644 index 0000000..4c0a83c --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs @@ -0,0 +1,75 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBase + { + private readonly IStorageService storageService = Substitute.For(); + + private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + { + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); + + return new RequestAlbumPictureUploadCommandHandler( + Database, + new UploadIntentValidator(Database, storageService), + storageService, + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.Album(), + uploadIntentConfig ?? TestConfigurations.UploadIntent()); + } + + [Fact] + public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp"); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl); + Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new RequestAlbumPictureUploadCommand(404, "webp", "image/webp"); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadBytesPerUser = 100; + + var user = TestEntities.User(); + await SeedAsync(user); + + var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000); + + var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs new file mode 100644 index 0000000..3d304d0 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -0,0 +1,94 @@ +using ErrorOr; +using Musify.Application.Albums; +using Musify.Application.Contracts; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase + { + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); + + private UpdateAlbumCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.Album(), + TestConfigurations.UploadIntent()); + + [Fact] + public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() + { + var owner = TestEntities.User(); + 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); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("New Title", result.Value.Title); + Assert.Equal("New description", result.Value.Description); + Assert.Equal(2025, result.Value.ReleaseYear); + + var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("NEW TITLE", stored.NormalizedTitle); + } + + [Fact] + public async Task Handle_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); + + var owner = TestEntities.User(); + 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); + + var command = new UpdateAlbumCommand(owner.Id, album.Id, "Old Title", null, null, intent.Id); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + 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()); + } + + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null, null); + + var result = await CreateHandler().Handle(command, 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 album = TestEntities.Album(owner.Id); + await SeedAsync(owner, stranger, album); + + var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Application.Tests.csproj b/backend/Tests/Musify.Application.Tests/Application.Tests.csproj new file mode 100644 index 0000000..d6f7af5 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Application.Tests.csproj @@ -0,0 +1,32 @@ + + + + Musify.Application.Tests + Musify.Application.Tests + false + true + Exe + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs b/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs new file mode 100644 index 0000000..fb11260 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs @@ -0,0 +1,31 @@ +using Musify.Application.Configuration; +using Musify.Application.Tests.TestSupport; +using Xunit; + +namespace Musify.Application.Tests.Configuration +{ + public sealed class GetPlaybackPublicConfigTests + { + [Fact] + public async Task Handle_ReflectsPlaybackConfiguration() + { + var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + var handler = new GetPlaybackPublicConfigQueryHandler(playback); + + var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken); + + Assert.True(response.AllowAnonymousListening); + Assert.Equal(30, response.AnonymousFragmentSeconds); + } + + [Fact] + public async Task Handle_DefaultsToDisabled() + { + var handler = new GetPlaybackPublicConfigQueryHandler(TestConfigurations.Playback()); + + var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken); + + Assert.False(response.AllowAnonymousListening); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs new file mode 100644 index 0000000..158ee08 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs @@ -0,0 +1,55 @@ +using Musify.Application.Likes; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Likes +{ + public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase + { + private GetLikedTracksQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_MultipleLikes_ReturnsMostRecentFirst() + { + var owner = TestEntities.User(); + 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) }, + new TrackLike { UserId = owner.Id, TrackId = newer.Id, CreatedAt = DateTime.UtcNow }); + + var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(["Newer like", "Older like"], result.Value.Items.Select(track => track.Title)); + } + + [Fact] + public async Task Handle_NoLikes_ReturnsEmpty() + { + var owner = TestEntities.User(); + await SeedAsync(owner); + + var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Empty(result.Value.Items); + } + + [Fact] + public async Task Handle_OtherUsersLikes_AreExcluded() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + 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); + + Assert.False(result.IsError); + Assert.Empty(result.Value.Items); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs new file mode 100644 index 0000000..f03b174 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs @@ -0,0 +1,56 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Likes; +using Musify.Application.Tests.TestSupport; +using Xunit; + +namespace Musify.Application.Tests.Likes +{ + public sealed class ToggleTrackLikeCommandHandlerTests : HandlerTestBase + { + private ToggleTrackLikeCommandHandler CreateHandler() => + new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_TrackNotLiked_LikesIt() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.True(result.Value); + Assert.Single(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_TrackAlreadyLiked_UnlikesIt() + { + var owner = TestEntities.User(); + 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), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.False(result.Value); + Assert.Empty(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + await SeedAsync(owner); + + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs new file mode 100644 index 0000000..7a3a8a4 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Mixes; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Mixes +{ + public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase + { + private GenerateMixesForUserCommandHandler CreateHandler() => new( + Database, TestConfigurations.Mix(), NoOpLogger()); + + [Fact] + public async Task Handle_NoTracks_DoesNotCreateAnyMix() + { + var user = TestEntities.User(); + await SeedAsync(user); + + 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)); + } + + [Fact] + public async Task Handle_AllTracksAlreadyListenedTo_CreatesOnlyTheDailyMix() + { + var user = TestEntities.User(); + 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); + 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_UnheardTracksInLibrary_CreatesDiscoveryAndDailyMixes_AndReplacesPreviousMixes() + { + var user = TestEntities.User(); + 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, listenedTrack.Id); + + await SeedAsync( + 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.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)); + + 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 new file mode 100644 index 0000000..97ba344 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs @@ -0,0 +1,50 @@ +using ErrorOr; +using Musify.Application.Mixes; +using Musify.Application.Tests.TestSupport; +using Xunit; + +namespace Musify.Application.Tests.Mixes +{ + public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase + { + private GetMixByIdQueryHandler CreateHandler() => new(Database); + + [Fact] + 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, 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); + + Assert.False(result.IsError); + Assert.Equal("Discovery", result.Value.Title); + Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title)); + } + + [Fact] + public async Task Handle_MixMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetMixByIdQuery(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_MixBelongsToAnotherUser_ReturnsNotFound() + { + var owner = TestEntities.User(1, "owner"); + var mix = TestEntities.Mix(owner.Id); + await SeedAsync(owner, mix); + + var result = await CreateHandler().Handle(new GetMixByIdQuery(2, mix.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs new file mode 100644 index 0000000..d5ec0d9 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs @@ -0,0 +1,39 @@ +using Musify.Application.Mixes; +using Musify.Application.Tests.TestSupport; +using Xunit; + +namespace Musify.Application.Tests.Mixes +{ + public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase + { + private GetMixesByUserIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ReturnsOnlyThatUsersMixesOrderedByPosition() + { + var owner = TestEntities.User(1, "owner"); + var other = TestEntities.User(2, "other"); + var second = TestEntities.Mix(owner.Id, "Second", position: 1); + var first = TestEntities.Mix(owner.Id, "First", position: 0); + var theirs = TestEntities.Mix(other.Id, "Theirs"); + await SeedAsync(owner, other, second, first, theirs); + + var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(["First", "Second"], result.Value.Select(mix => mix.Title)); + } + + [Fact] + public async Task Handle_NoMixes_ReturnsEmptyList() + { + var owner = TestEntities.User(); + await SeedAsync(owner); + + var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Empty(result.Value); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs new file mode 100644 index 0000000..2e1fad1 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs @@ -0,0 +1,93 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.PlayLists; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.PlayLists +{ + public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase + { + private AddTrackToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_FirstTrack_AddsItAtPositionZero() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + 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); + + Assert.False(result.IsError); + var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal(0, link.Position); + } + + [Fact] + public async Task Handle_PlayListAlreadyHasTracks_AppendsAtNextPosition() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + 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 }); + + var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, newTrack.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id, TestContext.Current.CancellationToken); + Assert.Equal(1, newLink.Position); + } + + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), 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 AddTrackToPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } + + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_TrackAlreadyInPlayList_ReturnsConflict() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + 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); + + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs new file mode 100644 index 0000000..697a4de --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs @@ -0,0 +1,114 @@ +using ErrorOr; +using Musify.Application.Contracts; +using Musify.Application.PlayLists; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.PlayLists +{ + public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase + { + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); + + private CreatePlayListCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.PlayList(), + TestConfigurations.UploadIntent()); + + [Fact] + public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var command = new CreatePlayListCommand(user.Id, "My Playlist", "A description", PictureIntentId: null); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("My Playlist", result.Value.Name); + Assert.Null(result.Value.SmallImageKeyName); + await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); + + var user = TestEntities.User(); + var intent = TestEntities.UploadIntent(user.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "cover.webp"); + await SeedAsync(user, intent); + + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, intent.Id); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var stored = await Database.PlayLists.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("cover.webp", stored.Pictures?.OriginalName); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_NoVisibilitySpecified_DefaultsToPrivate() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Private, result.Value.Visibility); + } + + [Fact] + public async Task Handle_PublicVisibility_PersistsIt() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new CreatePlayListCommand(404, "Orphan Playlist", null, null); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_PictureIntentNotFound_ReturnsError() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, Guid.NewGuid()); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs new file mode 100644 index 0000000..2c637a8 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs @@ -0,0 +1,54 @@ +using ErrorOr; +using Musify.Application.Contracts; +using Musify.Application.PlayLists; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.PlayLists +{ + public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase + { + private readonly IEventBus eventBus = Substitute.For(); + + private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); + + [Fact] + public async Task Handle_Owner_MarksPlayListAsRemoving() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new DeletePlayListCommand(owner.Id, playList.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new DeletePlayListCommand(1, Guid.NewGuid()), 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 DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs new file mode 100644 index 0000000..0610a17 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs @@ -0,0 +1,85 @@ +using ErrorOr; +using Musify.Application.PlayLists; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.PlayLists +{ + public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase + { + private GetPlayListByIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + 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); + + Assert.False(result.IsError); + Assert.Equal(playList.Name, result.Value.Name); + Assert.Equal([track.Id], result.Value.CoverTrackIds); + } + + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_PrivatePlayList_RequestedByOwner_ReturnsIt() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } + + [Fact] + public async Task Handle_PrivatePlayList_RequestedByStranger_ReturnsNotFound() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private); + await SeedAsync(owner, stranger, playList); + + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, stranger.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_PrivatePlayList_RequestedAnonymously_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_PublicPlayList_RequestedAnonymously_ReturnsIt() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs new file mode 100644 index 0000000..6c7dbbe --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs @@ -0,0 +1,51 @@ +using ErrorOr; +using Musify.Application.PlayLists; +using Musify.Application.Tests.TestSupport; +using Xunit; + +namespace Musify.Application.Tests.PlayLists +{ + public sealed class GetPlayListCoverQueryHandlerTests : HandlerTestBase + { + private GetPlayListCoverQueryHandler CreateHandler() => + new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.PlayList()); + + [Theory] + [InlineData("small", "small.webp")] + [InlineData("large", "large.webp")] + [InlineData("medium", "medium.webp")] + public async Task Handle_ExistingPlayList_ReturnsRequestedSize(string size, string expectedFileName) + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, size), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("covers-bucket", result.Value.Bucket); + Assert.EndsWith(expectedFileName, result.Value.Key); + } + + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetPlayListCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_PlayListWithoutPictures_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + playList.Pictures = null; + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, "small"), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs new file mode 100644 index 0000000..bedf50f --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs @@ -0,0 +1,39 @@ +using ErrorOr; +using Musify.Application.PlayLists; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.PlayLists +{ + public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase + { + private GetPlayListTracksQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_TracksOrderedByPosition_ReturnsThemInOrder() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + 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 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 }); + + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); + } + + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs new file mode 100644 index 0000000..4e051e5 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs @@ -0,0 +1,59 @@ +using Musify.Application.PlayLists; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.PlayLists +{ + public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase + { + private GetPlayListsByUserIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ReturnsOnlyActivePlayListsForThatUser() + { + var owner = TestEntities.User(1, "owner"); + var other = TestEntities.User(2, "other"); + await SeedAsync( + owner, other, + TestEntities.PlayList(owner.Id, "Mine"), + TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing), + TestEntities.PlayList(other.Id, "Theirs")); + + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var playList = Assert.Single(result.Value.Items); + Assert.Equal("Mine", playList.Name); + } + + [Fact] + public async Task Handle_OnlyPublic_ReturnsOnlyPublicPlayLists() + { + var owner = TestEntities.User(); + await SeedAsync( + owner, + TestEntities.PlayList(owner.Id, "Public one", visibility: PlaylistVisibility.Public), + TestEntities.PlayList(owner.Id, "Private one", visibility: PlaylistVisibility.Private)); + + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, OnlyPublic: true), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var playList = Assert.Single(result.Value.Items); + Assert.Equal("Public one", playList.Name); + } + + [Fact] + public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists() + { + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.PlayList(owner.Id, "Road Trip"), TestEntities.PlayList(owner.Id, "Study Focus")); + + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var playList = Assert.Single(result.Value.Items); + Assert.Equal("Road Trip", playList.Name); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs new file mode 100644 index 0000000..ba64fa9 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs @@ -0,0 +1,32 @@ +using Musify.Application.PlayLists; +using Musify.Application.Tests.TestSupport; +using Xunit; + +namespace Musify.Application.Tests.PlayLists +{ + public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase + { + private GetPlayListsQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ReturnsEveryPlayListPaged() + { + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.PlayList(owner.Id, "One"), TestEntities.PlayList(owner.Id, "Two")); + + var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + } + + [Fact] + public async Task Handle_NoPlayLists_ReturnsEmptyPage() + { + var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Empty(result.Value.Items); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs new file mode 100644 index 0000000..67bab6e --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs @@ -0,0 +1,60 @@ +using ErrorOr; +using Musify.Application.PlayLists; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.PlayLists +{ + public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase + { + private RemoveTrackFromPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_TrackInPlayList_RemovesTheLink() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + 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); + + Assert.False(result.IsError); + Assert.Empty(Database.PlayListHasTracks); + } + + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), 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 RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } + + [Fact] + public async Task Handle_TrackNotLinked_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs new file mode 100644 index 0000000..7a73cb4 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs @@ -0,0 +1,75 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.PlayLists; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.PlayLists +{ + public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTestBase + { + private readonly IStorageService storageService = Substitute.For(); + + private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + { + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); + + return new RequestPlayListPictureUploadCommandHandler( + Database, + new UploadIntentValidator(Database, storageService), + storageService, + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.PlayList(), + uploadIntentConfig ?? TestConfigurations.UploadIntent()); + } + + [Fact] + public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp"); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl); + Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new RequestPlayListPictureUploadCommand(404, "webp", "image/webp"); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadBytesPerUser = 100; + + var user = TestEntities.User(); + await SeedAsync(user); + + var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000); + + var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs new file mode 100644 index 0000000..ccee5d0 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs @@ -0,0 +1,136 @@ +using ErrorOr; +using Musify.Application.Contracts; +using Musify.Application.PlayLists; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.PlayLists +{ + public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase + { + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); + + private UpdatePlayListCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.PlayList(), + TestConfigurations.UploadIntent()); + + [Fact] + public async Task Handle_Owner_RenamesPlayList() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, "Old Name"); + await SeedAsync(owner, playList); + + var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("New Name", result.Value.Name); + } + + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, null); + + var result = await CreateHandler().Handle(command, 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 command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } + + [Fact] + public async Task Handle_NewVisibility_UpdatesIt() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); + + var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlaylistVisibility.Public); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility); + } + + [Fact] + public async Task Handle_NoVisibilitySpecified_LeavesItUnchanged() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + await SeedAsync(owner, playList); + + var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility); + } + + [Fact] + public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description"); + await SeedAsync(owner, playList); + + var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("Original Name", result.Value.Name); + Assert.Equal("Original description", result.Value.Description); + } + + [Fact] + public async Task Handle_PlayListWithoutPictures_ValidPictureIntent_SetsPictureAndPublishesSourceEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); + + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + playList.Pictures = null; + var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "new-cover.webp"); + await SeedAsync(owner, playList, intent); + + var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, intent.Id); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("new-cover.webp", result.Value.SmallImageKeyName); + var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs new file mode 100644 index 0000000..23e4924 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Configuration; +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 TrackStreamIssuerTests : HandlerTestBase + { + private readonly IStreamTicketService ticketService = Substitute.For(); + + private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new( + Database, + ticketService, + TestConfigurations.Track(), + TestConfigurations.StreamGateway("https://stream.musify.test/"), + playback ?? TestConfigurations.Playback()); + + [Fact] + public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory() + { + ticketService.IssueTicket(7, Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); + var owner = TestEntities.User(7); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken); + + Assert.Equal("signed-token", response.Ticket); + Assert.Equal(120, response.ExpiresInSeconds); + Assert.Equal( + "https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", + response.ManifestUrl); + + var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal(7, history.UserId); + Assert.Equal(track.Id, history.TrackId); + } + + [Fact] + public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + var owner = TestEntities.User(1); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); + + ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); + } + + [Fact] + public async Task IssueAsync_AnonymousUser_SkipsListeningHistoryAndNeverThrows() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + var owner = TestEntities.User(1); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var response = await CreateIssuer(TestConfigurations.Playback(allowAnonymousListening: true)) + .IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken); + + Assert.Equal("token", response.Ticket); + Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task IssueAsync_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + var owner = TestEntities.User(1); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken); + + ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond); + } + + [Fact] + public async Task IssueAsync_AuthenticatedUser_IgnoresTheAnonymousFragmentCap() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + var owner = TestEntities.User(1); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); + + ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs new file mode 100644 index 0000000..441885b --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs @@ -0,0 +1,146 @@ +using ErrorOr; +using Musify.Application.Contracts; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Services +{ + public sealed class UploadIntentValidatorTests : HandlerTestBase + { + private readonly IStorageService storageService = Substitute.For(); + private readonly Domain.Entities.User owner = TestEntities.User(); + + private UploadIntentValidator CreateValidator() => new(Database, storageService); + + [Fact] + public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent() + { + storageService + .HeadObjectAsync("bucket", "temp/object.webp", Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 2048)); + + var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp"); + await SeedAsync(owner, intent); + + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(intent.Id, result.Value.Id); + } + + [Fact] + public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound() + { + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task ValidateAndLoadAsync_IntentBelongsToAnotherUser_ReturnsNotFound() + { + var stranger = TestEntities.User(2, "stranger"); + var intent = TestEntities.UploadIntent(owner.Id); + await SeedAsync(owner, stranger, intent); + + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task ValidateAndLoadAsync_AlreadyConsumed_ReturnsConflict() + { + var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed); + await SeedAsync(owner, intent); + + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } + + [Fact] + public async Task ValidateAndLoadAsync_PastExpiry_ReturnsValidationError() + { + var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1)); + await SeedAsync(owner, intent); + + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + + [Fact] + public async Task ValidateAndLoadAsync_ObjectNeverUploaded_ReturnsNotFound() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ObjectMetaData?)null); + + var intent = TestEntities.UploadIntent(owner.Id); + await SeedAsync(owner, intent); + + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task ValidateAndLoadAsync_UploadedObjectExceedsMaxSize_ReturnsValidationError() + { + var config = TestConfigurations.UploadIntent(); + config.MaxUploadBytes = 1024; + + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 2048)); + + var intent = TestEntities.UploadIntent(owner.Id); + await SeedAsync(owner, intent); + + var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + + [Fact] + public async Task CheckQuotaAsync_WithinLimits_ReturnsSuccess() + { + await SeedAsync(owner); + + var result = await CreateValidator().CheckQuotaAsync(TestConfigurations.UploadIntent(), owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } + + [Fact] + public async Task CheckQuotaAsync_TooManyActiveIntents_ReturnsValidationError() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadIntentsPerUser = 1; + await SeedAsync(owner, TestEntities.UploadIntent(owner.Id)); + + var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + + [Fact] + public async Task CheckQuotaAsync_ExpiredOrConsumedIntentsDoNotCountTowardsTheLimit() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadIntentsPerUser = 1; + await SeedAsync( + owner, + TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired), + TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed)); + + var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs b/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs new file mode 100644 index 0000000..447256b --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs @@ -0,0 +1,41 @@ +using Musify.Application.Shared; +using Xunit; + +namespace Musify.Application.Tests.Shared +{ + public sealed class StorageKeyTests + { + [Fact] + public void Combine_MultipleSegments_JoinsThemWithForwardSlashes() + { + var key = StorageKey.Combine("Tracks", "SmallPictures", "cover.webp"); + + Assert.Equal("Tracks/SmallPictures/cover.webp", key); + } + + [Fact] + public void Combine_SegmentsWithSurroundingSlashesAndWhitespace_TrimsThem() + { + var key = StorageKey.Combine(" Tracks/ ", "/SmallPictures/", "cover.webp"); + + Assert.Equal("Tracks/SmallPictures/cover.webp", key); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Combine_BlankSegment_IsSkipped(string? blank) + { + var key = StorageKey.Combine("Tracks", blank!, "cover.webp"); + + Assert.Equal("Tracks/cover.webp", key); + } + + [Fact] + public void Combine_NoSegments_ReturnsEmptyString() + { + Assert.Equal(string.Empty, StorageKey.Combine()); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs new file mode 100644 index 0000000..a708efa --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs @@ -0,0 +1,22 @@ +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks.Responses; +using Xunit; + +namespace Musify.Application.Tests.Shared +{ + public sealed class TrackApplicationResponseTests + { + [Fact] + public void FromEntity_UsesOwnerNameAsArtist() + { + var owner = TestEntities.User(1, "The Owner"); + var track = TestEntities.Track(owner); + + var response = TrackApplicationResponse.FromEntity(track, listensCount: 3); + + Assert.Equal("The Owner", response.Artist); + Assert.Equal(owner.Id, response.OwnerUserId); + Assert.Equal(3, response.ListensCount); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs new file mode 100644 index 0000000..653dfeb --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Musify.Application.Tests.TestSupport +{ + public abstract class HandlerTestBase : IAsyncLifetime + { + private TestDatabase database = null!; + + protected TestDatabase Database => database; + + public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync(); + + public ValueTask DisposeAsync() => database.DisposeAsync(); + + protected static ILogger NoOpLogger() => NullLogger.Instance; + + protected async Task SeedAsync(params object[] entities) + { + foreach (var entity in entities) + Database.Add(entity); + + await Database.SaveChangesAsync(CancellationToken.None); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs new file mode 100644 index 0000000..2d45ebe --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs @@ -0,0 +1,26 @@ +using Musify.Application.Configuration; + +namespace Musify.Application.Tests.TestSupport +{ + public static class TestConfigurations + { + public static ApplicationStorageConfiguration Storage(string bucket = "test-bucket") => + new() { Bucket = bucket }; + + public static TrackConfiguration Track() => new(); + + public static PlayListConfiguration PlayList() => new(); + + public static AlbumConfiguration Album() => new(); + + public static MixConfiguration Mix() => new(); + + public static UploadIntentConfiguration UploadIntent() => new(); + + public static StreamGatewayConfiguration StreamGateway(string publicBaseUrl = "https://stream.musify.test") => + new() { PublicBaseUrl = publicBaseUrl }; + + public static PlaybackConfiguration Playback(bool allowAnonymousListening = false, int anonymousFragmentSeconds = 0) => + new() { AllowAnonymousListening = allowAnonymousListening, AnonymousFragmentSeconds = anonymousFragmentSeconds }; + } +} diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs new file mode 100644 index 0000000..177c128 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -0,0 +1,182 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Domain.Entities; + +namespace Musify.Application.Tests.TestSupport +{ + public sealed class TestDatabase : DbContext, IDatabase + { + private readonly SqliteConnection connection; + + private TestDatabase(SqliteConnection connection, DbContextOptions options) + : base(options) + { + this.connection = connection; + } + + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Filename=:memory:"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + var database = new TestDatabase(connection, options); + await database.Database.EnsureCreatedAsync(); + return database; + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity().OwnsOne(track => track.Pictures); + modelBuilder.Entity().Navigation(track => track.Pictures).IsRequired(); + + modelBuilder.Entity().OwnsOne(track => track.Audio); + modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); + + modelBuilder.Entity() + .HasOne(track => track.Owner) + .WithMany() + .HasForeignKey(track => track.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity().OwnsOne(playList => playList.Pictures, pictures => + { + pictures.Property(p => p.OriginalName).IsRequired(false); + pictures.Property(p => p.SmallName).IsRequired(false); + pictures.Property(p => p.MediumName).IsRequired(false); + pictures.Property(p => p.LargeName).IsRequired(false); + }); + modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(false); + + modelBuilder.Entity().OwnsOne(album => album.Pictures); + modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); + + modelBuilder.Entity() + .HasOne(album => album.Owner) + .WithMany() + .HasForeignKey(album => album.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Album) + .WithMany(album => album.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.AlbumId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Track) + .WithMany(track => track.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(mix => new { mix.UserId, mix.Position }); + + modelBuilder.Entity() + .HasOne(item => item.Mix) + .WithMany(mix => mix.Items) + .HasForeignKey(item => item.MixId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(item => item.Track) + .WithMany() + .HasForeignKey(item => item.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(item => new { item.MixId, item.Position }); + + modelBuilder.Entity() + .HasIndex(like => new { like.UserId, like.TrackId }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(trackTag => trackTag.Track) + .WithMany(track => track.Tags) + .HasForeignKey(trackTag => trackTag.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(follow => follow.Follower) + .WithMany() + .HasForeignKey(follow => follow.FollowerId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(follow => follow.Followed) + .WithMany() + .HasForeignKey(follow => follow.FollowedId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(follow => new { follow.FollowerId, follow.FollowedId }) + .IsUnique(); + } + + public DbSet Users => Set(); + + public DbSet PlayLists => Set(); + + public DbSet Albums => Set(); + + public DbSet AlbumHasTracks => Set(); + + public DbSet Tracks => Set(); + + public DbSet UserHasTracks => Set(); + + public DbSet PlayListHasTracks => Set(); + + public DbSet Mixes => Set(); + + public DbSet MixItems => Set(); + + public DbSet Uploads => Set(); + + public DbSet UploadIntents => Set(); + + public DbSet ListeningHistories => Set(); + + public DbSet TrackLikes => Set(); + + public DbSet TrackTags => Set(); + + public DbSet UserFollows => Set(); + + public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) + { + var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); + return new SqliteDatabaseTransaction(transaction); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + await connection.DisposeAsync(); + } + + private sealed class SqliteDatabaseTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction inner) + : IDatabaseTransaction + { + public Task CommitAsync(CancellationToken cancellationToken = default) => + inner.CommitAsync(cancellationToken); + + public ValueTask DisposeAsync() => inner.DisposeAsync(); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs new file mode 100644 index 0000000..3dce496 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -0,0 +1,188 @@ +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tests.TestSupport +{ + public static class TestEntities + { + public static User User( + long id = 1, + string name = "test-user", + string? firstName = "Test", + string? secondName = "User", + string? profilePictureUrl = null) => + new() + { + Id = id, + Name = name, + NormalizedName = name.ToUpperInvariant(), + FirstName = firstName, + SecondName = secondName, + ProfilePictureUrl = profilePictureUrl + }; + + public static TrackPictures ProcessedPictures() => new() + { + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp", + ProcessingStatus = ProcessingStatus.Completed + }; + + public static TrackPictures PendingPictures() => new() + { + OriginalName = "original.webp", + ProcessingStatus = ProcessingStatus.Pending + }; + + public static TrackAudio ProcessedAudio(string folderName = "audio-folder") => new() + { + OriginalName = "original.mp3", + FolderName = folderName, + TranscodeStatus = ProcessingStatus.Completed + }; + + public static TrackAudio PendingAudio() => new() + { + OriginalName = "original.mp3", + TranscodeStatus = ProcessingStatus.Pending + }; + + public static Track Track( + User owner, + string title = "Test Track", + double durationSeconds = 180, + TrackPictures? pictures = null, + TrackAudio? audio = null, + LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, + IEnumerable? tags = null) + { + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = durationSeconds, + LifeCycleStatus = lifeCycleStatus, + OwnerUserId = owner.Id, + Owner = owner, + Pictures = pictures ?? ProcessedPictures(), + Audio = audio ?? ProcessedAudio() + }; + + track.Tags = (tags ?? [Genre.Pop]) + .Select(tag => new TrackTag { TrackId = track.Id, Tag = tag }) + .ToList(); + + return track; + } + + public static PlayListPictures PlayListPictures() => new() + { + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp" + }; + + public static PlayList PlayList( + long userId, + string name = "Test Playlist", + string? description = null, + PlayListPictures? pictures = null, + LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, + PlaylistVisibility visibility = PlaylistVisibility.Private) => + new() + { + UserId = userId, + Name = name, + NormalizedName = name.ToUpperInvariant(), + Description = description, + Pictures = pictures ?? PlayListPictures(), + LifeCycleStatus = lifeCycleStatus, + Visibility = visibility + }; + + public static AlbumPictures AlbumPictures() => new() + { + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp" + }; + + public static Album Album( + long ownerUserId, + string title = "Test Album", + string? description = null, + int? releaseYear = 2024, + AlbumPictures? pictures = null) => + new() + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + Description = description, + ReleaseYear = releaseYear, + OwnerUserId = ownerUserId, + Pictures = pictures ?? AlbumPictures() + }; + + public static UploadIntent UploadIntent( + long userId, + UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture, + string bucket = "test-bucket", + string key = "temp/object.webp", + string objectName = "object.webp", + string contentType = "image/webp", + long? expectedSizeBytes = 1024, + UploadIntentStatus status = UploadIntentStatus.Issued, + DateTime? expiresAt = null) => + new() + { + UserId = userId, + Bucket = bucket, + Key = key, + ObjectName = objectName, + ContentType = contentType, + ExpectedSizeBytes = expectedSizeBytes, + Purpose = purpose, + Status = status, + ExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(5) + }; + + public static Mix Mix( + long userId, + string title = "Test Mix", + string? subtitle = null, + int position = 0) => + new() + { + UserId = userId, + Title = title, + Subtitle = subtitle, + Position = position + }; + + public static MixItem MixItem( + Guid mixId, + Guid trackId, + int position = 0) => + new() + { + MixId = mixId, + Position = position, + TrackId = trackId + }; + + public static ListeningHistory ListeningHistory( + long userId, + Guid trackId, + DateTime? listenedAt = null) => + new() + { + UserId = userId, + TrackId = trackId, + ListenedAt = listenedAt ?? DateTime.UtcNow + }; + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs new file mode 100644 index 0000000..de7ebbb --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -0,0 +1,130 @@ +using ErrorOr; +using Musify.Application.Contracts; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class CreateTrackCommandHandlerTests : HandlerTestBase + { + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); + + private CreateTrackCommandHandler CreateHandler() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); + + return new CreateTrackCommandHandler( + Database, + eventBus, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.Track(), + TestConfigurations.UploadIntent()); + } + + private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) => + (TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"), + TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3")); + + [Fact] + public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent() + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); + + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop, Genre.Rock]); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("My Song", result.Value.Title); + var localResponse = Assert.IsType(result.Value); + Assert.Equal(user.Id, localResponse.OwnerUserId); + Assert.Equal([Genre.Pop, Genre.Rock], localResponse.Tags); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + + var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); + } + + [Fact] + public async Task Handle_NoTags_ReturnsValidationError() + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); + + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, []); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + + [Fact] + public async Task Handle_IncompatibleTags_ReturnsValidationError() + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); + + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Classical, Genre.Metal]); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop]); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_PictureIntentBelongsToAnotherUser_ReturnsError() + { + var user = TestEntities.User(1, "user"); + var otherUser = TestEntities.User(2, "other"); + var (picture, audio) = SeedIntents(otherUser.Id); + await SeedAsync(user, otherUser, picture, audio); + + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AudioIntentAlreadyConsumed_ReturnsError() + { + var user = TestEntities.User(); + var picture = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"); + var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed); + await SeedAsync(user, picture, audio); + + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs new file mode 100644 index 0000000..da76df3 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs @@ -0,0 +1,66 @@ +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 DeleteTrackCommandHandlerTests : HandlerTestBase + { + private readonly IEventBus eventBus = Substitute.For(); + + private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); + + [Fact] + public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var stored = await Database.Tracks.FindAsync([track.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new DeleteTrackCommand(1, Guid.NewGuid()), 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 track = TestEntities.Track(owner); + await SeedAsync(owner, stranger, track); + + var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.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.Track(owner, audio: TestEntities.PendingAudio()); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs new file mode 100644 index 0000000..07d1fdf --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs @@ -0,0 +1,40 @@ +using ErrorOr; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestBase + { + private GetLastTrackListenedByUserIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_MultipleListens_ReturnsMostRecentlyListenedTrack() + { + var owner = TestEntities.User(); + 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)), + TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow)); + + var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("Newer listen", result.Value.Title); + } + + [Fact] + public async Task Handle_NoHistory_ReturnsNotFound() + { + var owner = TestEntities.User(); + await SeedAsync(owner); + + var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs new file mode 100644 index 0000000..c52a703 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs @@ -0,0 +1,39 @@ +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class GetListeningHistoryQueryHandlerTests : HandlerTestBase + { + private GetListeningHistoryQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst() + { + var owner = TestEntities.User(); + 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)), + TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-5)), + TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow)); + + var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.Equal(["Newer listen", "Older listen"], result.Select(track => track.Title)); + } + + [Fact] + public async Task Handle_NoHistory_ReturnsEmpty() + { + var owner = TestEntities.User(); + await SeedAsync(owner); + + var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.Empty(result); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs new file mode 100644 index 0000000..9805b3d --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs @@ -0,0 +1,37 @@ +using ErrorOr; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Application.Tracks.Responses; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class GetTrackByIdQueryHandlerTests : HandlerTestBase + { + private GetTrackByIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist() + { + var owner = TestEntities.User(1, "artist-name"); + var track = TestEntities.Track(owner, "My Song"); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("My Song", result.Value.Title); + Assert.Equal(owner.Name, result.Value.Artist); + var localResponse = Assert.IsType(result.Value); + Assert.Equal(owner.Id, localResponse.OwnerUserId); + } + + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetTrackByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs new file mode 100644 index 0000000..cd3b6fd --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs @@ -0,0 +1,52 @@ +using ErrorOr; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class GetTrackCoverQueryHandlerTests : HandlerTestBase + { + private GetTrackCoverQueryHandler CreateHandler() => + new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Track()); + + [Theory] + [InlineData("small", "small.webp")] + [InlineData("medium", "medium.webp")] + [InlineData("large", "large.webp")] + [InlineData("unknown-size-falls-back-to-medium", "medium.webp")] + public async Task Handle_ProcessedTrack_ReturnsRequestedSize(string size, string expectedFileName) + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("covers-bucket", result.Value.Bucket); + Assert.EndsWith(expectedFileName, result.Value.Key); + Assert.Equal("image/webp", result.Value.ContentType); + } + + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetTrackCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_PictureNotProcessedYet_ReturnsNotFound() + { + var owner = TestEntities.User(); + 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); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs new file mode 100644 index 0000000..d69cc17 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs @@ -0,0 +1,100 @@ +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.Tracks; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase + { + private readonly IStreamTicketService ticketService = Substitute.For(); + + private GetTrackStreamQueryHandler CreateHandler(PlaybackConfiguration? playback = null) + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("ticket-token", 60)); + + var issuer = new TrackStreamIssuer( + Database, + ticketService, + TestConfigurations.Track(), + TestConfigurations.StreamGateway("https://stream.musify.test"), + playback ?? TestConfigurations.Playback()); + + return new GetTrackStreamQueryHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger()); + } + + [Fact] + public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("ticket-token", result.Value.Ticket); + Assert.Equal(60, result.Value.ExpiresInSeconds); + Assert.Contains("audio-folder", result.Value.ManifestUrl); + + var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal(owner.Id, history.UserId); + Assert.Equal(track.Id, history.TrackId); + } + + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetTrackStreamQuery(Guid.NewGuid(), 1), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_AudioNotProcessedYet_ReturnsConflict() + { + var owner = TestEntities.User(); + 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); + + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } + + [Fact] + public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); + + var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } + + [Fact] + public async Task Handle_AnonymousUser_AnonymousListeningEnabled_IssuesTicketWithoutRecordingHistory() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); + + var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("ticket-token", result.Value.Ticket); + Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs new file mode 100644 index 0000000..1e6f20e --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs @@ -0,0 +1,46 @@ +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase + { + private GetTracksByUserIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ReturnsOnlyTracksLinkedToThatUser() + { + var owner = TestEntities.User(1, "owner"); + var other = TestEntities.User(2, "other"); + 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 }, + new UserHasTrack { UserId = other.Id, TrackId = theirs.Id }); + + var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var track = Assert.Single(result.Value.Items); + Assert.Equal("Mine", track.Title); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-5, 1)] + public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber) + { + var owner = TestEntities.User(); + 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); + + Assert.False(result.IsError); + Assert.Equal(expectedPageNumber, result.Value.PageNumber); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs new file mode 100644 index 0000000..a22dd53 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -0,0 +1,47 @@ +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class GetTracksQueryHandlerTests : HandlerTestBase + { + private GetTracksQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns() + { + var owner = TestEntities.User(); + 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 }, + new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackB.Id }); + + var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + Assert.Equal(2, result.Value.Items.Count); + } + + [Fact] + public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks() + { + var owner = TestEntities.User(); + 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 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.Equal("Bohemian Rhapsody", item.Track.Title); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs new file mode 100644 index 0000000..8867b86 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs @@ -0,0 +1,81 @@ +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.Tracks; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase + { + private readonly IStorageService storageService = Substitute.For(); + + private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + { + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); + + return new RequestTrackUploadUrlsCommandHandler( + Database, + new UploadIntentValidator(Database, storageService), + storageService, + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.Track(), + uploadIntentConfig ?? TestConfigurations.UploadIntent()); + } + + [Fact] + public async Task Handle_UserExists_IssuesBothUploadUrlsAndCreatesIntents() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.PictureUploadUrl); + Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.AudioUploadUrl); + + var intents = await Database.UploadIntents.Where(intent => intent.UserId == user.Id).ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, intents.Count); + Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackPicture); + Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackAudio); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new RequestTrackUploadUrlsCommand(404, "webp", "image/webp", "mp3", "audio/mpeg"); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_UserAtActiveIntentLimit_ReturnsValidationErrorAndCreatesNothing() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadIntentsPerUser = 1; + + var user = TestEntities.User(); + await SeedAsync(user, TestEntities.UploadIntent(user.Id)); + + var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"); + + var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + Assert.Equal(1, await Database.UploadIntents.CountAsync(TestContext.Current.CancellationToken)); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs new file mode 100644 index 0000000..eb15fa6 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs @@ -0,0 +1,41 @@ +using ErrorOr; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class CreateUserCommandHandlerTests : HandlerTestBase + { + private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_NewId_CreatesUser() + { + var command = new CreateUserCommand(1, "Jane Doe", "Jane", "Doe"); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("Jane Doe", result.Value.Name); + Assert.Equal(1, result.Value.Id); + + var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("JANE DOE", stored.NormalizedName); + } + + [Fact] + public async Task Handle_IdAlreadyExists_ReturnsConflict() + { + var existing = TestEntities.User(1); + await SeedAsync(existing); + + var command = new CreateUserCommand(1, "Someone Else", null, null); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs new file mode 100644 index 0000000..3659bbf --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs @@ -0,0 +1,61 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class FollowUserCommandHandlerTests : HandlerTestBase + { + private FollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_ValidTarget_CreatesFollow() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed); + + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_AlreadyFollowing_IsIdempotent() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed, new Musify.Domain.Entities.UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); + + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_SelfFollow_ReturnsValidationError() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var result = await CreateHandler().Handle(new FollowUserCommand(user.Id, user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + + [Fact] + public async Task Handle_TargetMissing_ReturnsNotFound() + { + var follower = TestEntities.User(); + await SeedAsync(follower); + + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, 404), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs new file mode 100644 index 0000000..1061cae --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs @@ -0,0 +1,87 @@ +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase + { + private GetListeningStatsQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_NoHistory_ReturnsZeroes() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(0, result.TracksThisWeek); + Assert.Equal(0, result.SecondsThisWeek); + Assert.Equal(0, result.StreakDays); + } + + [Fact] + public async Task Handle_ListensThisWeek_CountsDistinctTracksAndTotalSeconds() + { + var user = TestEntities.User(); + 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)), + TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddHours(-1)), + TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow), + TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow.AddDays(-10))); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.TracksThisWeek); + Assert.Equal(500, result.SecondsThisWeek); + } + + [Fact] + public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.StreakDays); + } + + [Fact] + public async Task Handle_GapBeforeToday_StopsStreakAtGap() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2))); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(1, result.StreakDays); + } + + [Fact] + public async Task Handle_NoListenToday_StreakIsZero() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(0, result.StreakDays); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs new file mode 100644 index 0000000..1854be6 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs @@ -0,0 +1,32 @@ +using ErrorOr; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class GetUserByIdQueryHandlerTests : HandlerTestBase + { + private GetUserByIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ExistingUser_ReturnsIt() + { + var user = TestEntities.User(1, "Jane Doe"); + await SeedAsync(user); + + var result = await CreateHandler().Handle(new GetUserByIdQuery(1), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("Jane Doe", result.Value.Name); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetUserByIdQuery(404), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs new file mode 100644 index 0000000..57b734e --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs @@ -0,0 +1,41 @@ +using ErrorOr; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class GetUserProfileQueryHandlerTests : HandlerTestBase + { + private GetUserProfileQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ExistingUser_ReturnsFollowerAndFollowingCounts() + { + var user = TestEntities.User(1, "user"); + var followerA = TestEntities.User(2, "followerA"); + var followerB = TestEntities.User(3, "followerB"); + var followed = TestEntities.User(4, "followed"); + await SeedAsync( + user, followerA, followerB, followed, + new UserFollow { FollowerId = followerA.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = followerB.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = followed.Id }); + + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.FollowersCount); + Assert.Equal(1, result.Value.FollowingCount); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetUserProfileQuery(404), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs new file mode 100644 index 0000000..f60f180 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs @@ -0,0 +1,34 @@ +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class GetUsersQueryHandlerTests : HandlerTestBase + { + private GetUsersQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_NoSearch_ReturnsEveryUser() + { + await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob")); + + var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + } + + [Fact] + public async Task Handle_UsernameSearch_ReturnsOnlyMatches() + { + await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder")); + + var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var user = Assert.Single(result.Value.Items); + Assert.Equal(1, user.Id); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs new file mode 100644 index 0000000..2d7f9ea --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs @@ -0,0 +1,36 @@ +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class IsFollowingUserQueryHandlerTests : HandlerTestBase + { + private IsFollowingUserQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_Following_ReturnsTrue() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); + + var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.True(result); + } + + [Fact] + public async Task Handle_NotFollowing_ReturnsFalse() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed); + + var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.False(result); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs new file mode 100644 index 0000000..3702165 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs @@ -0,0 +1,43 @@ +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class SyncUserCommandHandlerTests : HandlerTestBase + { + private SyncUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_UnknownUser_ProvisionsIt() + { + var command = new SyncUserCommand(1, "Jane Doe", "Jane", "Doe", "https://avatar.example/jane.png"); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("Jane Doe", stored.Name); + Assert.Equal("https://avatar.example/jane.png", stored.ProfilePictureUrl); + } + + [Fact] + public async Task Handle_ExistingUser_UpdatesProfileFields() + { + var user = TestEntities.User(1, "Old Name", profilePictureUrl: null); + await SeedAsync(user); + + var command = new SyncUserCommand(1, "New Name", "New", "Name", "https://avatar.example/new.png"); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("New Name", stored.Name); + Assert.Equal("NEW NAME", stored.NormalizedName); + Assert.Equal("https://avatar.example/new.png", stored.ProfilePictureUrl); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs new file mode 100644 index 0000000..e853dd8 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class UnfollowUserCommandHandlerTests : HandlerTestBase + { + private UnfollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_ExistingFollow_RemovesIt() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); + + var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Empty(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_NotFollowing_IsIdempotent() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed); + + var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } + } +} diff --git a/backend/Tests/Musify.Domain.Tests/Domain.Tests.csproj b/backend/Tests/Musify.Domain.Tests/Domain.Tests.csproj new file mode 100644 index 0000000..0562fae --- /dev/null +++ b/backend/Tests/Musify.Domain.Tests/Domain.Tests.csproj @@ -0,0 +1,28 @@ + + + + Musify.Domain.Tests + Musify.Domain.Tests + false + true + Exe + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs b/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs new file mode 100644 index 0000000..1aa3589 --- /dev/null +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs @@ -0,0 +1,51 @@ +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Domain.Tests.ValueObjects +{ + public sealed class GenreCompatibilityTests + { + [Theory] + [InlineData(Genre.Classical, Genre.Metal)] + [InlineData(Genre.Classical, Genre.Punk)] + [InlineData(Genre.Ambient, Genre.Dubstep)] + [InlineData(Genre.Lofi, Genre.DrumAndBass)] + public void AreCompatible_IncompatiblePair_ReturnsFalse(Genre first, Genre second) + { + Assert.False(GenreCompatibility.AreCompatible(first, second)); + Assert.False(GenreCompatibility.AreCompatible(second, first)); + } + + [Theory] + [InlineData(Genre.Pop, Genre.Rock)] + [InlineData(Genre.HipHop, Genre.RnB)] + [InlineData(Genre.Classical, Genre.Jazz)] + public void AreCompatible_CompatiblePair_ReturnsTrue(Genre first, Genre second) + { + Assert.True(GenreCompatibility.AreCompatible(first, second)); + } + + [Fact] + public void AreCompatible_SameGenre_ReturnsTrue() + { + Assert.True(GenreCompatibility.AreCompatible(Genre.Pop, Genre.Pop)); + } + + [Fact] + public void FindConflicts_NoIncompatiblePairs_ReturnsEmpty() + { + var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Rock, Genre.Indie]); + + Assert.Empty(conflicts); + } + + [Fact] + public void FindConflicts_IncompatiblePairPresent_ReturnsIt() + { + var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Classical, Genre.Metal]); + + var conflict = Assert.Single(conflicts); + Assert.Equal((Genre.Classical, Genre.Metal), conflict); + } + } +} diff --git a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs new file mode 100644 index 0000000..78fde26 --- /dev/null +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs @@ -0,0 +1,23 @@ +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Domain.Tests.ValueObjects +{ + public sealed class TrackAudioTests + { + [Theory] + [InlineData(ProcessingStatus.Pending, false, false, true)] + [InlineData(ProcessingStatus.Processing, false, false, true)] + [InlineData(ProcessingStatus.Completed, true, false, false)] + [InlineData(ProcessingStatus.Failed, false, true, false)] + public void ComputedFlags_ReflectTranscodeStatus( + ProcessingStatus status, bool expectedProcessed, bool expectedFailed, bool expectedInProgress) + { + var audio = new TrackAudio { TranscodeStatus = status }; + + Assert.Equal(expectedProcessed, audio.IsProcessed); + Assert.Equal(expectedFailed, audio.IsFailed); + Assert.Equal(expectedInProgress, audio.IsInProgress); + } + } +} diff --git a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs new file mode 100644 index 0000000..9ac53db --- /dev/null +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs @@ -0,0 +1,20 @@ +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Domain.Tests.ValueObjects +{ + public sealed class TrackPicturesTests + { + [Theory] + [InlineData(ProcessingStatus.Pending, false)] + [InlineData(ProcessingStatus.Processing, false)] + [InlineData(ProcessingStatus.Completed, true)] + [InlineData(ProcessingStatus.Failed, false)] + public void IsProcessed_ReflectsProcessingStatus(ProcessingStatus status, bool expected) + { + var pictures = new TrackPictures { ProcessingStatus = status }; + + Assert.Equal(expected, pictures.IsProcessed); + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Infrastructure.Tests.csproj b/backend/Tests/Musify.Infrastructure.Tests/Infrastructure.Tests.csproj new file mode 100644 index 0000000..f056cab --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Infrastructure.Tests.csproj @@ -0,0 +1,38 @@ + + + + Musify.Infrastructure.Tests + Musify.Infrastructure.Tests + false + true + Exe + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs new file mode 100644 index 0000000..b6ef79e --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs @@ -0,0 +1,48 @@ +using Musify.Domain.Entities; +using Musify.Infrastructure.Tests.TestSupport; +using Xunit; + +namespace Musify.Infrastructure.Tests.Persistence +{ + [Collection(InfrastructureCollection.Name)] + public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture) + { + [Fact] + public async Task SavingChanges_NewEntity_SetsCreatedAtAndUpdatedAtToNow() + { + await using var database = fixture.CreateDatabase(); + var before = DateTime.UtcNow; + + var name = "audit-user"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, TestContext.Current.CancellationToken); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + + var after = DateTime.UtcNow; + + Assert.InRange(user.CreatedAt, before, after); + Assert.InRange(user.UpdatedAt, before, after); + } + + [Fact] + public async Task SavingChanges_ModifiedEntity_BumpsUpdatedAtButKeepsCreatedAt() + { + await using var database = fixture.CreateDatabase(); + + var name = "audit-user-2"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, TestContext.Current.CancellationToken); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + var originalCreatedAt = user.CreatedAt; + + await Task.Delay(TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken); + + user.Name = "audit-user-2-renamed"; + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + + Assert.Equal(originalCreatedAt, user.CreatedAt); + Assert.True(user.UpdatedAt > originalCreatedAt, + $"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}"); + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs new file mode 100644 index 0000000..3265b9e --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs @@ -0,0 +1,108 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Tests.TestSupport; +using Xunit; + +namespace Musify.Infrastructure.Tests.Persistence +{ + [Collection(InfrastructureCollection.Name)] + public sealed class DatabaseTests(InfrastructureTestFixture fixture) + { + [Fact] + public async Task SaveChangesAsync_TrackWithOwnedTypes_RoundTripsThroughPostgres() + { + await using var database = fixture.CreateDatabase(); + + var userName = "roundtrip-user"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; + await database.Users.AddAsync(user, TestContext.Current.CancellationToken); + + var trackTitle = "Round Trip Track"; + var track = new Track + { + Title = trackTitle, + NormalizedTitle = trackTitle.ToUpperInvariant(), + DurationSeconds = 123.4, + OwnerUserId = user.Id, + Owner = user, + Pictures = new TrackPictures { OriginalName = "cover.webp", ProcessingStatus = ProcessingStatus.Pending }, + Audio = new TrackAudio { OriginalName = "song.mp3", TranscodeStatus = ProcessingStatus.Pending } + }; + 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.Equal(trackTitle, stored.Title); + Assert.Equal("cover.webp", stored.Pictures.OriginalName); + Assert.Equal("song.mp3", stored.Audio.OriginalName); + Assert.Equal(user.Id, stored.OwnerUserId); + } + + [Fact] + public async Task SaveChangesAsync_TrackWithTags_RoundTripsThroughNativePostgresEnum() + { + await using var database = fixture.CreateDatabase(); + + var userName = "tags-roundtrip-user"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; + var track = BuildTrack(user, $"tags-{Guid.NewGuid():N}"); + track.Tags = + [ + new TrackTag { TrackId = track.Id, Tag = Genre.Pop }, + new TrackTag { TrackId = track.Id, Tag = Genre.Rock } + ]; + + await database.AddRangeAsync(user, track); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + + await using var reloaded = fixture.CreateDatabase(); + var stored = await reloaded.Tracks.AsNoTracking() + .Include(t => t.Tags) + .SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken); + + Assert.Equal([Genre.Pop, Genre.Rock], stored.Tags.Select(t => t.Tag).OrderBy(t => t)); + } + + [Fact] + public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows() + { + await using var database = fixture.CreateDatabase(); + + 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 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.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.Tracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken)); + } + + private static Track BuildTrack(User owner, string title) + { + return new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 100, + OwnerUserId = owner.Id, + Owner = owner, + Pictures = new TrackPictures(), + Audio = new TrackAudio() + }; + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs new file mode 100644 index 0000000..7b0f086 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Infrastructure.Services; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Xunit; + +namespace Musify.Infrastructure.Tests.Services +{ + public sealed class PictureServiceTests + { + private static PictureService CreateService() => new(NullLogger.Instance); + + private static MemoryStream CreateSourceImage(int width, int height) + { + using var image = new Image(width, height, new Rgba32(255, 0, 0)); + var stream = new MemoryStream(); + image.SaveAsPng(stream); + stream.Position = 0; + return stream; + } + + [Fact] + public async Task ResizePictureAsWebpAsync_LargerThanTarget_ShrinksToFitWithinTheRequestedBox() + { + using var source = CreateSourceImage(800, 600); + + await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 200, 200, TestContext.Current.CancellationToken); + + using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken); + Assert.InRange(result.Width, 1, 200); + Assert.InRange(result.Height, 1, 200); + Assert.True(result.Width == 200 || result.Height == 200, "expected the longer side to land exactly on the target box"); + } + + [Fact] + public async Task ResizePictureAsWebpAsync_ReturnsAValidWebpStream() + { + using var source = CreateSourceImage(100, 100); + + await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 50, 50, TestContext.Current.CancellationToken); + + var format = await Image.DetectFormatAsync(resized, TestContext.Current.CancellationToken); + Assert.Equal("WEBP", format?.Name, ignoreCase: true); + } + + [Fact] + public async Task ResizePictureAsWebpAsync_PreservesAspectRatio() + { + using var source = CreateSourceImage(400, 200); + + await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 100, 100, TestContext.Current.CancellationToken); + + using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken); + Assert.Equal(100, result.Width); + Assert.Equal(50, result.Height); + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs new file mode 100644 index 0000000..50d7158 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs @@ -0,0 +1,81 @@ +using ErrorOr; +using Microsoft.Extensions.Caching.Memory; +using Musify.Infrastructure.Services; +using Xunit; + +namespace Musify.Infrastructure.Tests.Services +{ + public sealed class SingleFlightCacheTests + { + private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions())); + + [Fact] + public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory() + { + var cache = CreateCache(); + var calls = 0; + Task> Factory() { calls++; return Task.FromResult>(calls); } + + var first = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory); + var second = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory); + + Assert.Equal(1, first.Value); + Assert.Equal(1, second.Value); + Assert.Equal(1, calls); + } + + [Fact] + public async Task GetOrCreateAsync_ConcurrentCallsForTheSameKey_CollapseIntoASingleFactoryCall() + { + var cache = CreateCache(); + var calls = 0; + var gate = new TaskCompletionSource(); + + async Task> Factory() + { + Interlocked.Increment(ref calls); + await gate.Task; + return 7; + } + + var first = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory); + var second = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory); + gate.SetResult(); + + var results = await Task.WhenAll(first, second); + + Assert.Equal(1, calls); + Assert.All(results, result => Assert.Equal(7, result.Value)); + } + + [Fact] + public async Task GetOrCreateAsync_FactoryReturnsError_DoesNotCacheIt() + { + var cache = CreateCache(); + var calls = 0; + Task> Factory() + { + calls++; + return Task.FromResult>(Error.Failure(description: "boom")); + } + + await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory); + await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory); + + Assert.Equal(2, calls); + } + + [Fact] + public async Task GetOrCreateAsync_NonPositiveDuration_AlwaysCallsTheFactory() + { + var cache = CreateCache(); + var calls = 0; + Task> Factory() { calls++; return Task.FromResult>(calls); } + + await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory); + await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory); + + Assert.Equal(2, calls); + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs new file mode 100644 index 0000000..d2420f0 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs @@ -0,0 +1,146 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.Persistence; +using Musify.Infrastructure.Services; +using Musify.Infrastructure.Tests.TestSupport; +using Xunit; + +namespace Musify.Infrastructure.Tests.Services +{ + [Collection(InfrastructureCollection.Name)] + public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime + { + private Database database = null!; + private StorageService service = null!; + + public ValueTask InitializeAsync() + { + database = fixture.CreateDatabase(); + var storageConfiguration = new InfrastructureStorageConfiguration + { + Address = fixture.S3ServiceUrl, + AccessKey = InfrastructureTestFixture.S3AccessKey, + SecretAccessKey = InfrastructureTestFixture.S3SecretKey, + ForcePathStyle = true, + UseHttp = true, + }; + service = new StorageService(database, fixture.CreateS3Client(), NullLogger.Instance, storageConfiguration); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() => database.DisposeAsync(); + + [Fact] + public async Task UploadThenGetFile_RoundTripsTheSameBytes() + { + var key = $"tests/{Guid.NewGuid():N}/round-trip.txt"; + var content = "hello seaweedfs"u8.ToArray(); + + await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + using var reader = new StreamReader(stream, Encoding.UTF8); + var readBack = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); + + Assert.Equal("hello seaweedfs", readBack); + } + + [Fact] + public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata() + { + var key = $"tests/{Guid.NewGuid():N}/head.txt"; + await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + Assert.NotNull(metadata); + Assert.Equal(1, metadata.ContentLength); + } + + [Fact] + public async Task HeadObjectAsync_MissingObject_ReturnsNull() + { + var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken); + + Assert.Null(metadata); + } + + [Fact] + public async Task RemoveFileAsync_ExistingObject_DeletesIt() + { + var key = $"tests/{Guid.NewGuid():N}/to-remove.txt"; + await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + await service.RemoveFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + Assert.Null(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CopyFileAsync_CopiesToTheDestinationKey() + { + var sourceKey = $"tests/{Guid.NewGuid():N}/source.txt"; + var destinationKey = $"tests/{Guid.NewGuid():N}/destination.txt"; + await service.UploadFileAsync(new MemoryStream("copy-me"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, sourceKey, TestContext.Current.CancellationToken); + + await service.CopyFileAsync(InfrastructureTestFixture.S3Bucket, sourceKey, InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken); + + Assert.NotNull(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ListObjectsAsync_ReturnsEveryObjectUnderThePrefix() + { + var prefix = $"tests/{Guid.NewGuid():N}"; + await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken); + await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/b.txt", TestContext.Current.CancellationToken); + + var keys = new List(); + await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken)) + keys.Add(item.Key); + + Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order()); + } + + [Fact] + public async Task RemoveFolderAsync_DeletesEveryObjectUnderThePrefix() + { + var prefix = $"tests/{Guid.NewGuid():N}"; + await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken); + await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/nested/b.txt", TestContext.Current.CancellationToken); + + await service.RemoveFolderAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken); + + var remaining = new List(); + await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken)) + remaining.Add(item.Key); + Assert.Empty(remaining); + } + + [Fact] + public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject() + { + var key = $"tests/{Guid.NewGuid():N}/presigned.txt"; + + var uploadUrl = await service.GetUploadUrlAsync( + InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken); + + using var httpClient = new HttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) + { + Content = new ByteArrayContent(Encoding.UTF8.GetBytes("presigned content")) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); + request.Headers.TryAddWithoutValidation("If-None-Match", "*"); + var response = await httpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + Assert.NotNull(metadata); + Assert.Equal("presigned content".Length, metadata.ContentLength); + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs new file mode 100644 index 0000000..d16e298 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs @@ -0,0 +1,109 @@ +using System.Security.Cryptography; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.Services; +using Xunit; + +namespace Musify.Infrastructure.Tests.Services +{ + public sealed class StreamTicketServiceTests : IDisposable + { + private readonly string privateKeyPath = Path.GetTempFileName(); + private readonly RSA rsa = RSA.Create(2048); + + public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem()); + + public void Dispose() + { + rsa.Dispose(); + File.Delete(privateKeyPath); + } + + private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration + { + PrivateKeyPath = privateKeyPath, + Audience = "media-gateway", + Issuer = "musify-webapi", + TicketTtlSeconds = ticketTtlSeconds + }); + + [Fact] + public void IssueTicket_ReturnsATokenSignedWithTheConfiguredKeyAndClaims() + { + using var service = CreateService(ticketTtlSeconds: 120); + + var ticket = service.IssueTicket(userId: 42, keyPrefix: "Tracks/ProcessedAudios/abc/"); + + Assert.Equal(120, ticket.ExpiresInSeconds); + + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken(ticket.Token); + Assert.Equal("42", jwt.GetClaim("sub").Value); + Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value); + Assert.Equal("musify-webapi", jwt.Issuer); + Assert.Contains("media-gateway", jwt.Audiences); + } + + [Fact] + public void IssueTicket_AnonymousUser_OmitsTheSubClaim() + { + using var service = CreateService(); + + var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/"); + + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken(ticket.Token); + Assert.DoesNotContain(jwt.Claims, c => c.Type == "sub"); + Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value); + } + + [Fact] + public void IssueTicket_WithMaxBytes_IncludesTheMaxBytesClaim() + { + using var service = CreateService(); + + var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/", maxBytes: 480_000); + + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken(ticket.Token); + Assert.Equal("480000", jwt.GetClaim("maxBytes").Value); + } + + [Fact] + public void IssueTicket_WithoutMaxBytes_OmitsTheMaxBytesClaim() + { + using var service = CreateService(); + + var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/abc/"); + + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken(ticket.Token); + Assert.DoesNotContain(jwt.Claims, c => c.Type == "maxBytes"); + } + + [Fact] + public async Task IssueTicket_TokenValidatesAgainstTheMatchingPublicKey() + { + using var service = CreateService(); + var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/xyz/"); + + var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem(); + using var verifyingRsa = RSA.Create(); + verifyingRsa.ImportFromPem(publicKeyPem); + + var handler = new JsonWebTokenHandler(); + var result = await handler.ValidateTokenAsync(ticket.Token, new TokenValidationParameters + { + ValidIssuer = "musify-webapi", + ValidAudience = "media-gateway", + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + IssuerSigningKey = new RsaSecurityKey(verifyingRsa), + }); + + Assert.True(result.IsValid); + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs b/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs new file mode 100644 index 0000000..1e1f45d --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using Amazon.S3; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.Persistence; +using Testcontainers.PostgreSql; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using Xunit; + +namespace Musify.Infrastructure.Tests.TestSupport +{ + public sealed class InfrastructureTestFixture : IAsyncLifetime + { + public const string S3AccessKey = "musify-test"; + public const string S3SecretKey = "musify-test-secret"; + public const string S3Bucket = "musify-test-bucket"; + + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("musify_test") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + private readonly IContainer seaweedFs = new ContainerBuilder("chrislusf/seaweedfs:latest") + .WithEntrypoint("/bin/sh", "-c") + .WithCommand(BuildSeaweedFsStartupScript()) + .WithPortBinding(9333, assignRandomHostPort: true) + .WithPortBinding(8333, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request.ForPort(9333).ForPath("/cluster/healthz"))) + .Build(); + + public string ConnectionString => postgres.GetConnectionString(); + + public string S3ServiceUrl => $"http://{seaweedFs.Hostname}:{seaweedFs.GetMappedPublicPort(8333)}"; + + public async ValueTask InitializeAsync() + { + await Task.WhenAll(postgres.StartAsync(), seaweedFs.StartAsync()); + + await using var database = new Database(new DatabaseConfiguration { ConnectionString = ConnectionString }); + await database.Database.MigrateAsync(); + + using var s3 = CreateS3Client(); + await s3.PutBucketAsync(S3Bucket); + } + + public async ValueTask DisposeAsync() + { + await postgres.DisposeAsync(); + await seaweedFs.DisposeAsync(); + } + + public Database CreateDatabase() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([new("Database:ConnectionString", ConnectionString)]) + .Build(); + + var services = new ServiceCollection(); + services.AddDatabase(configuration); + return services.BuildServiceProvider().GetRequiredService(); + } + + public IAmazonS3 CreateS3Client() => + new AmazonS3Client(S3AccessKey, S3SecretKey, new AmazonS3Config + { + ServiceURL = S3ServiceUrl, + ForcePathStyle = true, + UseHttp = true, + }); + + private static string BuildSeaweedFsStartupScript() + { + var identity = JsonSerializer.Serialize(new + { + identities = new[] + { + new + { + name = "musify-test", + credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } }, + actions = new[] { "Admin", "Read", "Write" } + } + } + }); + + return $""" + cat > /tmp/s3.conf <<'JSON' + {identity} + JSON + exec weed server -ip.bind=0.0.0.0 -dir=/data -master.volumeSizeLimitMB=1024 -volume.max=0 -filer -s3 -s3.config=/tmp/s3.conf + """; + } + } + + [CollectionDefinition(Name)] + public sealed class InfrastructureCollection : ICollectionFixture + { + public const string Name = "Infrastructure"; + } +} diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs new file mode 100644 index 0000000..c454c9a --- /dev/null +++ b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs @@ -0,0 +1,165 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Musify.StreamingGateway.Authentication; +using Musify.StreamingGateway.Configuration; +using Xunit; + +namespace Musify.StreamingGateway.Tests.Authentication +{ + public sealed class TicketValidatorTests : IDisposable + { + private const string Issuer = "musify-webapi"; + private const string Audience = "media-gateway"; + + private readonly string publicKeyPath = Path.GetTempFileName(); + private readonly RSA rsa = RSA.Create(2048); + private readonly JsonWebTokenHandler tokenHandler = new(); + + public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); + + public void Dispose() + { + rsa.Dispose(); + File.Delete(publicKeyPath); + } + + private TicketValidator CreateValidator() => new(Options.Create(new StreamTicketValidationOptions + { + PublicKeyPath = publicKeyPath, + Audience = Audience, + Issuer = Issuer + })); + + private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience, + DateTime? expires = null, DateTime? notBefore = null, long? maxBytes = null) + { + var claims = new Dictionary { ["prefix"] = prefix }; + if (maxBytes != null) + claims["maxBytes"] = maxBytes.Value.ToString(); + + var descriptor = new SecurityTokenDescriptor + { + Issuer = issuer, + Audience = audience, + NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1), + Expires = expires ?? DateTime.UtcNow.AddMinutes(5), + Claims = claims, + SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), + }; + return tokenHandler.CreateToken(descriptor); + } + + [Fact] + public async Task ValidateTicketAsync_ValidToken_ReturnsThePrefixClaim() + { + var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/"); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.True(result.IsValid); + Assert.Equal("Tracks/ProcessedAudios/xyz/", result.Prefix); + Assert.Null(result.MaxBytes); + } + + [Fact] + public async Task ValidateTicketAsync_TokenWithMaxBytesClaim_ReturnsIt() + { + var token = IssueToken(maxBytes: 480_000); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.True(result.IsValid); + Assert.Equal(480_000, result.MaxBytes); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ValidateTicketAsync_BlankToken_ReturnsInvalid(string? token) + { + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + Assert.Equal(string.Empty, result.Prefix); + } + + [Fact] + public async Task ValidateTicketAsync_MalformedToken_ReturnsInvalid() + { + var result = await CreateValidator().ValidateTicketAsync("not-a-real-jwt"); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_ExpiredToken_ReturnsInvalid() + { + var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50)); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_WrongIssuer_ReturnsInvalid() + { + var token = IssueToken(issuer: "someone-else"); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_WrongAudience_ReturnsInvalid() + { + var token = IssueToken(audience: "someone-else"); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsInvalid() + { + using var otherKey = RSA.Create(2048); + var descriptor = new SecurityTokenDescriptor + { + Issuer = Issuer, + Audience = Audience, + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(5), + Claims = new Dictionary { ["prefix"] = "Tracks/ProcessedAudios/abc/" }, + SigningCredentials = new SigningCredentials(new RsaSecurityKey(otherKey), SecurityAlgorithms.RsaSha256), + }; + var token = tokenHandler.CreateToken(descriptor); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsInvalid() + { + var descriptor = new SecurityTokenDescriptor + { + Issuer = Issuer, + Audience = Audience, + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(5), + SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), + }; + var token = tokenHandler.CreateToken(descriptor); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } + } +} diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs new file mode 100644 index 0000000..9082339 --- /dev/null +++ b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs @@ -0,0 +1,220 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Musify.StreamingGateway.Authentication; +using Musify.StreamingGateway.Configuration; +using Musify.StreamingGateway.Middleware; +using Xunit; + +namespace Musify.StreamingGateway.Tests.Middleware +{ + public sealed class TicketValidationMiddlewareTests : IDisposable + { + private const string Prefix = "Tracks/ProcessedAudios/abc"; + + private readonly string publicKeyPath = Path.GetTempFileName(); + private readonly RSA rsa = RSA.Create(2048); + private readonly StreamTicketValidationOptions options; + + public TicketValidationMiddlewareTests() + { + File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); + options = new StreamTicketValidationOptions + { + PublicKeyPath = publicKeyPath, + Audience = "media-gateway", + Issuer = "musify-webapi", + }; + } + + public void Dispose() + { + rsa.Dispose(); + File.Delete(publicKeyPath); + } + + private string IssueValidToken(string prefix = Prefix, long? maxBytes = null) + { + var claims = new Dictionary { ["prefix"] = prefix }; + if (maxBytes != null) + claims["maxBytes"] = maxBytes.Value.ToString(); + + return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + { + Issuer = options.Issuer, + Audience = options.Audience, + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(5), + Claims = claims, + SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), + }); + } + + private (TicketValidationMiddleware Middleware, Func WasNextCalled) CreateMiddleware() + { + var nextCalled = false; + RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; + var validator = new TicketValidator(Options.Create(options)); + var middleware = new TicketValidationMiddleware(next, validator, Options.Create(options), NullLogger.Instance); + return (middleware, () => nextCalled); + } + + [Fact] + public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = "/health"; + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_NoTicket_ReturnsUnauthorized() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + + await middleware.InvokeAsync(context); + + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_ValidTicketAsQueryParameter_ForTheRequestedObject_PassesThroughAndStripsTheTicket() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}&keep=1"); + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?')); + } + + [Fact] + public async Task InvokeAsync_ValidTicketAsHeader_PassesThrough() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.Headers["X-Stream-Ticket"] = IssueValidToken(); + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + } + + [Fact] + public async Task InvokeAsync_ObjectKeyOutsideAuthorizedPrefix_ReturnsForbidden() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = "/media/Tracks/ProcessedAudios/someone-elses-track/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + + await middleware.InvokeAsync(context); + + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); + } + + [Theory] + [InlineData($"/media/{Prefix}/../../etc/passwd")] + [InlineData($"/media/{Prefix}/./audio.m4a")] + public async Task InvokeAsync_PathTraversalSegment_ReturnsBadRequest(string path) + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = path; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + + await middleware.InvokeAsync(context); + + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_TicketPrefixExactlyMatchesTheRequestedObject_PassesThrough() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + } + + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_NoRangeRequested_SynthesizesABoundedRange() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); + } + + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_RangeWithinCap_ClampsTheEndOnly() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = "bytes=1000-"; + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + Assert.Equal("bytes=1000-479999", context.Request.Headers.Range.ToString()); + } + + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_RangeStartsAtOrPastTheCap_ReturnsRangeNotSatisfiable() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = "bytes=480000-"; + + await middleware.InvokeAsync(context); + + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_TicketWithoutMaxBytes_LeavesTheRequestedRangeUntouched() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + context.Request.Headers.Range = "bytes=1000-2000"; + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString()); + } + } +} diff --git a/backend/Tests/Musify.StreamingGateway.Tests/StreamingGateway.Tests.csproj b/backend/Tests/Musify.StreamingGateway.Tests/StreamingGateway.Tests.csproj new file mode 100644 index 0000000..a761f3f --- /dev/null +++ b/backend/Tests/Musify.StreamingGateway.Tests/StreamingGateway.Tests.csproj @@ -0,0 +1,28 @@ + + + + Musify.StreamingGateway.Tests + Musify.StreamingGateway.Tests + false + true + Exe + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/backend/global.json b/backend/global.json new file mode 100644 index 0000000..3140116 --- /dev/null +++ b/backend/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/backend/nuget.config b/backend/nuget.config index 765346e..be7b17d 100644 --- a/backend/nuget.config +++ b/backend/nuget.config @@ -4,4 +4,12 @@ + + + + diff --git a/deploy/.env.example b/deploy/.env.example index 7f1d7e9..a5c82e3 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,35 +1,66 @@ -# Dev stack config (deploy/docker-compose.yml). -# Copy to deploy/.env and adjust. .env is not versioned (.gitignore ignores *.env). +# Development configuration for deploy/compose.yml + compose.dev.yml. +# Copy to deploy/.env and adjust. Not versioned. +# +# cp deploy/.env.example deploy/.env -# PostgreSQL (shared by Musify and Zitadel). Local apps use localhost:59000. +DOTNET_ENVIRONMENT=Development + +# --- Public URLs ------------------------------------------------------------- +# How each service is reached from *outside* Docker. `host.docker.internal` +# resolves to the same address from the browser, from apps running on the host +# and from inside the containers, which is what makes the OIDC issuer and the +# presigned S3 URLs valid in both run modes. +PUBLIC_WEB_URL=http://localhost:3000 +PUBLIC_API_URL=http://localhost:5111 +PUBLIC_STREAM_URL=http://localhost:8081 +PUBLIC_AUTH_URL=http://host.docker.internal:8080 + +# --- PostgreSQL (shared by Musify and Zitadel) ------------------------------- POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=musify_db POSTGRES_HOST_PORT=59000 -# RabbitMQ (MassTransit) +# --- Zitadel (OIDC) ---------------------------------------------------------- +# The masterkey MUST be exactly 32 characters. The external domain/port form +# the token issuer, so they have to match PUBLIC_AUTH_URL. Changing them needs +# a re-init: docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down -v +ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters +ZITADEL_EXTERNAL_DOMAIN=host.docker.internal +ZITADEL_EXTERNAL_PORT=8080 +ZITADEL_EXTERNAL_SECURE=false +ZITADEL_DATABASE_PASSWORD=zitadel +# Console login: @zitadel. +ZITADEL_ADMIN_USERNAME=admin +ZITADEL_ADMIN_PASSWORD=Password1! +# Allows the OIDC apps to use plain-http redirect URIs (dev only). +ZITADEL_DEV_MODE=true + +# OIDC clients: the zitadel-init one-shot service (see compose.yml) fills +# these in automatically on `docker compose up -d`. +AUTH_CLIENT_ID= +WEB_CLIENT_ID= +WEB_CLIENT_SECRET= + +# --- RabbitMQ (MassTransit) -------------------------------------------------- RABBITMQ_DEFAULT_USER=guest RABBITMQ_DEFAULT_PASS=guest -# Zitadel (OIDC). The masterkey MUST be exactly 32 characters. -ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters -# The external domain/port forms the token issuer and must match everywhere. -# host.docker.internal resolves the same from browser, local apps and containers, -# so auth works both locally and full-docker. Changing it needs a re-init -# (up.ps1 -Destroy; up.ps1). Admin login: @zitadel.. -ZITADEL_EXTERNAL_DOMAIN=host.docker.internal -ZITADEL_EXTERNAL_PORT=8080 -ZITADEL_ADMIN_USERNAME=admin -ZITADEL_ADMIN_PASSWORD=Password1! - -# SeaweedFS (S3). Must match deploy/seaweedfs/s3.conf. +# --- SeaweedFS (S3) ---------------------------------------------------------- S3_ACCESS_KEY=admin_access_key S3_SECRET_KEY=admin_secret_key S3_BUCKET=webapi-storage +S3_PUBLIC_URL=http://host.docker.internal:8333 +S3_USE_HTTP=true -# Host paths used by up.ps1 and the apps overlay. -STREAM_KEYS_DIR=D:/weed -WORKER_TEMP_DIR=D:/tempsFilesDev +# --- Web player -------------------------------------------------------------- +# Key material for the encrypted session cookie: any long random string. +SESSION_SECRET=dev-session-secret-change-me-0123456789abcdef +# Allows the plain-HTTP Zitadel issuer in a production Node build (dev only). +AUTH_ALLOW_INSECURE_HTTP=true -# OIDC ClientId, filled in by zitadel/provision.ps1 (used by the docker path). -AUTH_CLIENT_ID= +# --- Misc -------------------------------------------------------------------- +AUTH_REQUIRE_HTTPS=false +PGADMIN_DEFAULT_EMAIL=admin@musify.dev +PGADMIN_DEFAULT_PASSWORD=admin +PGADMIN_HOST_PORT=5050 diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example new file mode 100644 index 0000000..b6befc8 --- /dev/null +++ b/deploy/.env.prod.example @@ -0,0 +1,65 @@ +# Production configuration for deploy/compose.yml + compose.prod.yml. +# Copy to deploy/.env.prod, then: +# +# 1. replace every `example.com` with your domain, +# 2. replace every CHANGE_ME with a strong random value +# (`openssl rand -hex 24`; the Zitadel masterkey needs exactly 32 chars), +# 3. point these DNS records at the server, all proxied/TLS-terminated the +# same way: example.com, www, api, auth, stream, s3. +# +# Not versioned. Keep it readable only by the deploy user (chmod 600). + +DOTNET_ENVIRONMENT=Production + +# --- Domain ------------------------------------------------------------------ +# ROOT_DOMAIN drives the nginx vhosts; the URLs below must agree with it. +ROOT_DOMAIN=example.com +PUBLIC_WEB_URL=https://example.com +PUBLIC_API_URL=https://api.example.com +PUBLIC_STREAM_URL=https://stream.example.com +PUBLIC_AUTH_URL=https://auth.example.com +S3_PUBLIC_URL=https://s3.example.com + +# --- PostgreSQL (shared by Musify and Zitadel) ------------------------------- +POSTGRES_USER=postgres +POSTGRES_PASSWORD=CHANGE_ME +POSTGRES_DB=musify_db + +# --- Zitadel (OIDC) ---------------------------------------------------------- +# The masterkey MUST be exactly 32 characters and can never be rotated in place. +ZITADEL_MASTERKEY=CHANGE_ME_32_CHARACTERS_EXACTLY_ +ZITADEL_EXTERNAL_DOMAIN=auth.example.com +ZITADEL_EXTERNAL_PORT=443 +ZITADEL_EXTERNAL_SECURE=true +ZITADEL_DATABASE_PASSWORD=CHANGE_ME +# Console login: @zitadel. +ZITADEL_ADMIN_USERNAME=admin +ZITADEL_ADMIN_PASSWORD=CHANGE_ME +# Keep OIDC apps strict about https redirect URIs in production. +ZITADEL_DEV_MODE=false + +# OIDC clients: the zitadel-init one-shot service (see compose.yml) fills +# these in automatically on `docker compose up -d`. +AUTH_CLIENT_ID= +WEB_CLIENT_ID= +WEB_CLIENT_SECRET= + +# --- RabbitMQ (MassTransit) -------------------------------------------------- +RABBITMQ_DEFAULT_USER=musify +RABBITMQ_DEFAULT_PASS=CHANGE_ME + +# --- SeaweedFS (S3) ---------------------------------------------------------- +S3_ACCESS_KEY=CHANGE_ME +S3_SECRET_KEY=CHANGE_ME +S3_BUCKET=webapi-storage +S3_USE_HTTP=false + +# --- Web player -------------------------------------------------------------- +# Key material for the encrypted session cookie. Rotating it logs everyone out. +SESSION_SECRET=CHANGE_ME +AUTH_ALLOW_INSECURE_HTTP=false + +# --- Misc -------------------------------------------------------------------- +AUTH_REQUIRE_HTTPS=true +# Largest request body nginx accepts (audio uploads go through the web player). +MAX_UPLOAD_SIZE=1024m diff --git a/deploy/README.md b/deploy/README.md index 77b84d8..03e6b89 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,192 +1,178 @@ -# Development deploy +# Deploy -Everything the backend needs for development, in one place. A single -`docker-compose.yml` brings up **PostgreSQL, Zitadel, RabbitMQ, SeaweedFS and -Jaeger**; the .NET apps run from the IDE (or, optionally, in Docker too). +Everything needed to run Musify, in development and in production, from the +same set of compose files — no wrapper scripts, just `docker compose`. -## Quick start +| File | Role | +|---|---| +| `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. | +| `.env` / `.env.prod` | The values compose interpolates. Not versioned; templates in `.env.example` / `.env.prod.example`. | -```powershell -# From the repo root (Musify/) -./deploy/up.ps1 +The four applications (`api`, `worker`, `gateway`, `web-player`) sit behind the +`apps` compose profile, so a plain `up` starts infrastructure only — the usual +development setup, with the apps running from the IDE. + +### The one-shot bootstrap jobs + +Three services in `compose.yml` run once and exit, so a plain `docker compose +up -d` leaves the stack fully ready with no manual steps: + +| Service | Does | +|---|---| +| `keys-init` | Generates the RS256 stream-ticket key pair in `./keys`, if it is not there already. | +| `migrate` | Applies the EF Core migrations (built from `backend/Musify.Migrator.Dockerfile`). | +| `zitadel-init` | Creates the Musify project and its two OIDC apps in Zitadel (or reuses them), then writes `AUTH_CLIENT_ID` / `WEB_CLIENT_ID` / `WEB_CLIENT_SECRET` into the env file. | + +`api` and `worker` wait for `migrate`; `api` and `gateway` wait for +`keys-init` (`depends_on: condition: service_completed_successfully`). They do +**not** wait for `zitadel-init` — compose interpolates `${AUTH_CLIENT_ID}` once, +when a command starts, so a container that starts in the *same* `up` as +`zitadel-init` would still get the empty value it had before provisioning ran. +That's why bringing up the apps is a second command: by the time you run it, +`zitadel-init` has already finished and the env file has real values. + +--- + +## Development + +```sh +docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d +docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build ``` -`up.ps1` does the full bootstrap: +The first command starts the infrastructure and runs the bootstrap jobs +(keys, migrations, Zitadel project/apps). The second builds and runs `api`, +`worker`, `gateway` and `web-player` in Docker too — skip it to run the apps +from the IDE instead (the usual dev loop): -1. Creates `deploy/.env` from `.env.example` if missing. -2. Starts the stack (`docker compose up -d`) and waits for Postgres to be healthy. -3. Creates the `webapi-storage` bucket in SeaweedFS (`seaweedfs-init` service). -4. Generates the RS256 stream-ticket keys in `D:\weed` if missing. -5. Creates the Worker temp directory (`D:\tempsFilesDev`). -6. Applies the EF Core migrations to `musify_db`. -7. **Provisions Zitadel automatically** (project + OIDC app with JWT token) and - wires the ClientId into `Musify.Api` user-secrets and `.env`. - -When it finishes it prints a summary with URLs and credentials. **No manual steps.** - -### Without the script (infra only) - -```powershell -docker compose -f deploy/docker-compose.yml up -d +```sh +dotnet run --project backend/Hosts/Musify.Api # :5111 +dotnet run --project backend/Hosts/Musify.StreamingGateway # :8081 +dotnet run --project backend/Hosts/Musify.Worker # background +npm --prefix web-player run dev # :5173 ``` -The compose file still creates the bucket on its own; run migrations with -`dotnet ef database update` (see [../docs/development.md](../docs/development.md)). +When running the apps from the IDE, `Musify.Api` reads `Authentication:*` +from user-secrets and `web-player` reads its own `web-player/.env` — neither +looks at `deploy/.env`. Copy the three values `zitadel-init` wrote +(`AUTH_CLIENT_ID`, `WEB_CLIENT_ID`, `WEB_CLIENT_SECRET`) into +`dotnet user-secrets set Authentication:ClientId --project +backend/Hosts/Musify.Api` and into `web-player/.env` once; they stay valid +until you wipe the Zitadel volume. -## Services and ports +Add `--profile tools` to also start pgAdmin. -| Service | Port(s) | Used for | +### Ports + +| Service | Port | Used by | |---|---|---| -| PostgreSQL | `59000` → 5432 | API + Worker (EF Core) and the Zitadel database | -| Zitadel | `8080` | OIDC/OAuth2 (console at `/ui/console`) | +| PostgreSQL | `59000` → 5432 | API + Worker (EF Core) and Zitadel | +| Zitadel | `8080` | OIDC/OAuth2, console at `/ui/console` | | RabbitMQ | `5672` / `15672` | MassTransit / management UI | -| SeaweedFS S3 | `8333` | S3 (presigned PUT/GET) | -| SeaweedFS filer | `8888` | serves files → StreamingGateway | -| SeaweedFS master | `9333` | admin/UI | -| Jaeger | `16686` / `4317` / `4318` | OTLP traces (UI / gRPC / HTTP) | +| SeaweedFS | `8333` / `8888` / `9333` | S3 / filer (→ gateway) / master | +| Jaeger | `16686` / `4317` / `4318` | traces UI / OTLP gRPC / OTLP HTTP | +| pgAdmin (`--profile tools`) | `5050` | Postgres UI | +| API, gateway, web player (`--profile apps`) | `5111` / `8081` / `3000` | | -Ports are published to the host, so apps run locally connect without changing -`AppSettings.Development.json` (Postgres, RabbitMQ, S3, OTLP). - -## Authentication (Zitadel) — automatic - -There are **no manual steps**. `up.ps1` runs `zitadel/provision.ps1`, which -idempotently: - -1. Uses a **service account** with a **PAT** that Zitadel writes on init - (`zitadel/.output/admin-sa.pat`). -2. Creates (or reuses) the **project** `Musify` and an **OIDC app** (PKCE, - `devMode`, **access token = JWT** — required for `JwtBearer` to validate). -3. Captures the generated **ClientId** and writes it to: - - **`Musify.Api` user-secrets** (for running the apps locally), and - - `deploy/.env` as `AUTH_CLIENT_ID` (for the full-docker path). - -The values stay stable while the Postgres volume persists. After `up.ps1 -Destroy` -the next `up.ps1` re-provisions (new ClientId, re-wired). - -- Console: `http://host.docker.internal:8080/ui/console` -- Admin login: `admin@zitadel.host.docker.internal` / `Password1!` (see `.env`). -- Test login: open Scalar (`http://localhost:5111/scalar/v1`) and authorize. +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` -`ZITADEL_EXTERNAL_DOMAIN=host.docker.internal` forms the token *issuer* and -resolves the **same** from the browser, local apps and containers, so token -validation works both ways (local and full-docker). Zitadel routes by the `Host` -header, so the domain must be identical everywhere. If you only run the apps -locally you can use `localhost`. Changing the domain requires a re-init: -`up.ps1 -Destroy; up.ps1`. +`PUBLIC_AUTH_URL` and `S3_PUBLIC_URL` point at `host.docker.internal`, which +resolves to the same address from the browser, from apps running on the host +and from inside the containers. That keeps the OIDC token issuer and the +presigned S3 URLs valid whichever way the apps are running. Zitadel routes by +the `Host` header, so the domain has to be identical everywhere; changing it +requires wiping the Postgres/Zitadel volumes and starting over (`docker +compose -f compose.yml -f compose.dev.yml down -v`, then `up -d` again). -## Running the apps +### Resetting -### Recommended: from the IDE (best for debugging) - -```powershell -dotnet run --project backend/Musify.Api # :5111 -dotnet run --project backend/Musify.StreamingGateway # :8081 -dotnet run --project backend/Musify.Worker # background +```sh +docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down # stop, keep data +docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down -v # stop, wipe volumes ``` -Host requirements: **.NET 10 SDK**, **ffmpeg** on PATH (Worker audio transcode). +--- -### Optional: everything in Docker +## Production -```powershell -docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.apps.yml up -d --build +On a server with a domain, from the repository root: + +```sh +cp deploy/.env.prod.example deploy/.env.prod ``` -Builds and runs `api`, `worker` and `gateway` as containers (the Worker already -ships ffmpeg). The stream-ticket keys are mounted from `D:\weed` (configurable via -`STREAM_KEYS_DIR` in `.env`). Auth works in this mode too (the API downloads the -Zitadel JWKS via `host.docker.internal`). +Fill it in: -> Note: on Linux the apps look for `appsettings*.json` in lowercase. The `Api`/ -> `Worker` projects use `AppSettings*.json` (PascalCase), so their Dockerfiles -> create lowercase copies at publish time. On Windows it did not matter -> (case-insensitive). +1. Replace every `example.com` with your domain and every `CHANGE_ME` with a + random value (`openssl rand -hex 24`; the Zitadel masterkey needs exactly + 32 characters). `chmod 600 deploy/.env.prod`. +2. Drop **`deploy/nginx/certs/origin.pem` and `origin.key`** — the origin + certificate nginx serves. The stack assumes Cloudflare in *Full (strict)* + mode in front of it, so a Cloudflare Origin Certificate is enough; any + certificate valid for the five hostnames works. +3. Point `example.com`, `www`, `api`, `auth`, `stream` and `s3` at the server, + proxied/TLS-terminated the same way. -## Useful commands +Then, same two commands as development, with the prod env file and overlay: -```powershell -./deploy/up.ps1 -SkipMigrations # bring up without touching the DB -./deploy/up.ps1 -Down # stop and remove containers (keep data) -./deploy/up.ps1 -Destroy # stop and remove containers + volumes (wipe data) - -docker compose -f deploy/docker-compose.yml logs -f zitadel -docker compose -f deploy/docker-compose.yml ps +```sh +docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d +docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build ``` -## Project layout — what each file does +The first command starts the infrastructure, the nginx edge and the bootstrap +jobs; the second builds and starts the applications, already wired to the +OIDC client ids the first one produced. Both are idempotent — run them again +to deploy a new version (`docker compose ... --profile apps up -d --build` +alone is enough for an update once the stack already exists; it reuses the +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. + +--- + +## Layout ``` deploy/ -├─ docker-compose.yml # infrastructure stack (single source of truth) -├─ docker-compose.apps.yml # optional overlay: the 3 .NET apps in Docker -├─ .env # local config (secrets/ports); not versioned -├─ .env.example # config template (versioned) -├─ up.ps1 # orchestrator: up / down / destroy -├─ README.md # this file -├─ scripts/ -│ ├─ common.ps1 # shared helpers (Read-DotEnv, Write-Step) -│ ├─ stream-keys.ps1 # generates the RS256 stream-ticket key pair -│ └─ migrate.ps1 # applies EF Core migrations -├─ seaweedfs/ -│ └─ s3.conf # SeaweedFS S3 identities/credentials +├─ 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 +├─ .env.example / .env.prod.example +├─ keys/ # RS256 stream-ticket key pair (generated) +├─ nginx/ +│ ├─ templates/default.conf.template # vhosts, rendered with the domain by envsubst +│ ├─ snippets/proxy.conf # proxy headers shared by every vhost +│ └─ certs/ # origin.pem + origin.key (not versioned) └─ zitadel/ - ├─ provision.ps1 # creates project + OIDC app, wires the ClientId - └─ .output/ # PAT + init secrets written by Zitadel (not versioned) - -backend/ -├─ Musify.Api.Dockerfile # API image -├─ Musify.Worker.Dockerfile # Worker image (includes ffmpeg) -├─ Musify.StreamingGateway.Dockerfile # Gateway image -└─ .dockerignore + └─ .output/ # PAT written by Zitadel on init (not versioned) ``` -### Compose +The application images are built from `backend/Musify.*.Dockerfile` (build +context `backend/`, so restore can copy the central props and every csproj) +and `web-player/Dockerfile`. -- **`docker-compose.yml`** — the infra stack (`postgres`, `zitadel`, `rabbitmq`, - `seaweedfs`, the one-shot `seaweedfs-init` bucket creator, and `jaeger`). This is - the single source of truth for services, ports and volumes. Values come from `.env`. -- **`docker-compose.apps.yml`** — an optional overlay that adds the `api`, `worker` - and `gateway` services. It merges with the infra file (same `musify-dev` project - and network) and shares the backend env via YAML anchors. Used to run the whole - stack in Docker; for active development prefer running the apps from the IDE. +## Notes -### Config - -- **`.env`** — the actual values used by compose (Postgres/RabbitMQ/Zitadel/S3 - credentials, ports, host paths, `AUTH_CLIENT_ID`). Ignored by git (`*.env`). -- **`.env.example`** — the versioned template. Copy it to `.env` (or let `up.ps1` - do it) and adjust. - -### Scripts (PowerShell) - -- **`up.ps1`** — the orchestrator. Parses `.env`, starts the stack, waits for - Postgres, then calls the sub-scripts in order (keys → migrations → provisioning) - and prints the summary. Also handles `-Down` / `-Destroy` / `-SkipMigrations`. -- **`scripts/common.ps1`** — shared helpers dot-sourced by the others: `Read-DotEnv` - (parse `.env` into a hashtable) and `Write-Step` (step logging). -- **`scripts/stream-keys.ps1`** — generates the RS256 key pair that the API uses to - sign stream tickets and the Gateway uses to verify them. No-op if the keys exist. -- **`scripts/migrate.ps1`** — sets the connection string in user-secrets and runs - `dotnet ef database update` (startup project = `Musify.Infrastructure`). -- **`zitadel/provision.ps1`** — reads the service-account PAT, creates/reuses the - Zitadel project and OIDC app (JWT token), and wires the ClientId into - `Musify.Api` user-secrets and `.env`. Idempotent; can be run standalone. - -### Service configs - -- **`seaweedfs/s3.conf`** — the S3 identities (access/secret keys) SeaweedFS loads. - Must match `InfrastructureStorage:*` in the app settings and `S3_*` in `.env`. -- **`zitadel/.output/`** — created at runtime; holds the PAT Zitadel writes on init - (`admin-sa.pat`). Not versioned; wiped by `up.ps1 -Destroy`. - -### Dockerfiles (in `backend/`) - -- **`Musify.Api.Dockerfile`** / **`Musify.Worker.Dockerfile`** / - **`Musify.StreamingGateway.Dockerfile`** — multi-stage builds for each host. The - Worker image also installs ffmpeg. Built by the apps overlay; build context is - `backend/` so restore can copy the central props and all csproj files. -- **`.dockerignore`** — keeps `bin/`, `obj/`, IDE folders and `DesignSettings.json` - out of the build context. +- **S3 credentials** are rendered into the SeaweedFS identity file from + `S3_ACCESS_KEY` / `S3_SECRET_KEY` when the container starts, so they cannot + drift from the ones the apps are given. +- **Zitadel** runs `start-from-init`, which is idempotent: the same command + works on an empty volume and on an existing one. +- **`latest` Zitadel images** require the standalone Login UI v2 container, + which this stack does not run. `zitadel-init` turns that requirement off so + the built-in `/ui/login` is used. +- **`AppSettings*.json` are PascalCase** in `Musify.Api` and `Musify.Worker`; + on case-sensitive Linux the host looks for `appsettings*.json`, so their + Dockerfiles create lowercase copies at publish time. +- **Re-running `zitadel-init` or `migrate`** is safe: the former reuses the + existing project/apps by name (regenerating the web app's secret only if it + is missing from the env file), the latter is a normal `dotnet ef database + update`, a no-op once the schema is current. diff --git a/deploy/compose.dev.yml b/deploy/compose.dev.yml new file mode 100644 index 0000000..519c313 --- /dev/null +++ b/deploy/compose.dev.yml @@ -0,0 +1,85 @@ +name: musify-dev + +# Development overlay: publishes the infrastructure ports to the host so the +# .NET apps and the web player can run from the IDE, and adds pgAdmin. +# +# docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d +# docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build +# +# `host.docker.internal` (see ZITADEL_EXTERNAL_DOMAIN / *_PUBLIC_URL in .env) +# resolves to the same address from the browser, from apps on the host and from +# inside the containers, so the OIDC issuer and the presigned S3 URLs are valid +# in both modes. `extra_hosts` gives Linux containers that same name. + +x-host-alias: &host-alias + - "${ZITADEL_EXTERNAL_DOMAIN}:host-gateway" + +services: + postgres: + ports: + - "${POSTGRES_HOST_PORT}:5432" + + zitadel: + ports: + - "${ZITADEL_EXTERNAL_PORT}:8080" + + rabbitmq: + ports: + - "5672:5672" # AMQP -> MassTransit + - "15672:15672" # management UI + + seaweedfs: + ports: + - "9333:9333" # master / admin UI + - "8888:8888" # filer -> StreamingGateway + - "8333:8333" # S3 API -> API, Worker and presigned uploads + + jaeger: + ports: + - "16686:16686" # UI + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + + # zitadel-init (compose.yml) edits whichever env file is mounted here. + zitadel-init: + volumes: + - ./.env:/workspace/.env + + # Optional Postgres UI: docker compose ... --profile tools up -d + pgadmin: + profiles: [tools] + image: dpage/pgadmin4:latest + container_name: musify-pgadmin + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD} + PGADMIN_CONFIG_SERVER_MODE: "False" + depends_on: + postgres: + condition: service_healthy + ports: + - "${PGADMIN_HOST_PORT}:80" + volumes: + - pgadmin_data:/var/lib/pgadmin + restart: unless-stopped + + api: + ports: + - "5111:5111" + extra_hosts: *host-alias + + worker: + extra_hosts: *host-alias + + gateway: + ports: + - "8081:8081" + extra_hosts: *host-alias + + web-player: + ports: + - "3000:3000" + extra_hosts: *host-alias + +volumes: + pgadmin_data: diff --git a/deploy/compose.prod.yml b/deploy/compose.prod.yml new file mode 100644 index 0000000..344b226 --- /dev/null +++ b/deploy/compose.prod.yml @@ -0,0 +1,50 @@ +name: musify-prod + +# Production overlay: adds the nginx edge and the host-mounted secrets. +# Nothing but nginx publishes a port; every other service is reachable only on +# the internal `musify-prod_default` network. +# +# docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d +# docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build +# +# The first command starts the infrastructure, the nginx edge and the +# one-shot bootstrap jobs (stream keys, migrations, Zitadel project/apps); the +# second builds and starts the applications, picking up the OIDC client ids +# the first command just wrote into deploy/.env.prod. See deploy/README.md. + +services: + # zitadel-init (compose.yml) edits whichever env file is mounted here. + zitadel-init: + volumes: + - ./.env.prod:/workspace/.env + + # Every vhost proxies through `set $upstream ...; proxy_pass $upstream;` + # with the embedded Docker DNS resolver (see nginx/templates), so nginx + # does not need api/gateway/web-player to exist at its own startup — only + # at request time. That is what lets it start in the FIRST command + # (profile-gated services aren't part of that invocation yet) and still be + # reachable at the auth domain for zitadel-init to provision through. + nginx: + image: nginx:1.27-alpine + container_name: musify-nginx + depends_on: + zitadel: + condition: service_started + environment: + # The entrypoint renders /etc/nginx/templates/*.template with envsubst. + # 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} + NGINX_ENVSUBST_FILTER: ^MUSIFY_ + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/templates:/etc/nginx/templates:ro + - ./nginx/snippets:/etc/nginx/snippets:ro + - ./nginx/certs:/etc/nginx/certs:ro + - nginx_cache:/var/cache/nginx + restart: unless-stopped + +volumes: + nginx_cache: diff --git a/deploy/compose.yml b/deploy/compose.yml new file mode 100644 index 0000000..cdb5b6e --- /dev/null +++ b/deploy/compose.yml @@ -0,0 +1,504 @@ +# Musify — base stack, shared by development and production. +# +# This file publishes no host ports and hardcodes no domain: everything that +# differs between environments comes from the .env file (see .env.example) or +# from the overlay that is merged on top of it. +# +# dev docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d +# prod docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d +# +# The four application services sit behind the `apps` profile, so a plain `up` +# starts the infrastructure plus the one-shot bootstrap jobs (stream-ticket +# keys, database migrations, Zitadel project/apps) and stops there — the usual +# dev setup, with the apps running from the IDE. Add `--profile apps up -d +# --build` as a SECOND command to also build and run them in Docker: by then +# zitadel-init has already written the OIDC client ids into the env file, so +# the apps start with real values. See deploy/README.md. + +x-app-depends: &app-depends + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + seaweedfs: + condition: service_healthy + +# Backend settings that are identical for the API and the Worker. ASP.NET Core +# maps `__` to `:`, so these override the matching AppSettings.json sections. +x-backend-env: &backend-env + Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" + MassTransit__Address: "rabbitmq://rabbitmq:5672" + MassTransit__Username: ${RABBITMQ_DEFAULT_USER} + MassTransit__Password: ${RABBITMQ_DEFAULT_PASS} + # The public S3 URL, not the internal one: the same address is used to sign + # the presigned upload/download URLs the browser has to be able to reach. + InfrastructureStorage__Address: ${S3_PUBLIC_URL} + InfrastructureStorage__AccessKey: ${S3_ACCESS_KEY} + InfrastructureStorage__SecretAccessKey: ${S3_SECRET_KEY} + InfrastructureStorage__ForcePathStyle: "true" + InfrastructureStorage__UseHttp: ${S3_USE_HTTP} + ApplicationStorage__Bucket: ${S3_BUCKET} + OpenTelemetry__OtlpEndpoint: "http://jaeger:4317" + +services: + # --- Infrastructure ------------------------------------------------------- + + postgres: + image: postgres:17-alpine + container_name: musify-postgres + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + + # OIDC provider. `start-from-init` runs init + setup (both idempotent) and + # then starts, so the same command works on a cold and on a warm volume. + # It reuses the Postgres above: the admin user creates the `zitadel` database + # and role on first start. + zitadel: + image: ghcr.io/zitadel/zitadel:latest + container_name: musify-zitadel + command: start-from-init --masterkeyFromEnv --tlsMode disabled + environment: + ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY} + # The external domain/port forms the token issuer and must be identical + # for the browser, the apps and the containers (Zitadel routes by Host). + ZITADEL_EXTERNALSECURE: ${ZITADEL_EXTERNAL_SECURE} + ZITADEL_EXTERNALDOMAIN: ${ZITADEL_EXTERNAL_DOMAIN} + ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNAL_PORT} + ZITADEL_TLS_ENABLED: "false" + ZITADEL_DATABASE_POSTGRES_HOST: postgres + ZITADEL_DATABASE_POSTGRES_PORT: 5432 + ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel + ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DATABASE_PASSWORD} + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_USER} + ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_PASSWORD} + ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable + # Initial console admin. Login: @zitadel. + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ZITADEL_ADMIN_USERNAME} + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD} + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false" + # Service account + PAT written on init; the provisioning script uses it + # to create the Musify project and its OIDC apps. + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-admin-sa + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Admin Service Account + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z" + ZITADEL_FIRSTINSTANCE_PATPATH: /output/admin-sa.pat + depends_on: + postgres: + condition: service_healthy + volumes: + - ./zitadel/.output:/output + restart: unless-stopped + + rabbitmq: + image: rabbitmq:4-management-alpine + container_name: musify-rabbitmq + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + + # All-in-one object store: master + volume + filer + S3 gateway. + # The S3 identity file is rendered from the environment on start so the + # credentials can never drift from the ones handed to the apps. + seaweedfs: + image: chrislusf/seaweedfs:latest + container_name: musify-seaweedfs + entrypoint: ["/bin/sh", "-c"] + command: + - | + cat > /tmp/s3.conf <<'JSON' + { + "identities": [ + { + "name": "musify", + "credentials": [{ "accessKey": "${S3_ACCESS_KEY}", "secretKey": "${S3_SECRET_KEY}" }], + "actions": ["Admin", "Read", "Write"] + } + ] + } + JSON + exec weed server \ + -ip.bind=0.0.0.0 \ + -dir=/data \ + -master.volumeSizeLimitMB=1024 \ + -volume.max=0 \ + -filer \ + -s3 \ + -s3.config=/tmp/s3.conf + volumes: + - seaweedfs_data:/data + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9333/cluster/healthz"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + + # One-shot: SeaweedFS does not auto-create buckets. + seaweedfs-init: + image: amazon/aws-cli:latest + container_name: musify-seaweedfs-init + depends_on: + seaweedfs: + condition: service_healthy + environment: + AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY} + AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY} + AWS_DEFAULT_REGION: us-east-1 + entrypoint: ["/bin/sh", "-c"] + command: + - | + aws --endpoint-url http://seaweedfs:8333 s3 mb s3://${S3_BUCKET} 2>/dev/null || true + aws --endpoint-url http://seaweedfs:8333 s3 ls + restart: "no" + + # One-shot: generates the RS256 key pair used to sign (API) and verify + # (Gateway) stream tickets, if it is not already in ./keys. No host tools + # required — openssl runs inside the container. + keys-init: + image: alpine:3.20 + container_name: musify-keys-init + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + if [ -f /keys/stream_private.pem ] && [ -f /keys/stream_public.pem ]; then + echo "stream-ticket keys already present" + else + apk add --no-cache openssl >/dev/null + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /keys/stream_private.pem + openssl rsa -in /keys/stream_private.pem -pubout -out /keys/stream_public.pem + chmod 644 /keys/stream_public.pem + chmod 600 /keys/stream_private.pem + echo "generated stream-ticket keys" + fi + volumes: + - ./keys:/keys + restart: "no" + + # One-shot: applies the EF Core migrations, then exits. See + # backend/Musify.Migrator.Dockerfile. + migrate: + build: + context: ../backend + dockerfile: Musify.Migrator.Dockerfile + container_name: musify-migrate + depends_on: + postgres: + condition: service_healthy + environment: + Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" + restart: "no" + + # One-shot: creates the Musify project and its two OIDC apps in Zitadel (or + # reuses them if they already exist), then writes AUTH_CLIENT_ID, + # WEB_CLIENT_ID and WEB_CLIENT_SECRET back into the env file mounted at + # /workspace/.env (see compose.dev.yml / compose.prod.yml) — so a later + # `--profile apps up` picks up real values instead of empty ones. + zitadel-init: + image: alpine:3.20 + container_name: musify-zitadel-init + depends_on: + zitadel: + condition: service_started + environment: + PUBLIC_AUTH_URL: ${PUBLIC_AUTH_URL} + PUBLIC_API_URL: ${PUBLIC_API_URL} + PUBLIC_WEB_URL: ${PUBLIC_WEB_URL} + ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false} + volumes: + - ./zitadel/.output:/output:ro + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + apk add --no-cache curl jq >/dev/null + + ENV_FILE=/workspace/.env + if [ ! -f "$$ENV_FILE" ]; then + echo "no env file mounted at $$ENV_FILE, skipping" + exit 0 + fi + + # The env file may have CRLF line endings (edited on Windows), so + # strip \r on read and drop it on write — otherwise a "blank" value + # is actually a lone \r, and `[ -z ... ]` lies. + env_get() { grep -E "^$$2=" "$$1" 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '\r'; } + env_set() { + # $$1 is a bind-mounted file: rewrite its content in place rather + # than renaming a temp file over it (rename onto a bind-mounted + # inode fails with "Resource busy"). + if grep -qE "^$$2=" "$$1"; then + awk -v k="$$2" -v v="$$3" '{ sub(/\r$$/, "") } index($$0,k "=")==1{print k "=" v; next} {print}' "$$1" >"$$1.tmp" && cat "$$1.tmp" >"$$1" && rm -f "$$1.tmp" + else + printf '%s=%s\n' "$$2" "$$3" >>"$$1" + fi + } + + echo "==> waiting for the Zitadel service-account PAT" + deadline=$$(( $$(date +%s) + 180 )) + while [ ! -f /output/admin-sa.pat ]; do + [ "$$(date +%s)" -lt "$$deadline" ] || { echo "PAT not found, check 'docker logs musify-zitadel'" >&2; exit 1; } + sleep 3 + done + PAT=$$(tr -d '\r\n' waiting for Zitadel to answer at $$PUBLIC_AUTH_URL" + deadline=$$(( $$(date +%s) + 120 )) + until curl -fsS "$$PUBLIC_AUTH_URL/.well-known/openid-configuration" >/dev/null 2>&1; do + [ "$$(date +%s)" -lt "$$deadline" ] || { echo "Zitadel did not respond at $$PUBLIC_AUTH_URL" >&2; exit 1; } + sleep 3 + done + + api_call() { + if [ $$# -ge 3 ]; then + curl -fsS -X "$$1" "$$MGMT$$2" -H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" -d "$$3" + else + curl -fsS -X "$$1" "$$MGMT$$2" -H "Authorization: Bearer $$PAT" + fi + } + name_query() { printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$$1"; } + + # `latest` images require the standalone Login UI v2 container, which + # this stack does not run; opting out keeps the built-in /ui/login. + curl -fsS -X PUT "$$PUBLIC_AUTH_URL/v2/features/instance" \ + -H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" \ + -d '{"loginV2":{"required":false}}' >/dev/null 2>&1 || echo "warning: could not disable Login UI v2" + + echo "==> project" + PROJECT_NAME=Musify + PROJECT_ID=$$(api_call POST /projects/_search "$$(name_query "$$PROJECT_NAME")" | jq -r --arg n "$$PROJECT_NAME" '.result // [] | map(select(.name==$$n)) | first | .id // empty') + if [ -z "$$PROJECT_ID" ]; then + PROJECT_ID=$$(api_call POST /projects "$$(printf '{"name":"%s"}' "$$PROJECT_NAME")" | jq -r '.id') + echo " created (id $$PROJECT_ID)" + else + echo " reused (id $$PROJECT_ID)" + fi + + find_app() { api_call POST "/projects/$$PROJECT_ID/apps/_search" "$$(name_query "$$1")" | jq -r --arg n "$$1" '.result // [] | map(select(.name==$$n)) | first | .id // empty'; } + + echo "==> API app" + API_APP_NAME="Musify API (Scalar)" + API_APP_ID=$$(find_app "$$API_APP_NAME") + if [ -n "$$API_APP_ID" ]; then + API_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$API_APP_ID" | jq -r '.app.oidcConfig.clientId') + echo " reused (clientId $$API_CLIENT_ID)" + else + API_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(jq -n --arg name "$$API_APP_NAME" --arg api "$$PUBLIC_API_URL" '{ + name: $$name, + redirectUris: [($$api + "/scalar/"), ($$api + "/scalar/oauth2-redirect.html")], + postLogoutRedirectUris: [($$api + "/scalar/")], + responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], + grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"], + appType: "OIDC_APP_TYPE_USER_AGENT", + authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE", + version: "OIDC_VERSION_1_0", + devMode: ($$ENV.ZITADEL_DEV_MODE == "true"), + accessTokenType: "OIDC_TOKEN_TYPE_JWT", + accessTokenRoleAssertion: true, + idTokenRoleAssertion: true, + idTokenUserinfoAssertion: true + }')" | jq -r '.clientId') + echo " created (clientId $$API_CLIENT_ID)" + fi + + echo "==> web app" + WEB_APP_NAME="Musify Web" + WEB_APP_ID=$$(find_app "$$WEB_APP_NAME") + WEB_CLIENT_SECRET=$$(env_get "$$ENV_FILE" WEB_CLIENT_SECRET) + if [ -n "$$WEB_APP_ID" ]; then + WEB_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$WEB_APP_ID" | jq -r '.app.oidcConfig.clientId') + if [ -z "$$WEB_CLIENT_SECRET" ]; then + WEB_CLIENT_SECRET=$$(api_call POST "/projects/$$PROJECT_ID/apps/$$WEB_APP_ID/oidc_config/_generate_client_secret" '{}' | jq -r '.clientSecret') + echo " reused (clientId $$WEB_CLIENT_ID, secret regenerated)" + else + echo " reused (clientId $$WEB_CLIENT_ID)" + fi + else + CREATED=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(jq -n --arg name "$$WEB_APP_NAME" --arg web "$$PUBLIC_WEB_URL" '{ + name: $$name, + redirectUris: [($$web + "/auth/callback")], + postLogoutRedirectUris: [($$web + "/")], + responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], + grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"], + appType: "OIDC_APP_TYPE_WEB", + authMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC", + version: "OIDC_VERSION_1_0", + devMode: ($$ENV.ZITADEL_DEV_MODE == "true"), + accessTokenType: "OIDC_TOKEN_TYPE_JWT", + accessTokenRoleAssertion: true, + idTokenRoleAssertion: true, + idTokenUserinfoAssertion: true + }')") + WEB_CLIENT_ID=$$(echo "$$CREATED" | jq -r '.clientId') + WEB_CLIENT_SECRET=$$(echo "$$CREATED" | jq -r '.clientSecret') + echo " created (clientId $$WEB_CLIENT_ID)" + fi + + echo "==> native app (KMP client: desktop loopback + mobile custom scheme)" + NATIVE_APP_NAME="Musify Native" + NATIVE_APP_ID=$$(find_app "$$NATIVE_APP_NAME") + if [ -n "$$NATIVE_APP_ID" ]; then + NATIVE_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID" | jq -r '.app.oidcConfig.clientId') + echo " reused (clientId $$NATIVE_CLIENT_ID)" + else + NATIVE_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" '{ + "name": "Musify Native", + "redirectUris": ["http://127.0.0.1:17453/callback", "musify://callback"], + "postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "musify://logout"], + "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], + "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"], + "appType": "OIDC_APP_TYPE_NATIVE", + "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE", + "version": "OIDC_VERSION_1_0", + "devMode": true, + "accessTokenType": "OIDC_TOKEN_TYPE_JWT", + "accessTokenRoleAssertion": true, + "idTokenRoleAssertion": true, + "idTokenUserinfoAssertion": true + }' | jq -r '.clientId') + echo " created (clientId $$NATIVE_CLIENT_ID)" + fi + + env_set "$$ENV_FILE" AUTH_CLIENT_ID "$$API_CLIENT_ID" + env_set "$$ENV_FILE" WEB_CLIENT_ID "$$WEB_CLIENT_ID" + env_set "$$ENV_FILE" WEB_CLIENT_SECRET "$$WEB_CLIENT_SECRET" + env_set "$$ENV_FILE" NATIVE_CLIENT_ID "$$NATIVE_CLIENT_ID" + echo "==> wired into $$ENV_FILE" + restart: "no" + + # OTLP collector + trace UI (MassTransit / EF Core / HTTP spans). + jaeger: + image: jaegertracing/all-in-one:latest + container_name: musify-jaeger + environment: + COLLECTOR_OTLP_ENABLED: "true" + restart: unless-stopped + + # --- Applications (profile: apps) ----------------------------------------- + + api: + profiles: [apps] + build: + context: ../backend + dockerfile: Musify.Api.Dockerfile + container_name: musify-api + depends_on: + <<: *app-depends + migrate: + condition: service_completed_successfully + keys-init: + condition: service_completed_successfully + environment: + <<: *backend-env + ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT} + ASPNETCORE_URLS: http://+:5111 + StreamGateway__PublicBaseUrl: ${PUBLIC_STREAM_URL} + StreamTicket__PrivateKeyPath: /keys/stream_private.pem + Workers__Routes__TemporaryFilesDirectory: /tmp/musify + # AUTH_CLIENT_ID is written to the env file by the zitadel-init service. + Authentication__MetadataAddress: "${PUBLIC_AUTH_URL}/.well-known/openid-configuration" + Authentication__IssuerAddress: ${PUBLIC_AUTH_URL} + Authentication__ClientId: ${AUTH_CLIENT_ID} + Authentication__AudienceAddress: ${AUTH_CLIENT_ID} + Authentication__AuthorizationEndpoint: "${PUBLIC_AUTH_URL}/oauth/v2/authorize?prompt=login" + Authentication__TokenEndpoint: "${PUBLIC_AUTH_URL}/oauth/v2/token" + Authentication__ScalarRedirectUri: "${PUBLIC_API_URL}/scalar/" + Authentication__Scopes__0: openid + Authentication__Scopes__1: profile + Authentication__Scopes__2: email + Authentication__RequireHttpsMetadata: ${AUTH_REQUIRE_HTTPS} + volumes: + - ./keys:/keys:ro + restart: unless-stopped + + worker: + profiles: [apps] + build: + context: ../backend + dockerfile: Musify.Worker.Dockerfile + container_name: musify-worker + depends_on: + <<: *app-depends + migrate: + condition: service_completed_successfully + environment: + <<: *backend-env + DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT} + AudioTranscoder__Routes__WorkingDirectory: /tmp/musify + Workers__Routes__TemporaryFilesDirectory: /tmp/musify + restart: unless-stopped + + # YARP in front of the SeaweedFS filer: validates the RS256 stream ticket and + # streams the bytes, so audio never goes through the API. + gateway: + profiles: [apps] + build: + context: ../backend + dockerfile: Musify.StreamingGateway.Dockerfile + container_name: musify-gateway + depends_on: + seaweedfs: + condition: service_healthy + keys-init: + condition: service_completed_successfully + environment: + ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT} + ASPNETCORE_URLS: http://+:8081 + StreamTicket__PublicKeyPath: /keys/stream_public.pem + ReverseProxy__Clusters__seaweed-filer__Destinations__filer__Address: "http://seaweedfs:8888/" + ReverseProxy__Routes__media__Transforms__0__PathPattern: "/buckets/${S3_BUCKET}/{**rest}" + Cors__AllowedOrigins__0: ${PUBLIC_WEB_URL} + volumes: + - ./keys:/keys:ro + restart: unless-stopped + + web-player: + profiles: [apps] + build: + context: ../web-player + dockerfile: Dockerfile + container_name: musify-web-player + depends_on: + - api + environment: + NODE_ENV: production + PORT: 3000 + ORIGIN: ${PUBLIC_WEB_URL} + ZITADEL_ISSUER: ${PUBLIC_AUTH_URL} + ZITADEL_CLIENT_ID: ${WEB_CLIENT_ID} + ZITADEL_CLIENT_SECRET: ${WEB_CLIENT_SECRET} + AUTH_REDIRECT_URI: "${PUBLIC_WEB_URL}/auth/callback" + AUTH_POST_LOGOUT_URI: "${PUBLIC_WEB_URL}/" + AUTH_ALLOW_INSECURE_HTTP: ${AUTH_ALLOW_INSECURE_HTTP} + SESSION_SECRET: ${SESSION_SECRET} + API_BASE_URL: http://api:5111 + # Presigned PUTs are signed for the public S3 host; the container sends + # them straight to SeaweedFS instead of going back out through the edge. + S3_UPLOAD_ENDPOINT: http://seaweedfs:8333 + restart: unless-stopped + +volumes: + postgres_data: + rabbitmq_data: + seaweedfs_data: diff --git a/deploy/docker-compose.apps.yml b/deploy/docker-compose.apps.yml deleted file mode 100644 index 78a6c5f..0000000 --- a/deploy/docker-compose.apps.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: musify-dev - -# Optional overlay: runs the .NET apps in Docker. Merges with docker-compose.yml. -# -# docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.apps.yml up -d --build -# -# For active development prefer infra in Docker + apps from the IDE (easier to -# debug). Auth works in both modes: ZITADEL_EXTERNAL_DOMAIN=host.docker.internal -# resolves the same from the browser, local apps and containers, so the token -# issuer matches everywhere (containers reach the host via extra_hosts). - -x-backend-depends: &backend-depends - postgres: - condition: service_healthy - rabbitmq: - condition: service_healthy - seaweedfs: - condition: service_healthy - -x-backend-env: &backend-env - Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" - MassTransit__Address: "rabbitmq://rabbitmq:5672" - MassTransit__Username: ${RABBITMQ_DEFAULT_USER} - MassTransit__Password: ${RABBITMQ_DEFAULT_PASS} - InfrastructureStorage__Address: "http://seaweedfs:8333" - InfrastructureStorage__AccessKey: ${S3_ACCESS_KEY} - InfrastructureStorage__SecretAccessKey: ${S3_SECRET_KEY} - InfrastructureStorage__ForcePathStyle: "true" - InfrastructureStorage__UseHttp: "true" - ApplicationStorage__Bucket: ${S3_BUCKET} - OpenTelemetry__OtlpEndpoint: "http://jaeger:4317" - -services: - api: - build: - context: ../backend - dockerfile: Musify.Api.Dockerfile - container_name: musify-api - depends_on: *backend-depends - environment: - <<: *backend-env - ASPNETCORE_ENVIRONMENT: Development - ASPNETCORE_URLS: http://+:5111 - StreamGateway__PublicBaseUrl: "http://localhost:8081" - StreamTicket__PrivateKeyPath: "/keys/stream_private.pem" - # Auth values provisioned by zitadel/provision.ps1 (AUTH_CLIENT_ID in .env). - Authentication__MetadataAddress: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}/.well-known/openid-configuration" - Authentication__IssuerAddress: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}" - Authentication__ClientId: "${AUTH_CLIENT_ID}" - Authentication__AudienceAddress: "${AUTH_CLIENT_ID}" - Authentication__AuthorizationEndpoint: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}/oauth/v2/authorize?prompt=login" - Authentication__TokenEndpoint: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}/oauth/v2/token" - Authentication__ScalarRedirectUri: "http://localhost:5111/scalar/" - Authentication__Scopes__0: "openid" - Authentication__Scopes__1: "profile" - Authentication__Scopes__2: "email" - Authentication__RequireHttpsMetadata: "false" - extra_hosts: - - "${ZITADEL_EXTERNAL_DOMAIN}:host-gateway" - volumes: - - "${STREAM_KEYS_DIR:-D:/weed}:/keys:ro" - ports: - - "5111:5111" - restart: unless-stopped - - worker: - build: - context: ../backend - dockerfile: Musify.Worker.Dockerfile - container_name: musify-worker - depends_on: *backend-depends - environment: - <<: *backend-env - DOTNET_ENVIRONMENT: Development - AudioTranscoder__Routes__WorkingDirectory: "/tmp/musify" - Workers__Routes__TemporaryFilesDirectory: "/tmp/musify" - restart: unless-stopped - - gateway: - build: - context: ../backend - dockerfile: Musify.StreamingGateway.Dockerfile - container_name: musify-gateway - depends_on: - seaweedfs: - condition: service_healthy - environment: - ASPNETCORE_ENVIRONMENT: Development - ASPNETCORE_URLS: http://+:8081 - StreamTicket__PublicKeyPath: "/keys/stream_public.pem" - ReverseProxy__Clusters__seaweed-filer__Destinations__filer__Address: "http://seaweedfs:8888/" - volumes: - - "${STREAM_KEYS_DIR:-D:/weed}:/keys:ro" - ports: - - "8081:8081" - restart: unless-stopped diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml deleted file mode 100644 index a427dd9..0000000 --- a/deploy/docker-compose.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: musify-dev - -# Infrastructure stack for Musify development: everything the backend needs -# (Postgres, Zitadel, RabbitMQ, SeaweedFS + bucket, Jaeger/OTLP). The .NET apps -# run from the IDE and connect over the published ports. Config in deploy/.env -# -# docker compose -f deploy/docker-compose.yml up -d - -services: - postgres: - image: postgres:17-alpine - container_name: musify-postgres - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - ports: - - "${POSTGRES_HOST_PORT}:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 10 - restart: unless-stopped - - zitadel: - image: ghcr.io/zitadel/zitadel:latest - container_name: musify-zitadel - command: 'start-from-init --masterkeyFromEnv --tlsMode disabled' - environment: - ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY} - ZITADEL_EXTERNALSECURE: "false" - ZITADEL_EXTERNALDOMAIN: ${ZITADEL_EXTERNAL_DOMAIN} - ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNAL_PORT} - ZITADEL_TLS_ENABLED: "false" - # Reuses the same Postgres: the admin user creates the 'zitadel' database - # and the 'zitadel' user on first start. - ZITADEL_DATABASE_POSTGRES_HOST: postgres - ZITADEL_DATABASE_POSTGRES_PORT: 5432 - ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel - ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel - ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel - ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_USER} - ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_PASSWORD} - ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable - # Initial console admin. Login: @zitadel. - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ZITADEL_ADMIN_USERNAME} - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD} - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false" - # Service account + PAT for automatic provisioning. Zitadel writes the PAT - # to PATPATH on init; provision.ps1 uses it to create the project and app. - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-admin-sa - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Admin Service Account - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z" - ZITADEL_FIRSTINSTANCE_PATPATH: /output/admin-sa.pat - depends_on: - postgres: - condition: service_healthy - ports: - - "${ZITADEL_EXTERNAL_PORT}:8080" - volumes: - - ./zitadel/.output:/output - restart: unless-stopped - - rabbitmq: - image: rabbitmq:4-management-alpine - container_name: musify-rabbitmq - environment: - RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} - RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} - ports: - - "5672:5672" # AMQP -> MassTransit (API + Worker) - - "15672:15672" # management UI - volumes: - - rabbitmq_data:/var/lib/rabbitmq - healthcheck: - test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] - interval: 10s - timeout: 5s - retries: 10 - restart: unless-stopped - - seaweedfs: - image: chrislusf/seaweedfs:latest - container_name: musify-seaweedfs - # All-in-one dev server: master + volume + filer + S3 gateway. - command: > - server - -ip.bind=0.0.0.0 - -dir=/data - -master.volumeSizeLimitMB=1024 - -volume.max=0 - -filer - -s3 - -s3.config=/etc/seaweedfs/s3.conf - ports: - - "9333:9333" # master (admin/UI) - - "8888:8888" # filer HTTP -> StreamingGateway - - "8333:8333" # S3 API -> WebApi/Worker (InfrastructureStorage:Address) - volumes: - - seaweedfs_data:/data - - ./seaweedfs/s3.conf:/etc/seaweedfs/s3.conf:ro - healthcheck: - test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9333/cluster/healthz"] - interval: 10s - timeout: 5s - retries: 10 - restart: unless-stopped - - # One-shot: creates the app bucket in SeaweedFS (it is not auto-created). - seaweedfs-init: - image: amazon/aws-cli:latest - container_name: musify-seaweedfs-init - depends_on: - seaweedfs: - condition: service_healthy - environment: - AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY} - AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY} - AWS_DEFAULT_REGION: us-east-1 - entrypoint: ["/bin/sh", "-c"] - command: - - | - aws --endpoint-url http://seaweedfs:8333 s3 mb s3://${S3_BUCKET} 2>/dev/null || true - aws --endpoint-url http://seaweedfs:8333 s3 ls - restart: "no" - - # OTLP collector + trace UI (MassTransit/EF/HTTP spans). OtlpEndpoint = :4317 - jaeger: - image: jaegertracing/all-in-one:latest - container_name: musify-jaeger - environment: - COLLECTOR_OTLP_ENABLED: "true" - ports: - - "16686:16686" # UI - - "4317:4317" # OTLP gRPC - - "4318:4318" # OTLP HTTP - restart: unless-stopped - -volumes: - postgres_data: - rabbitmq_data: - seaweedfs_data: diff --git a/deploy/nginx/snippets/proxy.conf b/deploy/nginx/snippets/proxy.conf new file mode 100644 index 0000000..934e050 --- /dev/null +++ b/deploy/nginx/snippets/proxy.conf @@ -0,0 +1,9 @@ +# Shared proxy settings for every Musify vhost. +proxy_http_version 1.1; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto https; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection $connection_upgrade; diff --git a/deploy/nginx/templates/default.conf.template b/deploy/nginx/templates/default.conf.template new file mode 100644 index 0000000..ed2ac8a --- /dev/null +++ b/deploy/nginx/templates/default.conf.template @@ -0,0 +1,119 @@ +# Musify edge. The nginx entrypoint renders this with envsubst into +# /etc/nginx/conf.d/default.conf (replacing the image's stock file) using the +# MUSIFY_* variables set in compose.prod.yml. +# +# Cloudflare (Full strict) terminates the public edge; this box only needs to +# serve the Cloudflare Origin Certificate in nginx/certs/. Each vhost proxies to +# a compose service by name through the embedded Docker DNS resolver, so nginx +# re-resolves it instead of caching the IP of a container that may be recreated. + +resolver 127.0.0.11 valid=10s; + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + listen [::]:80; + server_name ${MUSIFY_ROOT_DOMAIN} www.${MUSIFY_ROOT_DOMAIN} + api.${MUSIFY_ROOT_DOMAIN} auth.${MUSIFY_ROOT_DOMAIN} + stream.${MUSIFY_ROOT_DOMAIN} s3.${MUSIFY_ROOT_DOMAIN}; + return 301 https://$host$request_uri; +} + +# Web player (SvelteKit, adapter-node). Uploads are posted to its own form +# actions, so it needs the large body size too. +server { + listen 443 ssl; + http2 on; + server_name ${MUSIFY_ROOT_DOMAIN}; + client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE}; + + ssl_certificate /etc/nginx/certs/origin.pem; + ssl_certificate_key /etc/nginx/certs/origin.key; + + location / { + set $upstream http://web-player:3000; + proxy_pass $upstream; + include /etc/nginx/snippets/proxy.conf; + } +} + +server { + listen 443 ssl; + http2 on; + server_name www.${MUSIFY_ROOT_DOMAIN}; + + ssl_certificate /etc/nginx/certs/origin.pem; + ssl_certificate_key /etc/nginx/certs/origin.key; + + return 301 https://${MUSIFY_ROOT_DOMAIN}$request_uri; +} + +server { + listen 443 ssl; + http2 on; + server_name api.${MUSIFY_ROOT_DOMAIN}; + + ssl_certificate /etc/nginx/certs/origin.pem; + ssl_certificate_key /etc/nginx/certs/origin.key; + + location / { + set $upstream http://api:5111; + proxy_pass $upstream; + include /etc/nginx/snippets/proxy.conf; + } +} + +# Audio bytes. Buffering off keeps range requests (seeking) responsive. +server { + listen 443 ssl; + http2 on; + server_name stream.${MUSIFY_ROOT_DOMAIN}; + + ssl_certificate /etc/nginx/certs/origin.pem; + ssl_certificate_key /etc/nginx/certs/origin.key; + + location / { + set $upstream http://gateway:8081; + proxy_pass $upstream; + include /etc/nginx/snippets/proxy.conf; + proxy_buffering off; + } +} + +server { + listen 443 ssl; + http2 on; + server_name auth.${MUSIFY_ROOT_DOMAIN}; + + ssl_certificate /etc/nginx/certs/origin.pem; + ssl_certificate_key /etc/nginx/certs/origin.key; + + location / { + set $upstream http://zitadel:8080; + proxy_pass $upstream; + include /etc/nginx/snippets/proxy.conf; + grpc_set_header Host $host; + } +} + +# Presigned S3 PUT/GET straight from the browser. +server { + listen 443 ssl; + http2 on; + server_name s3.${MUSIFY_ROOT_DOMAIN}; + client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE}; + + ssl_certificate /etc/nginx/certs/origin.pem; + ssl_certificate_key /etc/nginx/certs/origin.key; + + location / { + set $upstream http://seaweedfs:8333; + proxy_pass $upstream; + include /etc/nginx/snippets/proxy.conf; + proxy_request_buffering off; + } +} diff --git a/deploy/scripts/common.ps1 b/deploy/scripts/common.ps1 deleted file mode 100644 index 5cf2123..0000000 --- a/deploy/scripts/common.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -#requires -Version 5.1 -# Shared helpers for the deploy scripts. - -function Write-Step([string]$Message) { Write-Host "`n==> $Message" -ForegroundColor Cyan } - -# Parses a .env file into a hashtable (ignores comments and blank lines). -function Read-DotEnv([string]$Path) { - $values = @{} - if (Test-Path $Path) { - Get-Content $Path | ForEach-Object { - if ($_ -match '^\s*([^#=\s][^=]*)=(.*)$') { $values[$matches[1].Trim()] = $matches[2].Trim() } - } - } - return $values -} diff --git a/deploy/scripts/migrate.ps1 b/deploy/scripts/migrate.ps1 deleted file mode 100644 index 5b6449e..0000000 --- a/deploy/scripts/migrate.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -#requires -Version 5.1 -# Applies EF Core migrations to the Musify database. -[CmdletBinding()] -param([Parameter(Mandatory)][string]$ConnectionString) - -$ErrorActionPreference = "Stop" -$infraProject = Resolve-Path (Join-Path $PSScriptRoot "..\..\backend\Musify.Infrastructure") - -# The design-time factory reads the connection string from user-secrets. -dotnet user-secrets set "Database:ConnectionString" $ConnectionString --project $infraProject | Out-Null - -# Infrastructure is also the startup project: it owns the IDesignTimeDbContextFactory -# and the EFCore.Design package (Musify.Api does not reference Design). -dotnet ef database update --project $infraProject --startup-project $infraProject --context Database -if ($LASTEXITCODE -ne 0) { throw "EF Core migration failed" } diff --git a/deploy/scripts/stream-keys.ps1 b/deploy/scripts/stream-keys.ps1 deleted file mode 100644 index 50d260a..0000000 --- a/deploy/scripts/stream-keys.ps1 +++ /dev/null @@ -1,28 +0,0 @@ -#requires -Version 5.1 -# Generates the RS256 key pair used to sign (API) and verify (Gateway) stream tickets. -# No-op if both keys already exist. -[CmdletBinding()] -param([Parameter(Mandatory)][string]$KeysDir) - -$ErrorActionPreference = "Stop" -$privateKey = Join-Path $KeysDir "stream_private.pem" -$publicKey = Join-Path $KeysDir "stream_public.pem" - -if ((Test-Path $privateKey) -and (Test-Path $publicKey)) { - Write-Host "Stream-ticket keys already present in $KeysDir" - return -} - -if (-not (Test-Path $KeysDir)) { New-Item -ItemType Directory -Path $KeysDir | Out-Null } - -if (Get-Command openssl -ErrorAction SilentlyContinue) { - & openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out $privateKey - & openssl rsa -in $privateKey -pubout -out $publicKey -} else { - $rsa = [System.Security.Cryptography.RSA]::Create(2048) - [System.IO.File]::WriteAllText($privateKey, $rsa.ExportPkcs8PrivateKeyPem()) - [System.IO.File]::WriteAllText($publicKey, $rsa.ExportSubjectPublicKeyInfoPem()) - $rsa.Dispose() -} - -Write-Host "Generated stream-ticket keys in $KeysDir" diff --git a/deploy/seaweedfs/s3.conf b/deploy/seaweedfs/s3.conf deleted file mode 100644 index dc0ed2a..0000000 --- a/deploy/seaweedfs/s3.conf +++ /dev/null @@ -1,24 +0,0 @@ -{ - "identities": [ - { - "name": "admin_user", - "credentials": [ - { - "accessKey": "admin_access_key", - "secretKey": "admin_secret_key" - } - ], - "actions": ["Admin", "Read", "Write"] - }, - { - "name": "read_only_user", - "credentials": [ - { - "accessKey": "readonly_access_key", - "secretKey": "readonly_secret_key" - } - ], - "actions": ["Read"] - } - ] -} diff --git a/deploy/up.ps1 b/deploy/up.ps1 deleted file mode 100644 index f87f187..0000000 --- a/deploy/up.ps1 +++ /dev/null @@ -1,121 +0,0 @@ -#requires -Version 5.1 -<# -.SYNOPSIS - Brings up the Musify development environment. - -.DESCRIPTION - Starts the infrastructure stack, generates the stream-ticket keys, applies EF - Core migrations and provisions Zitadel (project + OIDC app + ClientId). - -.EXAMPLE - ./up.ps1 # Bring everything up - ./up.ps1 -SkipMigrations # Skip the database migration step - ./up.ps1 -Down # Stop and remove containers (keep volumes) - ./up.ps1 -Destroy # Stop and remove containers + volumes (wipe data) -#> -[CmdletBinding()] -param( - [switch]$Down, - [switch]$Destroy, - [switch]$SkipMigrations -) - -$ErrorActionPreference = "Stop" -$deployDir = $PSScriptRoot -$composeFile = Join-Path $deployDir "docker-compose.yml" - -. (Join-Path $deployDir "scripts\common.ps1") - -# Teardown ------------------------------------------------------------------- -if ($Down -or $Destroy) { - if ($Destroy) { - Write-Step "Stopping the stack and REMOVING volumes (data is wiped)" - docker compose -f $composeFile down -v --remove-orphans - # Drop the Zitadel init secrets (PAT) so the next start re-initializes cleanly. - Get-ChildItem (Join-Path $deployDir "zitadel\.output") -File -ErrorAction SilentlyContinue | Remove-Item -Force - } else { - Write-Step "Stopping the stack (volumes are kept)" - docker compose -f $composeFile down --remove-orphans - } - return -} - -# Config --------------------------------------------------------------------- -$envFile = Join-Path $deployDir ".env" -if (-not (Test-Path $envFile)) { - Write-Step "Creating .env from .env.example" - Copy-Item (Join-Path $deployDir ".env.example") $envFile -} -$cfg = Read-DotEnv $envFile -$streamKeysDir = if ($cfg['STREAM_KEYS_DIR']) { $cfg['STREAM_KEYS_DIR'] } else { "D:/weed" } -$workerTempDir = if ($cfg['WORKER_TEMP_DIR']) { $cfg['WORKER_TEMP_DIR'] } else { "D:/tempsFilesDev" } - -# Infrastructure ------------------------------------------------------------- -Write-Step "Starting the infrastructure stack" -docker compose -f $composeFile up -d -if ($LASTEXITCODE -ne 0) { throw "docker compose up failed" } - -Write-Step "Waiting for Postgres to become healthy" -$deadline = (Get-Date).AddMinutes(2) -do { - Start-Sleep -Seconds 2 - $status = docker inspect --format '{{.State.Health.Status}}' musify-postgres 2>$null -} until ($status -eq "healthy" -or (Get-Date) -gt $deadline) -if ($status -ne "healthy") { throw "Postgres did not become healthy in time" } - -# Stream-ticket keys --------------------------------------------------------- -Write-Step "Ensuring stream-ticket keys" -& (Join-Path $deployDir "scripts\stream-keys.ps1") -KeysDir $streamKeysDir - -# Worker temp directory ------------------------------------------------------ -if (-not (Test-Path $workerTempDir)) { - Write-Step "Creating the Worker temp directory: $workerTempDir" - New-Item -ItemType Directory -Path $workerTempDir | Out-Null -} - -# Database migrations -------------------------------------------------------- -if ($SkipMigrations) { - Write-Host "`nSkipping migrations (-SkipMigrations)." -} else { - Write-Step "Applying EF Core migrations" - $conn = "Host=localhost;Port=$($cfg['POSTGRES_HOST_PORT']);Database=$($cfg['POSTGRES_DB']);Username=$($cfg['POSTGRES_USER']);Password=$($cfg['POSTGRES_PASSWORD'])" - & (Join-Path $deployDir "scripts\migrate.ps1") -ConnectionString $conn -} - -# Zitadel provisioning ------------------------------------------------------- -Write-Step "Provisioning Zitadel (project + OIDC app + ClientId)" -try { - & (Join-Path $deployDir "zitadel\provision.ps1") -} catch { - Write-Host "Warning: Zitadel provisioning failed: $($_.Exception.Message)" -ForegroundColor Yellow -} - -# Summary -------------------------------------------------------------------- -$zDomain = $cfg['ZITADEL_EXTERNAL_DOMAIN'] -$zPort = $cfg['ZITADEL_EXTERNAL_PORT'] -Write-Host @" - -============================================================ - Musify development environment READY -============================================================ - - Service URL / port - ------------------ -------------------------------------- - PostgreSQL localhost:$($cfg['POSTGRES_HOST_PORT']) (db: $($cfg['POSTGRES_DB'])) - Zitadel console http://$zDomain`:$zPort/ui/console - RabbitMQ mgmt http://localhost:15672 ($($cfg['RABBITMQ_DEFAULT_USER'])/$($cfg['RABBITMQ_DEFAULT_PASS'])) - SeaweedFS S3 http://localhost:8333 (bucket: $($cfg['S3_BUCKET'])) - SeaweedFS filer http://localhost:8888 - SeaweedFS master http://localhost:9333 - Jaeger traces http://localhost:16686 (OTLP: localhost:4317) - - Zitadel admin: $($cfg['ZITADEL_ADMIN_USERNAME'])@zitadel.$zDomain / $($cfg['ZITADEL_ADMIN_PASSWORD']) - Auth is provisioned automatically; ClientId is in Musify.Api user-secrets and .env. - Log in via Scalar: http://localhost:5111/scalar/v1 - - Run the apps from the IDE or: - dotnet run --project backend/Musify.Api # :5111 - dotnet run --project backend/Musify.StreamingGateway # :8081 - dotnet run --project backend/Musify.Worker # background -============================================================ -"@ -ForegroundColor Green diff --git a/deploy/zitadel/.gitignore b/deploy/zitadel/.gitignore deleted file mode 100644 index cb93576..0000000 --- a/deploy/zitadel/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Secrets Zitadel writes on init (do not version) -.output/ diff --git a/deploy/zitadel/provision.ps1 b/deploy/zitadel/provision.ps1 deleted file mode 100644 index fdddf5a..0000000 --- a/deploy/zitadel/provision.ps1 +++ /dev/null @@ -1,121 +0,0 @@ -#requires -Version 5.1 -<# -.SYNOPSIS - Provisions Zitadel for Musify (idempotent). - -.DESCRIPTION - Creates the project and the OIDC app (PKCE, JWT access token) and wires the - generated ClientId into Musify.Api user-secrets and deploy/.env (AUTH_CLIENT_ID). - Uses the service-account PAT that Zitadel writes on init (zitadel/.output/admin-sa.pat). - Re-running reuses the existing project/app and refreshes the wired values. -#> -[CmdletBinding()] -param( - [string]$ProjectName = "Musify", - [string]$AppName = "Musify API (Scalar)" -) - -$ErrorActionPreference = "Stop" -$deployDir = Split-Path $PSScriptRoot -Parent -$apiProject = Resolve-Path (Join-Path $deployDir "..\backend\Musify.Api") -$patFile = Join-Path $PSScriptRoot ".output\admin-sa.pat" - -. (Join-Path $deployDir "scripts\common.ps1") - -$cfg = Read-DotEnv (Join-Path $deployDir ".env") -$issuer = "http://$($cfg['ZITADEL_EXTERNAL_DOMAIN']):$($cfg['ZITADEL_EXTERNAL_PORT'])" -$apiBase = "http://localhost:5111" -$scalarRedirect = "$apiBase/scalar/" - -# Zitadel writes the PAT during init; on a cold start it may take a moment. -$deadline = (Get-Date).AddMinutes(3) -while (-not (Test-Path $patFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 3 } -if (-not (Test-Path $patFile)) { throw "PAT not found ($patFile). Check 'docker logs musify-zitadel'." } - -$headers = @{ Authorization = "Bearer $((Get-Content $patFile -Raw).Trim())" } -$mgmt = "$issuer/management/v1" - -function Invoke-Zitadel($method, $path, $body) { - $params = @{ Method = $method; Uri = "$mgmt$path"; Headers = $headers; ContentType = "application/json"; TimeoutSec = 20 } - if ($body) { $params.Body = ($body | ConvertTo-Json -Depth 10) } - Invoke-RestMethod @params -} - -Write-Step "Waiting for Zitadel at $issuer" -$deadline = (Get-Date).AddMinutes(2) -do { - try { Invoke-RestMethod "$issuer/.well-known/openid-configuration" -TimeoutSec 5 | Out-Null; $ready = $true } - catch { $ready = $false; Start-Sleep 3 } -} until ($ready -or (Get-Date) -gt $deadline) -if (-not $ready) { throw "Zitadel did not respond in time." } - -# Project (idempotent) ------------------------------------------------------- -Write-Step "Project '$ProjectName'" -$project = (Invoke-Zitadel POST "/projects/_search" @{ - queries = @(@{ nameQuery = @{ name = $ProjectName; method = "TEXT_QUERY_METHOD_EQUALS" } }) -}).result | Where-Object { $_.name -eq $ProjectName } | Select-Object -First 1 - -if ($project) { - $projectId = $project.id - Write-Host " reused (id $projectId)" -} else { - $projectId = (Invoke-Zitadel POST "/projects" @{ name = $ProjectName }).id - Write-Host " created (id $projectId)" -} - -# OIDC app (idempotent) ------------------------------------------------------ -Write-Step "OIDC app '$AppName'" -$app = (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{ - queries = @(@{ nameQuery = @{ name = $AppName; method = "TEXT_QUERY_METHOD_EQUALS" } }) -}).result | Where-Object { $_.name -eq $AppName } | Select-Object -First 1 - -if ($app) { - $clientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($app.id)" $null).app.oidcConfig.clientId - Write-Host " reused (clientId $clientId)" -} else { - $clientId = (Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{ - name = $AppName - redirectUris = @($scalarRedirect, "$apiBase/scalar/oauth2-redirect.html") - postLogoutRedirectUris = @($scalarRedirect) - responseTypes = @("OIDC_RESPONSE_TYPE_CODE") - grantTypes = @("OIDC_GRANT_TYPE_AUTHORIZATION_CODE") - appType = "OIDC_APP_TYPE_USER_AGENT" - authMethodType = "OIDC_AUTH_METHOD_TYPE_NONE" - version = "OIDC_VERSION_1_0" - devMode = $true # allow http redirect URIs - accessTokenType = "OIDC_TOKEN_TYPE_JWT" # required so JwtBearer can validate - accessTokenRoleAssertion = $true - idTokenRoleAssertion = $true - idTokenUserinfoAssertion = $true - }).clientId - Write-Host " created (clientId $clientId)" -} - -# Wire the config: user-secrets (local runs) + .env (docker runs) ------------- -Write-Step "Wiring Authentication into Musify.Api user-secrets" -@{ - "Authentication:ClientId" = $clientId - "Authentication:AudienceAddress" = $clientId - "Authentication:IssuerAddress" = $issuer - "Authentication:MetadataAddress" = "$issuer/.well-known/openid-configuration" - "Authentication:AuthorizationEndpoint" = "$issuer/oauth/v2/authorize?prompt=login" - "Authentication:TokenEndpoint" = "$issuer/oauth/v2/token" - "Authentication:ScalarRedirectUri" = $scalarRedirect - "Authentication:RequireHttpsMetadata" = "false" -}.GetEnumerator() | ForEach-Object { - dotnet user-secrets set $_.Key $_.Value --project $apiProject | Out-Null -} - -$envPath = Join-Path $deployDir ".env" -$envLines = Get-Content $envPath -if ($envLines -match '^\s*AUTH_CLIENT_ID=') { - $envLines = $envLines -replace '^\s*AUTH_CLIENT_ID=.*', "AUTH_CLIENT_ID=$clientId" -} else { - $envLines += "AUTH_CLIENT_ID=$clientId" -} -Set-Content -Path $envPath -Value $envLines -Encoding utf8 - -Write-Host "`nZitadel provisioned:" -ForegroundColor Green -Write-Host " Project: $ProjectName ($projectId)" -Write-Host " ClientId: $clientId" -Write-Host " Issuer: $issuer" diff --git a/docs/README.md b/docs/README.md index 963c0d0..316215f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,12 +13,13 @@ Esta carpeta explica **qué hace cada pieza, su propósito y por qué está dise - [streaming.md](streaming.md) — Streaming del audio (`.m4a` por HTTP range) con stream-ticket (RS256) y el reverse proxy (StreamingGateway). - [media-processing.md](media-processing.md) — El worker y los workflows de MassTransit (transcode de audio, miniaturas). - [development.md](development.md) — Cómo levantar todo en local: contenedores, puertos y comandos. +- [../deploy/README.md](../deploy/README.md) — El deploy en detalle: compose, scripts y producción. ## Mapa de componentes (alto nivel) ``` ┌─────────────────────────────────────────────┐ - Cliente ───► │ WebApi (:5111) API REST + emite tickets │ + Cliente ───► │ Musify.Api (:5111) API REST + emite tickets │ └─────────────────────────────────────────────┘ │ EF Core │ presigned (subidas) │ MassTransit (eventos) ▼ ▼ ▼ @@ -27,19 +28,20 @@ Esta carpeta explica **qué hace cada pieza, su propósito y por qué está dise ▲ Cliente ───► StreamingGateway (:8081) ┘ sirve audio .m4a (valida ticket, proxy al filer) - Login: Cliente ◄──► Zitadel (:8080, OIDC) → WebApi valida el JWT + Login: Cliente ◄──► Zitadel (:8080, OIDC) → Musify.Api valida el JWT ``` ## Stack -- **.NET 10**, C#. Minimal APIs (WebApi), Worker Service (Worker), ASP.NET + YARP (StreamingGateway). -- **CQRS** con [Mediator] (source-generator) y resultados con **Ardalis.Result**. +- **.NET 10**, C#. Minimal APIs (`Musify.Api`), Worker Service (`Musify.Worker`), ASP.NET + YARP (`Musify.StreamingGateway`). +- **CQRS** con [Mediator] (source-generator) y resultados con **ErrorOr**. - **EF Core 10** + **PostgreSQL** (metadatos). - **MassTransit 8** + **RabbitMQ** (procesado asíncrono con routing slips/sagas). - **SeaweedFS** (almacenamiento de objetos compatible con S3, vía AWSSDK.S3). - **ffmpeg** (transcode de audio a AAC/`.m4a`), **SixLabors.ImageSharp** (miniaturas). - **Zitadel** (OIDC/OAuth2) para identidad. -## Repositorios git +## Estructura del repositorio -Cada proyecto de nivel superior es su propio repo git: `MusifyBackend`, `Musify.WebApi`, `Musify.StreamingGateway`, `Musify.Worker`. La raíz del workspace (`docs/`, `deploy/`, `player.html`) no está versionada. +Un solo repo: `backend/` (los seis proyectos .NET), `web-player/` (cliente web +SvelteKit), `deploy/` (stacks de Docker Compose y scripts) y `docs/`. diff --git a/docs/architecture.md b/docs/architecture.md index 9226e1c..bff0579 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,17 +2,17 @@ ## Principios -- **Clean architecture / capas** en `MusifyBackend`: el dominio y la aplicación no dependen de infraestructura; la infraestructura implementa las interfaces (`Application/Contracts`). -- **CQRS**: cada operación es un `Command` o `Query` con su handler (Mediator). Los handlers devuelven `Ardalis.Result` (Ok/NotFound/Unauthorized/Conflict/Invalid…), que la WebApi traduce a códigos HTTP. -- **Los bytes pesados no pasan por la API**: subidas con URLs prefirmadas (cliente → S3) y reproducción vía un reverse proxy dedicado (gateway → S3). La WebApi solo mueve metadatos y emite permisos. +- **Clean architecture / capas** en `backend/`: el dominio y la aplicación no dependen de infraestructura; la infraestructura implementa las interfaces (`Application/Contracts`). +- **CQRS**: cada operación es un `Command` o `Query` con su handler (Mediator). Los handlers devuelven `Ardalis.Result` (Ok/NotFound/Unauthorized/Conflict/Invalid…), que la API traduce a códigos HTTP. +- **Los bytes pesados no pasan por la API**: subidas con URLs prefirmadas (cliente → S3) y reproducción vía un reverse proxy dedicado (gateway → S3). La API solo mueve metadatos y emite permisos. - **Procesado asíncrono**: crear una pista no transcodifica en caliente; publica un evento y un worker hace el trabajo pesado con compensación (sagas/routing slips de MassTransit). ## Componentes | Componente | Responsabilidad | Por qué separado | |---|---|---| -| **WebApi** | API REST (usuarios, pistas, playlists), autoriza, emite presigned URLs y stream-tickets | Punto de entrada fino; no procesa medios ni sirve bytes | -| **MusifyBackend** | Dominio + casos de uso + infraestructura compartida (EF, S3, MassTransit, ffmpeg) | Lógica reutilizada por WebApi y Worker | +| **Musify.Api** | API REST (usuarios, pistas, playlists), autoriza, emite presigned URLs y stream-tickets | Punto de entrada fino; no procesa medios ni sirve bytes | +| **Domain/Application/Infrastructure** | Dominio + casos de uso + infraestructura compartida (EF, S3, MassTransit, ffmpeg) | Lógica reutilizada por la API y el Worker | | **StreamingGateway** | Reverse proxy (YARP) que valida el ticket y sirve el audio (`.m4a`) desde SeaweedFS | Escala con el tráfico de bytes, independiente de la API; SeaweedFS queda privado detrás | | **Worker** | Consume eventos de MassTransit: transcodifica audio a `.m4a` y genera miniaturas | Trabajo CPU/IO intensivo fuera del request HTTP | | **PostgreSQL** | Metadatos (usuarios, pistas, playlists, upload intents) | — | @@ -23,15 +23,15 @@ ## Flujos principales ### 1. Subida de una pista -1. Cliente pide `POST /tracks/upload-urls` → la WebApi crea **UploadIntents** (reservas) y devuelve **URLs prefirmadas PUT** (imagen + audio). +1. Cliente pide `POST /tracks/upload-urls` → la API crea **UploadIntents** (reservas) y devuelve **URLs prefirmadas PUT** (imagen + audio). 2. Cliente sube los ficheros **directo a SeaweedFS** con esas URLs (no pasan por la API). -3. Cliente llama `POST /tracks` con los `intentId`s → la WebApi valida los intents, crea el `Track` (+ `UserHasTrack`) y publica `CreateTrackResourcesEvent`. +3. Cliente llama `POST /tracks` con los `intentId`s → la API valida los intents, crea el `Track` (+ `UserHasTrack`) y publica `CreateTrackResourcesEvent`. 4. El **Worker** consume el evento: mueve los originales a su sitio, genera miniaturas y transcodifica el audio a `.m4a`, y actualiza el estado del track. Ver detalle en [media-processing.md](media-processing.md) y [storage.md](storage.md). ### 2. Reproducción (streaming) -1. Cliente pide `GET /tracks/{id}/stream` (autenticado) → la WebApi autoriza y devuelve `{ manifestUrl, ticket }` (URL al `.m4a` + ticket RS256 acotado a la carpeta de esa pista). +1. Cliente pide `GET /tracks/{id}/stream` (autenticado) → la API autoriza y devuelve `{ manifestUrl, ticket }` (URL al `.m4a` + ticket RS256 acotado a la carpeta de esa pista). 2. El reproductor abre la URL del audio añadiendo `?t=` (o cabecera `X-Stream-Ticket`) y descarga por HTTP range. 3. El **StreamingGateway** valida el ticket y reenvía al filer de SeaweedFS; los bytes van storage → cliente. @@ -39,7 +39,7 @@ Ver [streaming.md](streaming.md). ### 3. Autenticación 1. Cliente hace login OIDC contra **Zitadel** y obtiene un access token (JWT). -2. La WebApi valida la firma/iss/aud del JWT. +2. La Musify.Api valida la firma/iss/aud del JWT. 3. En cada token validado, se **sincroniza** el usuario local (crea/actualiza nombre y foto) — provisioning just-in-time. Ver [authentication.md](authentication.md). diff --git a/docs/authentication.md b/docs/authentication.md index 0529118..372d043 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -2,7 +2,7 @@ ## Proveedor: Zitadel (OIDC/OAuth2) -El login y la emisión de tokens los hace **Zitadel** (no la WebApi). La WebApi es un **resource server**: solo **valida** el access token (JWT) que le llega en `Authorization: Bearer`. +El login y la emisión de tokens los hace **Zitadel** (no la API). La API es un **resource server**: solo **valida** el access token (JWT) que le llega en `Authorization: Bearer`. - Config en la sección `Authentication` (`AuthenticationConfiguration`): metadata address, issuer, audience, client id, endpoints de authorize/token, scopes. - `JwtBearerOptionsSetup` configura la validación (issuer, audience, claves vía metadata OIDC). diff --git a/docs/development.md b/docs/development.md index 93d0c9a..1acbc55 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,92 +1,151 @@ # Entorno de desarrollo -Cómo levantar todo en local y probarlo. +Cómo levantar todo en local y probarlo. El detalle completo del deploy (y el de +producción) está en [deploy/README.md](../deploy/README.md). -## Dependencias (Docker) +## Arranque -Todo (PostgreSQL, Zitadel, RabbitMQ, SeaweedFS, Jaeger) vive en un solo stack en -[`deploy/`](../deploy/README.md). Bootstrap completo (levanta + bucket + claves + migraciones): - -```powershell -./deploy/up.ps1 +```sh +docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d ``` -O solo la infraestructura: +Ese único comando levanta la infraestructura y corre los jobs *one-shot* de +bootstrap: genera las claves RS256 de stream-ticket en `deploy/keys/`, aplica +las migraciones EF Core y provisiona Zitadel (proyecto + apps OIDC), dejando +los client ids en `deploy/.env`. Detalle completo, incluido cómo arrancar las +apps en Docker con `--profile apps`, en [deploy/README.md](../deploy/README.md). + +## Aplicaciones ```powershell -docker compose -f deploy\docker-compose.yml up -d -``` - -Config (usuarios/claves/puertos) en `deploy/.env`. Ver [deploy/README.md](../deploy/README.md) -para el detalle y el paso de configuración de Zitadel (crear la app OIDC y copiar el ClientId). - -## Aplicaciones (.NET) - -```powershell -dotnet run --project Musify.WebApi\WebApi # :5111 -dotnet run --project Musify.StreamingGateway\StreamingGateway # :8081 -dotnet run --project Musify.Worker\Worker # background +dotnet run --project backend/Hosts/Musify.Api # :5111 +dotnet run --project backend/Hosts/Musify.StreamingGateway # :8081 +dotnet run --project backend/Hosts/Musify.Worker # background +npm --prefix web-player run dev # :5173 ``` ## Mapa de puertos | Servicio | Puerto | |---|---| -| WebApi | 5111 (`/scalar/v1`, `/openapi/v1.json`) | -| StreamingGateway | 8081 (`/health`, `/media/...`) | +| Musify.Api | 5111 (`/scalar/v1`, `/openapi/v1.json`) | +| Musify.StreamingGateway | 8081 (`/health`, `/media/...`) | +| web-player | 5173 (`npm run dev`) o 3000 (contenedor) | | SeaweedFS S3 / filer / master | 8333 / 8888 / 9333 | | Zitadel | 8080 | | PostgreSQL | 59000 (→ 5432 en el contenedor) | | RabbitMQ AMQP / management | 5672 / 15672 | -| pgAdmin | 5050 | +| Jaeger UI / OTLP | 16686 / 4317 | +| pgAdmin (`-Tools`) | 5050 | -DB: `musify_db`, usuario `postgres`/`postgres`. Bucket S3: `webapi-storage` (credenciales `admin_access_key`/`admin_secret_key`). +DB: `musify_db`, usuario `postgres`/`postgres`. Bucket S3: `webapi-storage` +(credenciales `admin_access_key`/`admin_secret_key`). Todos los valores salen de +`deploy/.env`. ## Migraciones -No hay migración automática al arrancar. `deploy/up.ps1` las aplica en el bootstrap; -para hacerlo a mano tras cambios de esquema: +El servicio `migrate` de `deploy/compose.yml` las aplica en cada `docker +compose up -d` (no-op si el esquema ya está al día). A mano, tras cambios de +esquema, contra la base local: ```powershell -dotnet ef database update --project backend\Musify.Infrastructure --startup-project backend\Musify.Infrastructure --context Database +dotnet ef database update --project backend/Core/Musify.Infrastructure --startup-project backend/Core/Musify.Infrastructure --context Database ``` -La cadena de conexión para `dotnet ef` se lee de `DesignSettings.json` / user-secrets del proyecto Infrastructure -(`up.ps1` la deja en user-secrets automáticamente). +`Musify.Infrastructure` es a la vez el proyecto de migraciones y el de arranque: +tiene el `IDesignTimeDbContextFactory` y el paquete `EFCore.Design`. La cadena +de conexión se lee de sus user-secrets, o de la variable de entorno +`Database__ConnectionString` si no hay user-secrets (así es como la usa el +contenedor `migrate`, ver `backend/Musify.Migrator.Dockerfile`). ## Stream-tickets (claves RS256) -La WebApi firma los stream-tickets con una clave privada y el gateway valida con la pública: +La API firma los stream-tickets con la clave privada y el gateway los valida con +la pública. El servicio `keys-init` de `deploy/compose.yml` las genera en +`deploy/keys/` si no existen; a mano: ```powershell -openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out stream_private.pem -openssl rsa -in stream_private.pem -pubout -out stream_public.pem +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out deploy/keys/stream_private.pem +openssl rsa -in deploy/keys/stream_private.pem -pubout -out deploy/keys/stream_public.pem ``` -- WebApi: `StreamTicket:PrivateKeyPath` → `stream_private.pem`. -- Gateway: `StreamTicket:PublicKeyPath` → `stream_public.pem`. +- API: `StreamTicket:PrivateKeyPath`. +- Gateway: `StreamTicket:PublicKeyPath`. -## Consola de pruebas: `player.html` +## Tests -Un único archivo HTML para probar todo sin front: +```sh +dotnet test backend/Musify.slnx +``` -1. Sírvelo o ábrelo directo (el CORS está abierto en Development). -2. Pega tu **access token** de Zitadel y la API base (`http://localhost:5111`). -3. Permite: crear/actualizar/eliminar playlists, listar canciones, añadir/quitar canciones de una playlist, ver las canciones de una playlist y **reproducir** (pide el ticket y abre el `.m4a` con `?t=`). +`backend/Tests/` (carpeta `/Tests/` en el `.slnx`) tiene cinco proyectos xUnit: -> Nota: `player.html` todavía usa dash.js; tras el cambio a `.m4a` debe actualizarse para reproducir el fichero directo (elemento `