Reorganize backend to mirror Musify.slnx folder structure

Moves the six backend projects on disk into Core/ and Hosts/
subfolders, matching the solution folders already defined in
Musify.slnx, so the file explorer reflects the same layout as the
solution:

  backend/Core/Musify.Domain
  backend/Core/Musify.Application
  backend/Core/Musify.Infrastructure
  backend/Hosts/Musify.Api
  backend/Hosts/Musify.Worker
  backend/Hosts/Musify.StreamingGateway

Updates every reference to the old paths: ProjectReference entries in
Musify.Api.csproj and Musify.Worker.csproj, the three Dockerfiles,
deploy/scripts/migrate.ps1|sh, deploy/zitadel/provision.ps1,
deploy/up.ps1, deploy/README.md and docs/development.md. Also fixes a
stale Dockerfile comment pointing at a compose file that no longer
exists.

Verified with `dotnet build Musify.slnx` against the new layout.
This commit is contained in:
2026-09-09 14:27:50 +02:00
parent d867a60126
commit 89569736b7
369 changed files with 41 additions and 41 deletions
@@ -0,0 +1,85 @@
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<ErrorOr<Success>>;
public class AddTrackToAlbumCommandHandler(
IDatabase database,
ILogger<AddTrackToAlbumCommandHandler> logger)
: ICommandHandler<AddTrackToAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.UserAlbums
.AsNoTracking()
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album is 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 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 localTrack = await database.LocalTracks
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
if (localTrack is not null && localTrack.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();
}
}
}
@@ -0,0 +1,62 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.Albums
{
public record CreateAlbumCommand(
long UserId,
string Title,
string? Description,
int? ReleaseYear)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler(
IDatabase database,
ILogger<CreateAlbumCommandHandler> logger)
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> 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 title = request.Title.Trim();
var album = new UserAlbum
{
OwnerUserId = request.UserId,
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Description = request.Description,
ReleaseYear = request.ReleaseYear
};
await database.UserAlbums.AddAsync(album, cancellationToken);
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);
}
}
}
@@ -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<ErrorOr<Success>>;
public class DeleteAlbumCommandHandler(
IDatabase database,
ILogger<DeleteAlbumCommandHandler> logger)
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.UserAlbums
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album is 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.UserAlbums.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();
}
}
}
@@ -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<ErrorOr<AlbumApplicationResponse>>;
public class GetAlbumByIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
{
var album = await database.UserAlbums
.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 is null)
return Error.NotFound();
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
}
}
}
@@ -0,0 +1,51 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums
{
public record GetAlbumTracksQuery(
Guid AlbumId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetAlbumTracksQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> 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 => ((ExternalTrack)albumTrack.Track).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(albumTrack => ((LocalTrack)albumTrack.Track).Owner)
.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<TrackApplicationResponse>(
pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
}
}
@@ -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 GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
public class GetAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
{
var albumsQuery = database.UserAlbums
.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 pagedAlbums = new StaticPagedList<AlbumApplicationResponse>(
pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums);
}
}
}
@@ -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<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
public class GetAlbumsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> 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.UserAlbums
.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<AlbumApplicationResponse>(
pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums);
}
}
}
@@ -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<ErrorOr<IReadOnlyList<AlbumApplicationResponse> >>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
{
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> 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<AlbumApplicationResponse>();
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();
}
}
}
@@ -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<ErrorOr<Success>>;
public class RemoveTrackFromAlbumCommandHandler(
IDatabase database,
ILogger<RemoveTrackFromAlbumCommandHandler> logger)
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.UserAlbums
.AsNoTracking()
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album is 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 is 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();
}
}
}
@@ -0,0 +1,66 @@
using Musify.Domain.Entities;
namespace Musify.Application.Albums.Responses
{
public record AlbumApplicationResponse(
Guid Id,
string Title,
string? Description,
int? ReleaseYear,
long OwnerUserId,
int TrackCount,
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds,
string? YouTubeAlbumId = null,
string? ThumbnailUrl = null)
{
public const int CoverTrackCount = 4;
public static AlbumApplicationResponse FromEntity(
UserAlbum album,
int trackCount,
IReadOnlyList<Guid>? 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 ?? []);
}
public static AlbumApplicationResponse FromEntity(
Album album,
int trackCount,
IReadOnlyList<Guid>? coverTrackIds = null)
{
var externalAlbum = album as ExternalAlbum;
return new AlbumApplicationResponse(
album.Id,
album.Title,
album.Description,
album.ReleaseYear,
album is UserAlbum userAlbum ? userAlbum.OwnerUserId : 0,
trackCount,
album.Pictures?.SmallName,
album.Pictures?.MediumName,
album.Pictures?.LargeName,
album.CreatedAt,
album.UpdatedAt,
coverTrackIds ?? [],
externalAlbum?.ExternalId,
externalAlbum?.ThumbnailUrl);
}
}
}
@@ -0,0 +1,70 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record UpdateAlbumCommand(
long UserId,
Guid AlbumId,
string Title,
string? Description,
int? ReleaseYear)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler(
IDatabase database,
ILogger<UpdateAlbumCommandHandler> logger)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.UserAlbums
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album is 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;
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);
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
}
}
}
@@ -0,0 +1,27 @@
using Mediator;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Musify.Application.Configuration;
using Musify.Application.Services;
namespace Musify.Application;
public static class ApplicationDependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
{
services.AddMediator();
services.AddScoped<UploadIntentValidator>();
services.AddScoped<TrackStreamIssuer>();
services.AddScoped<YouTubeTrackProvisioner>();
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName);
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName);
services.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
return services;
}
}
@@ -0,0 +1,4 @@
using Mediator;
using Microsoft.Extensions.DependencyInjection;
[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)]
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration
{
public class ApplicationStorageConfiguration
{
public const string SectionName = "ApplicationStorage";
[Required]
[StringLength(128, MinimumLength = 1)]
public required string Bucket { get; set; }
}
}
@@ -0,0 +1,25 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Musify.Application.Configuration;
public static class ConfigurationDependencyInjection
{
public static IServiceCollection AddValidatedOptions<T>(
this IServiceCollection services,
IConfiguration configuration,
string sectionName)
where T : class
{
services
.AddOptionsWithValidateOnStart<T>()
.Bind(configuration.GetRequiredSection(sectionName))
.ValidateDataAnnotations();
services.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<T>>().Value);
return services;
}
}
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration
{
public class MixConfiguration
{
public const string SectionName = "Mix";
[Range(1, 20)]
public int ArtistMixCount { get; set; } = 3;
[Range(4, 100)]
public int ItemsPerMix { get; set; } = 12;
[Range(1, 10)]
public int SeedArtistCount { get; set; } = 3;
[Range(1, 100)]
public int YouTubeResultsPerSeed { get; set; } = 12;
}
}
@@ -0,0 +1,101 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration
{
public class PlayListConfiguration
{
public const string SectionName = "PlayList";
[Required]
public PlayListRoutes Routes { get; set; } = new PlayListRoutes();
[Required]
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes();
public bool SeedPresetPictures { get; set; } = true;
public bool OverwritePresetPictures { get; set; } = false;
}
public class PlayListRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolders { get; set; } = "PlayLists";
[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";
[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 OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolders, SmallPicturesFolder, PresetSmallPicture);
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolders, MediumPicturesFolder, PresetMediumPicture);
public string PresetLargePicturePath => StorageKey.Combine(ParentFolders, LargePicturesFolder, PresetLargePicture);
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 PlayListPicturesSizes
{
[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;
}
}
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration
{
public class StreamGatewayConfiguration
{
public const string SectionName = "StreamGateway";
[Url]
[Required]
public required string PublicBaseUrl { get; set; }
[Required]
public string AudioFileName { get; set; } = "audio.m4a";
}
}
@@ -0,0 +1,116 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration
{
public class TrackConfiguration
{
public const string SectionName = "Track";
[Required]
public TrackRoutes Routes { get; set; } = new TrackRoutes();
[Required]
public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes();
}
public class TrackRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolder { get; set; } = "Tracks";
[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";
[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";
[Required]
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
[Required]
public string ProcessedAudioFolder { get; set; } = "ProcessedAudios";
public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder);
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture);
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder);
public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder);
public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(OriginalPicturesPath, pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName);
public string BuildOriginalAudioPath(string audioName) => StorageKey.Combine(OriginalAudiosPath, audioName);
public string BuildOriginalAudioPath(long userId, string audioName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName);
public string BuildProcessedAudioPath(string audioName) => StorageKey.Combine(ProcessedAudiosPath, audioName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
public string BuildTempAudioPath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
}
public class TrackPicturesSizes
{
[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;
}
}
@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration
{
public class UploadIntentConfiguration
{
public const string SectionName = "UploadIntent";
[Range(10, 3600)]
public int UploadUrlExpiresInSeconds { get; set; } = 120;
[Range(1, 100)]
public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
[Range(1, long.MaxValue)]
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
[Range(1, long.MaxValue)]
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
[Range(1, long.MaxValue)]
public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024;
[Range(1, long.MaxValue)]
public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024;
[Range(30, 86400)]
public int ExpirationJobIntervalSeconds { get; set; } = 300;
[Range(0, 365)]
public int ExpiredIntentsRetentionDays { get; set; } = 7;
[Required, MinLength(1)]
public string TemporalRootPrefix { get; set; } = "temporal";
[Range(1, 30)]
public int TemporalUploadsRetentionDays { get; set; } = 3;
[Range(60, 86400)]
public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600;
}
}
@@ -0,0 +1,4 @@
namespace Musify.Application.Contracts
{
public record AudioTranscodeResult(int StatusCode, TimeSpan Duration);
}
@@ -0,0 +1,9 @@
namespace Musify.Application.Contracts
{
public interface IAudioTranscoderService
{
Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken);
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore;
using Musify.Domain.Entities;
namespace Musify.Application.Contracts
{
public interface IDatabase
{
DbSet<User> Users { get; }
DbSet<PlayList> PlayLists { get; }
DbSet<Album> Albums { get; }
DbSet<UserAlbum> UserAlbums { get; }
DbSet<ExternalAlbum> ExternalAlbums { get; }
DbSet<AlbumHasTrack> AlbumHasTracks { get; }
DbSet<Track> Tracks { get; }
DbSet<LocalTrack> LocalTracks { get; }
DbSet<ExternalTrack> ExternalTracks { get; }
DbSet<Artist> Artists { get; }
DbSet<TrackArtist> TrackArtists { get; }
DbSet<UserHasTrack> UserHasTracks { get; }
DbSet<PlayListHasTrack> PlayListHasTracks { get; }
DbSet<Mix> Mixes { get; }
DbSet<MixItem> MixItems { get; }
DbSet<Upload> Uploads { get; }
DbSet<UploadIntent> UploadIntents { get; }
DbSet<ListeningHistory> ListeningHistories { get; }
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
}
@@ -0,0 +1,7 @@
namespace Musify.Application.Contracts
{
public interface IDatabaseTransaction : IAsyncDisposable
{
Task CommitAsync(CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,7 @@
namespace Musify.Application.Contracts
{
public interface IEventBus
{
Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
}
}
@@ -0,0 +1,7 @@
namespace Musify.Application.Contracts
{
public interface IPictureService
{
Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
}
}
@@ -0,0 +1,28 @@
namespace Musify.Application.Contracts
{
public interface IStorageService
{
Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken);
Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true);
Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
CancellationToken cancellationToken);
Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken);
IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
}
}
@@ -0,0 +1,9 @@
namespace Musify.Application.Contracts
{
public interface IStreamTicketService
{
StreamTicket IssueTicket(long userId, string keyPrefix);
}
public record StreamTicket(string Token, int ExpiresInSeconds);
}
@@ -0,0 +1,9 @@
namespace Musify.Application.Contracts
{
public record YouTubeDownloadResult(int ExitCode, string StandardError);
public interface IYouTubeDownloaderService
{
Task<YouTubeDownloadResult> DownloadAudioAsync(string videoId, string destinationFilePath, CancellationToken cancellationToken);
}
}
@@ -0,0 +1,64 @@
using ErrorOr;
namespace Musify.Application.Contracts
{
public record YouTubeArtistRef(string? Id, string Name);
public record YouTubeSongResult(
string VideoId,
string Title,
string Artist,
string Album,
int DurationSeconds,
string ThumbnailUrl,
bool IsExplicit,
IReadOnlyList<YouTubeArtistRef> Artists);
public record YouTubeSearchResult(
IReadOnlyList<YouTubeSongResult> Items,
string ContinuationToken);
public record YouTubeAlbumResult(
string AlbumId,
string Title,
string Artist,
string ThumbnailUrl,
int? ReleaseYear,
bool IsSingle,
bool IsEp,
IReadOnlyList<YouTubeArtistRef> Artists);
public record YouTubeAlbumSearchResult(
IReadOnlyList<YouTubeAlbumResult> Items,
string ContinuationToken);
public record YouTubeAlbumTrack(
string VideoId,
string Title,
int DurationSeconds,
int TrackNumber,
bool IsExplicit);
public record YouTubeAlbumDetail(
YouTubeAlbumResult Album,
string Description,
int TotalDurationSeconds,
IReadOnlyList<YouTubeAlbumTrack> Tracks);
public record YouTubeStreamInfo(string Url, int ExpiresInSeconds);
public interface IYouTubeMusicService
{
Task<ErrorOr<YouTubeSearchResult>> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken);
Task<ErrorOr<YouTubeAlbumSearchResult>> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken);
Task<ErrorOr<YouTubeAlbumDetail>> GetAlbumAsync(string albumId, CancellationToken cancellationToken);
Task<ErrorOr<YouTubeStreamInfo>> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken);
Task<ErrorOr<YouTubeSongResult>> GetSongAsync(string videoId, CancellationToken cancellationToken);
string ResolveArtworkUrl(string thumbnailUrl);
}
}
@@ -0,0 +1,4 @@
namespace Musify.Application.Contracts
{
public record ObjectMetaData(string ContentType, long ContentLength);
}
@@ -0,0 +1,4 @@
namespace Musify.Application.Contracts
{
public record StorageObject(string Key, DateTime LastModifiedUtc);
}
@@ -0,0 +1,14 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
{
public record CreatePlayListResourcesEvent(
Guid PlayListId,
Guid PictureIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -0,0 +1,18 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
{
public record CreateTrackResourcesEvent(
Guid TrackId,
Guid PictureIntentId,
Guid AudioIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
string AudioSourceKey,
string AudioDestinationKey,
string AudioProcessedFolderKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -0,0 +1,6 @@
namespace Musify.Application.Events
{
public record DeletePlayListEvent(
Guid PlayListId,
long UserId);
}
@@ -0,0 +1,6 @@
namespace Musify.Application.Events
{
public record DeleteTrackEvent(
Guid TrackId,
long UserId);
}
@@ -0,0 +1,14 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
{
public record DownloadYouTubeTrackEvent(
Guid TrackId,
string VideoId,
string ThumbnailUrl,
string Bucket,
string AudioProcessedFolderKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -0,0 +1,4 @@
namespace Musify.Application.Events
{
public record PlayListPictureProcessed(Guid PlayListId);
}
@@ -0,0 +1,4 @@
namespace Musify.Application.Events
{
public record PlayListPictureProcessingFailed(Guid PlayListId);
}
@@ -0,0 +1,7 @@
namespace Musify.Application.Events
{
public record PlayListProcessingFailed(
Guid PlayListId,
string? Bucket,
string? PictureKey);
}
@@ -0,0 +1,4 @@
namespace Musify.Application.Events
{
public record TrackAudioProcessed(Guid TrackId);
}
@@ -0,0 +1,4 @@
namespace Musify.Application.Events
{
public record TrackAudioProcessingFailed(Guid TrackId);
}
@@ -0,0 +1,4 @@
namespace Musify.Application.Events
{
public record TrackPictureProcessed(Guid TrackId);
}
@@ -0,0 +1,4 @@
namespace Musify.Application.Events
{
public record TrackPictureProcessingFailed(Guid TrackId);
}
@@ -0,0 +1,8 @@
namespace Musify.Application.Events
{
public record TrackProcessingFailed(
Guid TrackId,
string? Bucket,
string? PictureKey,
string? AudioKey);
}
@@ -0,0 +1,12 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
{
public record UpdatePlayListPictureEvent(
Guid PlayListId,
string Bucket,
string SourceKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -0,0 +1,14 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
{
public record UpdatePlayListPictureSourceEvent(
Guid PlayListId,
Guid PictureIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -0,0 +1,9 @@
namespace Musify.Application.Events
{
public record UpdateTrackAudioEvent(
Guid TrackId,
string SourceBucket,
string SourceKey,
string DestinationBucket,
string DestinationFolderKey);
}
@@ -0,0 +1,12 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
{
public record UpdateTrackPictureEvent(
Guid TrackId,
string Bucket,
string SourceKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -0,0 +1,340 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes
{
public record GenerateMixesForUserCommand(long UserId)
: ICommand<ErrorOr<Success>>;
public class GenerateMixesForUserCommandHandler(
IDatabase database,
IYouTubeMusicService youTubeMusicService,
MixConfiguration mixConfiguration,
ILogger<GenerateMixesForUserCommandHandler> logger)
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
{
private sealed record SeedArtist(Guid Id, string Name);
private sealed record LocalCandidate(Guid TrackId, string Title, string? Artist, double DurationSeconds);
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<MixItem> Items);
private const int TitleLength = 300;
private const int ArtistLength = 300;
private const int ThumbnailUrlLength = 512;
private const int MixTitleLength = 100;
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
{
var userId = request.UserId;
var historyTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == userId)
.Select(history => history.TrackId)
.Distinct()
.ToListAsync(cancellationToken);
if (historyTrackIds.Count == 0)
{
logger.LogInformation("User {UserId} has no listening history yet, skipping mix generation", userId);
return new Success();
}
var seedArtists = await GetSeedArtistsAsync(historyTrackIds, cancellationToken);
if (seedArtists.Count == 0)
{
logger.LogInformation("User {UserId} has no artists to seed mixes from, skipping mix generation", userId);
return new Success();
}
var knownVideoIds = await database.ExternalTracks
.AsNoTracking()
.Where(track => track.Source == TrackSource.YouTube && historyTrackIds.Contains(track.Id))
.Select(track => track.ExternalId)
.ToListAsync(cancellationToken);
var excludedVideoIds = new HashSet<string>(knownVideoIds, StringComparer.OrdinalIgnoreCase);
var youTubeByArtist = await SearchYouTubeAsync(seedArtists, excludedVideoIds, cancellationToken);
var localByArtist = await GetLocalCandidatesAsync(seedArtists, historyTrackIds, cancellationToken);
var drafts = BuildDrafts(seedArtists, localByArtist, youTubeByArtist);
if (drafts.Count == 0)
{
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
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 async Task<List<SeedArtist>> GetSeedArtistsAsync(
IReadOnlyList<Guid> historyTrackIds,
CancellationToken cancellationToken)
{
var ranked = await database.TrackArtists
.AsNoTracking()
.Where(trackArtist => historyTrackIds.Contains(trackArtist.TrackId))
.GroupBy(trackArtist => trackArtist.ArtistId)
.Select(group => new { ArtistId = group.Key, Listens = group.Count() } )
.OrderByDescending(entry => entry.Listens)
.Take(mixConfiguration.SeedArtistCount)
.ToListAsync(cancellationToken);
var artistIds = ranked.Select(entry => entry.ArtistId).ToList();
var names = await database.Artists
.AsNoTracking()
.Where(artist => artistIds.Contains(artist.Id))
.ToDictionaryAsync(artist => artist.Id, artist => artist.Name, cancellationToken);
return [.. ranked
.Where(entry => names.ContainsKey(entry.ArtistId))
.Select(entry => new SeedArtist(entry.ArtistId, names[entry.ArtistId])) ];
}
private async Task<Dictionary<Guid, List<MixItem>> > SearchYouTubeAsync(
IReadOnlyList<SeedArtist> seedArtists,
HashSet<string> excludedVideoIds,
CancellationToken cancellationToken)
{
var byArtist = new Dictionary<Guid, List<MixItem>>();
foreach (var seed in seedArtists)
{
var searchResult = await youTubeMusicService.SearchSongsAsync(seed.Name, string.Empty, cancellationToken);
if (searchResult.IsError)
{
logger.LogWarning("YouTube Music search failed for seed artist {Artist}: {Error}",
seed.Name, searchResult.FirstError.Description);
byArtist[seed.Id] = [];
continue;
}
var songs = new List<MixItem>();
foreach (var song in searchResult.Value.Items.Take(mixConfiguration.YouTubeResultsPerSeed))
{
if (!excludedVideoIds.Add(song.VideoId))
continue;
songs.Add(new MixItem
{
MixId = Guid.Empty,
Source = MixItemSource.YouTube,
VideoId = song.VideoId,
Title = Truncate(song.Title, TitleLength),
Artist = Truncate(song.Artist, ArtistLength),
ThumbnailUrl = Truncate(youTubeMusicService.ResolveArtworkUrl(song.ThumbnailUrl), ThumbnailUrlLength),
DurationSeconds = song.DurationSeconds,
IsExplicit = song.IsExplicit
} );
}
byArtist[seed.Id] = songs;
}
return byArtist;
}
private async Task<Dictionary<Guid, List<MixItem>> > GetLocalCandidatesAsync(
IReadOnlyList<SeedArtist> seedArtists,
IReadOnlyList<Guid> historyTrackIds,
CancellationToken cancellationToken)
{
var artistIds = seedArtists.Select(seed => seed.Id).ToList();
var links = await database.TrackArtists
.AsNoTracking()
.Where(trackArtist => artistIds.Contains(trackArtist.ArtistId) && !historyTrackIds.Contains(trackArtist.TrackId))
.Select(trackArtist => new { trackArtist.ArtistId, trackArtist.TrackId } )
.ToListAsync(cancellationToken);
var trackIds = links.Select(link => link.TrackId).Distinct().ToList();
var candidates = await LoadCandidatesAsync(trackIds, cancellationToken);
var byArtist = new Dictionary<Guid, List<MixItem>>();
foreach (var seed in seedArtists)
{
byArtist[seed.Id] = [.. links
.Where(link => link.ArtistId == seed.Id)
.Select(link => candidates.GetValueOrDefault(link.TrackId))
.Where(candidate => candidate is not null)
.Select(candidate => ToMixItem(candidate!))];
}
return byArtist;
}
private async Task<Dictionary<Guid, LocalCandidate>> LoadCandidatesAsync(
IReadOnlyList<Guid> trackIds,
CancellationToken cancellationToken)
{
if (trackIds.Count == 0)
return [];
var tracks = await database.Tracks
.AsNoTracking()
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Include(track => ((ExternalTrack)track).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(track => ((LocalTrack)track).Owner)
.ToListAsync(cancellationToken);
return tracks
.Select(track => TrackApplicationResponse.FromEntity(track, 0))
.ToDictionary(
response => response.Id,
response => new LocalCandidate(response.Id, response.Title, response.Artist, response.Duration));
}
private static MixItem ToMixItem(LocalCandidate candidate) => new()
{
MixId = Guid.Empty,
Source = MixItemSource.Musify,
TrackId = candidate.TrackId,
Title = candidate.Title,
Artist = candidate.Artist,
DurationSeconds = candidate.DurationSeconds
};
private List<MixDraft> BuildDrafts(
IReadOnlyList<SeedArtist> seedArtists,
IReadOnlyDictionary<Guid, List<MixItem>> localByArtist,
IReadOnlyDictionary<Guid, List<MixItem>> youTubeByArtist)
{
var drafts = new List<MixDraft>();
var everyYouTube = seedArtists.SelectMany(seed => youTubeByArtist[seed.Id]).ToList();
var everyLocal = seedArtists.SelectMany(seed => localByArtist[seed.Id]).ToList();
var discovery = Take(Shuffle(everyYouTube));
if (discovery.Count > 0)
{
drafts.Add(new MixDraft(
"Descubrimiento",
"Canciones nuevas que todavía no has escuchado.",
discovery));
}
var daily = Take(Interleave(Shuffle(everyLocal), Shuffle(everyYouTube)));
if (daily.Count > 0)
{
drafts.Add(new MixDraft(
"Tu mezcla diaria",
"Lo que más escuchas, con alguna sorpresa nueva.",
daily));
}
foreach (var seed in seedArtists.Take(mixConfiguration.ArtistMixCount))
{
var items = Take(Interleave(Shuffle(localByArtist[seed.Id]), Shuffle(youTubeByArtist[seed.Id])));
if (items.Count == 0)
continue;
drafts.Add(new MixDraft(
Truncate(seed.Name, MixTitleLength),
$"Canciones de {seed.Name} y de artistas parecidos.",
items));
}
return drafts;
}
private List<MixItem> Take(IEnumerable<MixItem> items) =>
[.. items.Take(mixConfiguration.ItemsPerMix)];
private static string Truncate(string value, int maxLength) =>
value.Length <= maxLength ? value : value[..maxLength];
private static List<MixItem> Shuffle(IEnumerable<MixItem> items) =>
[.. items.OrderBy(_ => Guid.NewGuid())];
private static List<MixItem> Interleave(IReadOnlyList<MixItem> first, IReadOnlyList<MixItem> second)
{
var merged = new List<MixItem>(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<MixDraft> 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.Items.Count; itemPosition++)
{
var item = draft.Items[itemPosition];
await database.MixItems.AddAsync(new MixItem
{
MixId = mix.Id,
Position = itemPosition,
Source = item.Source,
TrackId = item.TrackId,
VideoId = item.VideoId,
Title = item.Title,
Artist = item.Artist,
ThumbnailUrl = item.ThumbnailUrl,
DurationSeconds = item.DurationSeconds,
IsExplicit = item.IsExplicit
}, cancellationToken);
}
}
}
}
}
@@ -0,0 +1,30 @@
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<ErrorOr<MixApplicationResponse>>;
public class GetMixByIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
{
public async ValueTask<ErrorOr<MixApplicationResponse>> 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 is null)
return Error.NotFound(description: $"Mix {request.MixId} was not found");
var items = mix.Items.OrderBy(item => item.Position).ToList();
return MixApplicationResponse.FromEntity(mix, items.Count, items);
}
}
}
@@ -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<ErrorOr<IReadOnlyList<MixApplicationResponse>>>;
public class GetMixesByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixesByUserIdQuery, ErrorOr<IReadOnlyList<MixApplicationResponse>>>
{
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
{
var entries = await database.Mixes
.AsNoTracking()
.Where(mix => mix.UserId == request.UserId)
.OrderBy(mix => mix.Position)
.Select(mix => new
{
Mix = mix,
ItemCount = mix.Items.Count,
CoverItems = mix.Items
.OrderBy(item => item.Position)
.Take(MixApplicationResponse.CoverItemCount)
.ToList()
})
.ToListAsync(cancellationToken);
return entries
.Select(entry => MixApplicationResponse.FromEntity(entry.Mix, entry.ItemCount, entry.CoverItems))
.ToList();
}
}
}
@@ -0,0 +1,25 @@
using Musify.Domain.Entities;
namespace Musify.Application.Mixes.Responses
{
public record MixApplicationResponse(
Guid Id,
string Title,
string? Subtitle,
int ItemCount,
IReadOnlyList<MixItemApplicationResponse> Items)
{
public const int CoverItemCount = 4;
public static MixApplicationResponse FromEntity(
Mix mix,
int itemCount,
IReadOnlyList<MixItem> items) =>
new(
mix.Id,
mix.Title,
mix.Subtitle,
itemCount,
[.. items.Select(MixItemApplicationResponse.FromEntity)]);
}
}
@@ -0,0 +1,27 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes.Responses
{
public record MixItemApplicationResponse(
MixItemSource Source,
Guid? TrackId,
string? VideoId,
string Title,
string? Artist,
string? ThumbnailUrl,
double DurationSeconds,
bool IsExplicit)
{
public static MixItemApplicationResponse FromEntity(MixItem item) =>
new(
item.Source,
item.TrackId,
item.VideoId,
item.Title ?? string.Empty,
item.Artist,
item.ThumbnailUrl,
item.DurationSeconds,
item.IsExplicit);
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="ErrorOr" />
<PackageReference Include="MassTransit" />
<PackageReference Include="Mediator.Abstractions" />
<PackageReference Include="Mediator.SourceGenerator">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<PackageReference Include="MimeMapping" />
<PackageReference Include="X.PagedList" />
<PackageReference Include="X.PagedList.EF" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Musify.Domain\Musify.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,75 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.PlayLists
{
public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class AddTrackToPlayListCommandHandler(
IDatabase database,
ILogger<AddTrackToPlayListCommandHandler> logger)
: ICommandHandler<AddTrackToPlayListCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList is null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var 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 alreadyAdded = await database.PlayListHasTracks
.AsNoTracking()
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
if (alreadyAdded)
{
logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", request.TrackId, request.PlayListId);
return Error.Conflict(description: "Track is already in the playlist.");
}
var playListTracksQuery = database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId);
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
var nextPosition = hasTracks
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
: 0;
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = request.TrackId,
Position = nextPosition
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}",
request.TrackId, request.PlayListId, nextPosition);
return new Success();
}
}
}
@@ -0,0 +1,149 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
public record AddYouTubeTrackToPlayListCommand(
long UserId,
Guid PlayListId,
string VideoId)
: ICommand<ErrorOr<TrackApplicationResponse>>;
public class AddYouTubeTrackToPlayListCommandHandler(
IDatabase database,
IEventBus eventBus,
IYouTubeMusicService youTubeMusicService,
YouTubeTrackProvisioner provisioner,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
ILogger<AddYouTubeTrackToPlayListCommandHandler> logger)
: ICommandHandler<AddYouTubeTrackToPlayListCommand, ErrorOr<TrackApplicationResponse>>
{
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(AddYouTubeTrackToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists.AsNoTracking()
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList is null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken);
if (provisionResult.IsError)
return provisionResult.Errors;
var track = provisionResult.Value.Track;
var failed = track.Audio.IsFailed;
var neverQueued = !track.Audio.DownloadRequested && !track.Audio.IsProcessed;
if (failed || neverQueued)
{
if (failed)
{
track.Audio.TranscodeStatus = ProcessingStatus.Pending;
track.Pictures.ProcessingStatus = ProcessingStatus.Pending;
track.LifeCycleStatus = LifeCycleStatus.Active;
track.Audio.RetryCount += 1;
track.Audio.LastRetryAt = DateTime.UtcNow;
}
track.Audio.DownloadRequested = true;
var thumbnailUrl = await ResolveThumbnailUrlAsync(request.VideoId, provisionResult.Value.Song, cancellationToken);
if (thumbnailUrl.IsError)
return thumbnailUrl.Errors;
await PublishDownloadEventAsync(track.Id, request.VideoId, thumbnailUrl.Value, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Queued YouTube track {VideoId} for download", request.VideoId);
}
var alreadyAdded = await database.PlayListHasTracks.AsNoTracking()
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == track.Id, cancellationToken);
if (alreadyAdded)
{
logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", track.Id, request.PlayListId);
return Error.Conflict(description: "Track is already in the playlist.");
}
var playListTracksQuery = database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId);
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
var nextPosition = hasTracks
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
: 0;
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = track.Id,
Position = nextPosition
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added YouTube track {VideoId} ({TrackId}) to playlist {PlayListId} at position {Position}",
request.VideoId, track.Id, request.PlayListId, nextPosition);
var listensCount = await database.ListeningHistories.AsNoTracking()
.CountAsync(lh => lh.TrackId == track.Id, cancellationToken);
return TrackApplicationResponse.FromEntity(track, listensCount);
}
private async Task<ErrorOr<string>> ResolveThumbnailUrlAsync(string videoId, YouTubeSongResult? song, CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(song?.ThumbnailUrl))
return youTubeMusicService.ResolveArtworkUrl(song.ThumbnailUrl);
var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken);
if (songResult.IsError)
return songResult.Errors;
return youTubeMusicService.ResolveArtworkUrl(songResult.Value.ThumbnailUrl);
}
private async Task PublishDownloadEventAsync(Guid trackId, string videoId, string thumbnailUrl, CancellationToken cancellationToken)
{
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
await eventBus.PublishAsync(
new DownloadYouTubeTrackEvent(
trackId,
videoId,
thumbnailUrl,
storageConfiguration.Bucket,
audioProcessedFolderKey,
new ImageSize(
trackConfiguration.Routes.SmallPicturesPath,
trackConfiguration.PicturesSizes.SmallPictureWidth,
trackConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
trackConfiguration.Routes.MediumPicturesPath,
trackConfiguration.PicturesSizes.MediumPictureWidth,
trackConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
trackConfiguration.Routes.LargePicturesPath,
trackConfiguration.PicturesSizes.LargePictureWidth,
trackConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
}
}
}
@@ -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.PlayLists.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
public record CreatePlayListCommand(
long UserId,
string Name,
string? Description,
Guid? PictureIntentId)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class CreatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{
private sealed record ResolvedPlayListPictures(PlayListPictures Pictures, UploadIntent? Intent);
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand 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 picturesResult = await ResolvePicturesAsync(request, cancellationToken);
if (picturesResult.IsError)
return picturesResult.Errors;
var resolvedPictures = picturesResult.Value;
var playList = new PlayList
{
UserId = request.UserId,
Name = request.Name,
NormalizedName = request.Name.Trim().ToUpperInvariant(),
Description = string.IsNullOrWhiteSpace(request.Description)? null: request.Description.Trim(),
Pictures = resolvedPictures.Pictures
};
await database.PlayLists.AddAsync(playList, cancellationToken);
if (resolvedPictures.Intent != null)
{
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;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId);
return Error.Failure(description: $"Failed to create playlist for user {request.UserId}");
}
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return PlayListApplicationResponse.FromEntity(playList);
}
private async ValueTask<ErrorOr<ResolvedPlayListPictures>> ResolvePicturesAsync(
CreatePlayListCommand request, CancellationToken cancellationToken)
{
if (!request.PictureIntentId.HasValue)
{
return new ResolvedPlayListPictures(new PlayListPictures
{
OriginalName = playListConfiguration.Routes.PresetOriginalPicture,
SmallName = playListConfiguration.Routes.PresetSmallPicture,
MediumName = playListConfiguration.Routes.PresetMediumPicture,
LargeName = playListConfiguration.Routes.PresetLargePicture
}, 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 = playListConfiguration.Routes.PresetSmallPicture,
MediumName = playListConfiguration.Routes.PresetMediumPicture,
LargeName = playListConfiguration.Routes.PresetLargePicture
}, validation.Value);
}
async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync(
Guid playListId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new CreatePlayListResourcesEvent(
playListId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
playListConfiguration.Routes.SmallPicturesPath,
playListConfiguration.PicturesSizes.SmallPictureWidth,
playListConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
playListConfiguration.Routes.MediumPicturesPath,
playListConfiguration.PicturesSizes.MediumPictureWidth,
playListConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
playListConfiguration.Routes.LargePicturesPath,
playListConfiguration.PicturesSizes.LargePictureWidth,
playListConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId);
return Error.Failure(description: $"Failed to publish create playlist event for playlist {playListId}");
}
}
}
}
@@ -0,0 +1,62 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
public record DeletePlayListCommand(long UserId, Guid PlayListId)
: ICommand<ErrorOr<Success>>;
public class DeletePlayListCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeletePlayListCommandHandler> logger)
: ICommandHandler<DeletePlayListCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken);
if (playList is null)
{
logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
return Error.Unauthorized();
}
playList.LifeCycleStatus = LifeCycleStatus.Removing;
try
{
await eventBus.PublishAsync(
new DeletePlayListEvent(playList.Id, request.UserId),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId);
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId);
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
}
return new Success();
}
}
}
@@ -0,0 +1,37 @@
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<ErrorOr<PlayListApplicationResponse>>;
public class GetPlayListByIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> 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 is null)
return Error.NotFound();
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
}
}
}
@@ -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<ErrorOr<PlayListCoverLocation>>;
public record PlayListCoverLocation(string Bucket, string Key, string ContentType);
public class GetPlayListCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
: IQueryHandler<GetPlayListCoverQuery, ErrorOr<PlayListCoverLocation>>
{
public async ValueTask<ErrorOr<PlayListCoverLocation>> 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 is 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<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
}
}
@@ -0,0 +1,51 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.PlayLists
{
public record GetPlayListTracksQuery(
Guid PlayListId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetPlayListTracksQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>> >
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse> >> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
{
var playListExists = await database.PlayLists
.AsNoTracking()
.AnyAsync(p => p.Id == request.PlayListId, cancellationToken);
if (!playListExists)
return Error.NotFound();
var tracksQuery = database.PlayListHasTracks
.AsNoTracking()
.Include(plt => ((ExternalTrack)plt.Track).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(plt => ((LocalTrack)plt.Track).Owner)
.Where(plt => plt.PlayListId == request.PlayListId);
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderBy(plt => plt.Position)
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
}
}
@@ -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<ErrorOr<PaginatedResponse<PlayListApplicationResponse>> >;
public class GetPlayListsQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse> >>
{
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse> >> 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<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
}
}
}
@@ -0,0 +1,58 @@
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)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>> >;
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse> > >
{
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse> >> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
{
var playListsQuery = database.PlayLists
.AsNoTracking()
.Where(p => p.UserId == request.UserId);
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<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
}
}
}
@@ -0,0 +1,49 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class RemoveTrackFromPlayListCommandHandler(
IDatabase database,
ILogger<RemoveTrackFromPlayListCommandHandler> logger)
: ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList is null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var link = await database.PlayListHasTracks
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
if (link is null)
{
logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId);
return Error.NotFound(description: "Track is not in the playlist.");
}
database.PlayListHasTracks.Remove(link);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
return new Success();
}
}
}
@@ -0,0 +1,105 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
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
{
public record RequestPlayListPictureUploadCommand(
long UserId,
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PlayListPictureUploadResponse>>;
public class RequestPlayListPictureUploadCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PlayListPictureUploadResponse>>
{
public async ValueTask<ErrorOr<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand 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 = playListConfiguration.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.PlayListPicture,
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} (PlayListPicture, temp key: {Key} )", intent.Id, request.UserId, tempKey);
return new PlayListPictureUploadResponse(
intent.Id,
storageConfiguration.Bucket,
tempKey,
objectName,
request.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
uploadUrl);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId);
return Error.Failure(description: "Failed to generate upload URL");
}
}
}
}
@@ -0,0 +1,34 @@
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,
IReadOnlyList<Guid> CoverTrackIds)
{
public const int CoverTrackCount = 4;
public static PlayListApplicationResponse FromEntity(
PlayList playList,
IReadOnlyList<Guid>? coverTrackIds = null)
{
return new PlayListApplicationResponse(
playList.Id,
playList.Name,
playList.Description,
playList.Pictures?.SmallName,
playList.Pictures?.MediumName,
playList.Pictures?.LargeName,
playList.CreatedAt,
playList.UpdatedAt,
coverTrackIds ?? []);
}
}
}
@@ -0,0 +1,11 @@
namespace Musify.Application.PlayLists.Responses
{
public record PlayListPictureUploadResponse(
Guid IntentId,
string Bucket,
string Key,
string PictureName,
string ContentType,
int ExpiresInSeconds,
string UploadUrl);
}
@@ -0,0 +1,151 @@
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.PlayLists.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
public record UpdatePlayListCommand(
long UserId,
Guid PlayListId,
string? NewName,
string? NewDescription,
Guid? NewPictureIntentId)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
{
var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playListEntity is null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playListEntity.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
if (!string.IsNullOrWhiteSpace(request.NewName))
{
playListEntity.Name = request.NewName;
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
}
if (!string.IsNullOrWhiteSpace(request.NewDescription))
{
playListEntity.Description = request.NewDescription;
}
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;
playListEntity.Pictures = new PlayListPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = playListConfiguration.Routes.PresetSmallPicture,
MediumName = playListConfiguration.Routes.PresetMediumPicture,
LargeName = playListConfiguration.Routes.PresetLargePicture
};
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
}
database.PlayLists.Update(playListEntity);
if (pictureIntent != null&& finalPictureKey != null)
{
var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id);
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, coverTrackIds);
}
async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
Guid playListId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new UpdatePlayListPictureSourceEvent(
playListId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
playListConfiguration.Routes.SmallPicturesPath,
playListConfiguration.PicturesSizes.SmallPictureWidth,
playListConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
playListConfiguration.Routes.MediumPicturesPath,
playListConfiguration.PicturesSizes.MediumPictureWidth,
playListConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
playListConfiguration.Routes.LargePicturesPath,
playListConfiguration.PicturesSizes.LargePictureWidth,
playListConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish update playlist picture source event for playlist {PlayListId}", playListId);
return Error.Failure(description: $"Failed to publish update playlist picture source event for playlist {playListId}");
}
}
}
}
@@ -0,0 +1,41 @@
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)
{
public async Task<TrackStreamResponse> IssueAsync(
Guid trackId,
string audioFolderName,
long userId,
CancellationToken cancellationToken)
{
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
var keyPrefix = $"{folderPath}/";
var ticket = ticketService.IssueTicket(userId, keyPrefix);
var manifestUrl = string.Join('/',
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
"media",
folderPath,
streamGatewayConfiguration.AudioFileName);
var newListeningHistory = new ListeningHistory
{
UserId = userId,
TrackId = trackId,
};
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds);
}
}
@@ -0,0 +1,63 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Services;
public sealed class UploadIntentValidator(
IDatabase database,
IStorageService storageService)
{
public async Task<ErrorOr<Success>> CheckQuotaAsync(
UploadIntentConfiguration config,
long userId,
long requiredBytes,
int requiredIntentCount,
CancellationToken cancellationToken)
{
var activeIntents = await database.UploadIntents
.AsNoTracking()
.Where(i => i.UserId == userId && i.Status == UploadIntentStatus.Issued)
.ToListAsync(cancellationToken);
if (activeIntents.Count + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire.");
var activeBytes = activeIntents.Sum(i => i.ExpectedSizeBytes ?? config.DefaultExpectedPictureSizeBytes);
if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser)
return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire.");
return new Success();
}
public async Task<ErrorOr<UploadIntent>> ValidateAndLoadAsync(
UploadIntentConfiguration config,
Guid intentId,
long userId,
CancellationToken cancellationToken)
{
var intent = await database.UploadIntents
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
if (intent is null || intent.UserId != userId)
return Error.NotFound(description: "Upload intent not found or not accessible.");
if (intent.IsConsumed)
return Error.Conflict(description: "Upload intent has already been consumed.");
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)
return Error.NotFound(description: "Uploaded object not found in storage. Upload the file first.");
if (metadata.ContentLength > config.MaxUploadBytes)
return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes.");
return intent;
}
}
@@ -0,0 +1,265 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Services;
public sealed record YouTubeTrackProvision(ExternalTrack Track, YouTubeSongResult? Song);
public sealed class YouTubeTrackProvisioner(
IDatabase database,
IYouTubeMusicService youTubeMusicService,
TrackConfiguration trackConfiguration,
ILogger<YouTubeTrackProvisioner> logger)
{
public Task<ExternalTrack?> FindAsync(string videoId, CancellationToken cancellationToken) =>
database.ExternalTracks
.Include(track => track.TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == videoId, cancellationToken);
public async Task<ErrorOr<YouTubeTrackProvision>> GetOrCreateAsync(string videoId, CancellationToken cancellationToken)
{
var existing = await FindAsync(videoId, cancellationToken);
if (existing is not null)
return new YouTubeTrackProvision(existing, Song: null);
var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken);
if (songResult.IsError)
return songResult.Errors;
var song = songResult.Value;
var artistRefs = song.Artists
.GroupBy(artistRef => artistRef.Id ?? artistRef.Name.ToUpperInvariant())
.Select(group => group.First())
.ToList();
var artists = new List<Artist>();
foreach (var artistRef in artistRefs)
artists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken));
var title = Truncate(song.Title, 200);
var track = new ExternalTrack
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Source = TrackSource.YouTube,
ExternalId = videoId,
DurationSeconds = song.DurationSeconds,
Pictures = new TrackPictures
{
SmallName = trackConfiguration.Routes.PresetSmallPicture,
MediumName = trackConfiguration.Routes.PresetMediumPicture,
LargeName = trackConfiguration.Routes.PresetLargePicture,
ProcessingStatus = ProcessingStatus.Pending
},
Audio = new TrackAudio
{
DownloadRequested = false,
TranscodeStatus = ProcessingStatus.Pending
}
};
await database.ExternalTracks.AddAsync(track, cancellationToken);
for (var position = 0; position < artists.Count; position++)
{
await database.TrackArtists.AddAsync(new TrackArtist
{
TrackId = track.Id,
ArtistId = artists[position].Id,
Position = position
}, cancellationToken);
}
try
{
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created YouTube track {VideoId} ({TrackId})", videoId, track.Id);
return new YouTubeTrackProvision(track, Song: song);
}
catch (DbUpdateException)
{
database.ExternalTracks.Remove(track);
var refetched = await FindAsync(videoId, cancellationToken);
if (refetched is null)
{
logger.LogWarning("YouTube track {VideoId} creation raced but could not be refetched", videoId);
return Error.Conflict(description: "The track was just created by another request. Retry the operation.");
}
return new YouTubeTrackProvision(refetched, Song: null);
}
}
public async Task MaterializeAlbumAsync(string albumExternalId, YouTubeAlbumDetail detail, CancellationToken cancellationToken)
{
var validTracks = detail.Tracks
.Where(track => !string.IsNullOrEmpty(track.VideoId))
.ToList();
if (validTracks.Count == 0)
return;
var album = await GetOrCreateExternalAlbumAsync(albumExternalId, detail.Album.Title, detail.Album.ThumbnailUrl, cancellationToken);
var videoIds = validTracks.Select(track => track.VideoId).ToList();
var tracksByVideoId = await database.ExternalTracks
.Where(track => track.Source == TrackSource.YouTube && videoIds.Contains(track.ExternalId))
.ToDictionaryAsync(track => track.ExternalId, cancellationToken);
var linkedTrackIds = (await database.AlbumHasTracks
.Where(albumTrack => albumTrack.AlbumId == album.Id)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken))
.ToHashSet();
if (tracksByVideoId.Count == validTracks.Count && linkedTrackIds.Count >= validTracks.Count)
return;
var artistRefs = detail.Album.Artists
.GroupBy(artistRef => artistRef.Id ?? artistRef.Name.ToUpperInvariant())
.Select(group => group.First())
.ToList();
var albumArtists = new List<Artist>();
foreach (var artistRef in artistRefs)
albumArtists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken));
foreach (var albumTrack in validTracks)
{
if (!tracksByVideoId.TryGetValue(albumTrack.VideoId, out var track))
{
track = BuildStubTrack(albumTrack);
await database.ExternalTracks.AddAsync(track, cancellationToken);
for (var position = 0; position < albumArtists.Count; position++)
{
await database.TrackArtists.AddAsync(new TrackArtist
{
TrackId = track.Id,
ArtistId = albumArtists[position].Id,
Position = position
}, cancellationToken);
}
tracksByVideoId[albumTrack.VideoId] = track;
}
if (linkedTrackIds.Contains(track.Id))
continue;
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
{
AlbumId = album.Id,
TrackId = track.Id,
TrackNumber = albumTrack.TrackNumber
}, cancellationToken);
linkedTrackIds.Add(track.Id);
}
try
{
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Materialized YouTube album {AlbumId} ({TrackCount} tracks)", albumExternalId, validTracks.Count);
}
catch (DbUpdateException exception)
{
logger.LogWarning(exception, "Race while materializing YouTube album {AlbumId}", albumExternalId);
}
}
private ExternalTrack BuildStubTrack(YouTubeAlbumTrack albumTrack)
{
var title = Truncate(albumTrack.Title, 200);
return new ExternalTrack
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Source = TrackSource.YouTube,
ExternalId = albumTrack.VideoId,
DurationSeconds = albumTrack.DurationSeconds,
Pictures = new TrackPictures
{
SmallName = trackConfiguration.Routes.PresetSmallPicture,
MediumName = trackConfiguration.Routes.PresetMediumPicture,
LargeName = trackConfiguration.Routes.PresetLargePicture,
ProcessingStatus = ProcessingStatus.Pending
},
Audio = new TrackAudio
{
DownloadRequested = false,
TranscodeStatus = ProcessingStatus.Pending
}
};
}
private async Task<Artist> GetOrCreateArtistAsync(YouTubeArtistRef reference, CancellationToken cancellationToken)
{
var name = Truncate(reference.Name, 200);
var normalized = name.ToUpperInvariant();
var externalId = reference.Id ?? $"name:{normalized}";
var existing = await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == externalId, cancellationToken);
if (existing is not null)
return existing;
var artist = new Artist
{
Name = name,
NormalizedName = normalized,
ExternalId = externalId
};
await database.Artists.AddAsync(artist, cancellationToken);
try
{
await database.SaveChangesAsync(cancellationToken);
return artist;
}
catch (DbUpdateException)
{
database.Artists.Remove(artist);
return await database.Artists.SingleAsync(a => a.ExternalId == externalId, cancellationToken);
}
}
private async Task<ExternalAlbum> GetOrCreateExternalAlbumAsync(string externalId, string name, string? thumbnailUrl, CancellationToken cancellationToken)
{
var existing = await database.ExternalAlbums
.SingleOrDefaultAsync(album => album.Source == TrackSource.YouTube && album.ExternalId == externalId, cancellationToken);
if (existing is not null)
return existing;
var title = Truncate(name, 200);
var album = new ExternalAlbum
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Source = TrackSource.YouTube,
ExternalId = externalId,
ThumbnailUrl = string.IsNullOrEmpty(thumbnailUrl) ? null : Truncate(thumbnailUrl, 512)
};
await database.ExternalAlbums.AddAsync(album, cancellationToken);
try
{
await database.SaveChangesAsync(cancellationToken);
return album;
}
catch (DbUpdateException)
{
database.ExternalAlbums.Remove(album);
return await database.ExternalAlbums.SingleAsync(a => a.Source == TrackSource.YouTube && a.ExternalId == externalId, cancellationToken);
}
}
private static string Truncate(string value, int maxLength) =>
value.Length <= maxLength ? value : value[..maxLength];
}
@@ -0,0 +1,7 @@
namespace Musify.Application.Shared
{
public record ImageSize(
string SavePath,
int Width,
int Height);
}
@@ -0,0 +1,26 @@
using X.PagedList;
namespace Musify.Application.Shared
{
public record PaginatedResponse<T>(
IReadOnlyCollection<T> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasNextPage,
bool HasPreviousPage)
{
public static PaginatedResponse<T> FromPagedList(IPagedList<T> pagedList)
{
return new PaginatedResponse<T>(
pagedList.ToArray(),
pagedList.PageNumber,
pagedList.PageSize,
pagedList.PageCount,
pagedList.TotalItemCount,
pagedList.HasNextPage,
pagedList.HasPreviousPage);
}
}
}
@@ -0,0 +1,12 @@
namespace Musify.Application.Shared
{
public static class StorageKey
{
public static string Combine(params string[] segments)
{
return string.Join('/', segments
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => s.Trim().Trim('/', '\\')));
}
}
}
@@ -0,0 +1,137 @@
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.Tracks.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks
{
public record CreateTrackCommand(
long UserId,
string Title,
Guid PictureIntentId,
Guid AudioIntentId)
: ICommand<ErrorOr<TrackApplicationResponse>>;
public class CreateTrackCommandHandler(
IDatabase database,
IEventBus eventBus,
UploadIntentValidator uploadIntentValidator,
ILogger<CreateTrackCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreateTrackCommand, ErrorOr<TrackApplicationResponse>>
{
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
{
var user = await database.Users
.SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user is null)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.PictureIntentId, request.UserId, cancellationToken);
if (pictureValidation.IsError)
return pictureValidation.Errors;
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.AudioIntentId, request.UserId, cancellationToken);
if (audioValidation.IsError)
return audioValidation.Errors;
var pictureIntent = pictureValidation.Value;
var audioIntent = audioValidation.Value;
var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
var trackEntity = new LocalTrack
{
Title = request.Title,
NormalizedTitle = request.Title.ToUpperInvariant(),
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
}
};
await database.LocalTracks.AddAsync(trackEntity, cancellationToken);
await database.UserHasTracks.AddAsync(new UserHasTrack
{
UserId = request.UserId,
TrackId = trackEntity.Id
}, cancellationToken);
try
{
await eventBus.PublishAsync(
new CreateTrackResourcesEvent(
trackEntity.Id,
pictureIntent.Id,
audioIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
audioIntent.Key,
finalAudioKey,
audioProcessedFolderKey,
new ImageSize(
trackConfiguration.Routes.SmallPicturesPath,
trackConfiguration.PicturesSizes.SmallPictureWidth,
trackConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
trackConfiguration.Routes.MediumPicturesPath,
trackConfiguration.PicturesSizes.MediumPictureWidth,
trackConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
trackConfiguration.Routes.LargePicturesPath,
trackConfiguration.PicturesSizes.LargePictureWidth,
trackConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create track event for track {TrackId}", trackEntity.Id);
return Error.Failure(description: $"Failed to create track for {request.Title}");
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save track {Title}", request.Title);
return Error.Failure(description: $"Failed to create track for {request.Title}");
}
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
}
}
}
@@ -0,0 +1,71 @@
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;
namespace Musify.Application.Tracks
{
public record DeleteTrackCommand(
long UserId,
Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class DeleteTrackCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeleteTrackCommandHandler> logger)
: ICommandHandler<DeleteTrackCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
{
var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
if (track is null)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound();
}
if (track is not LocalTrack localTrack || localTrack.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
return Error.Unauthorized();
}
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");
}
track.LifeCycleStatus = LifeCycleStatus.Removing;
try
{
await eventBus.PublishAsync(
new DeleteTrackEvent(track.Id, request.UserId),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId);
return Error.Failure(description: $"Failed to delete track {request.TrackId}");
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to mark track {TrackId} as processing before deletion", request.TrackId);
return Error.Failure(description: $"Failed to delete track {request.TrackId}");
}
return new Success();
}
}
}
@@ -0,0 +1,40 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.Tracks
{
public record GetListeningHistoryQuery(long UserId) : IQuery<IEnumerable<TrackApplicationResponse>>;
public class GetListeningHistoryQueryHandler(
IDatabase database)
: IQueryHandler<GetListeningHistoryQuery, IEnumerable<TrackApplicationResponse>>
{
public const int ListSize = 15;
public async ValueTask<IEnumerable<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
{
var recentTrackIds = await database.ListeningHistories
.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
.Include(t => ((ExternalTrack)t).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(t => ((LocalTrack)t).Owner)
.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));
}
}
}
@@ -0,0 +1,32 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.Tracks
{
public record GetTrackByIdQuery(Guid TrackId)
: IQuery<ErrorOr<TrackApplicationResponse>>;
public class GetTrackByIdQueryHandler(IDatabase database)
: IQueryHandler<GetTrackByIdQuery, ErrorOr<TrackApplicationResponse> >
{
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
{
var entity = await database.Tracks.AsNoTracking()
.Include(t => ((ExternalTrack)t).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(t => ((LocalTrack)t).Owner)
.Where(t => t.Id == request.TrackId)
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
.SingleOrDefaultAsync(cancellationToken);
if (entity is null)
return Error.NotFound();
return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount);
}
}
}
@@ -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<ErrorOr<TrackCoverLocation>>;
public record TrackCoverLocation(string Bucket, string Key, string ContentType);
public class GetTrackCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration)
: IQueryHandler<GetTrackCoverQuery, ErrorOr<TrackCoverLocation>>
{
public async ValueTask<ErrorOr<TrackCoverLocation>> 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<string, string> builder, string? name)
=> string.IsNullOrEmpty(name)? null: builder(name);
}
}
@@ -0,0 +1,43 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks
{
public record GetTrackStreamQuery(Guid TrackId, long UserId)
: IQuery<ErrorOr<TrackStreamResponse>>;
public class GetTrackStreamQueryHandler(
IDatabase database,
TrackStreamIssuer streamIssuer,
ILogger<GetTrackStreamQueryHandler> logger)
: IQueryHandler<GetTrackStreamQuery, ErrorOr<TrackStreamResponse>>
{
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken)
{
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId)
.Select(t => new { t.Id, t.Audio })
.SingleOrDefaultAsync(cancellationToken);
if (track == null)
return Error.NotFound();
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 response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId);
return response;
}
}
}
@@ -0,0 +1,49 @@
using ErrorOr;
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;
using Musify.Domain.Entities;
namespace Musify.Application.Tracks
{
public record GetTracksQuery(string? Name, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse> >>;
public class GetTracksQueryHandler(IDatabase database)
: IQueryHandler<GetTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>> >
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse> >> Handle(GetTracksQuery request, CancellationToken cancellationToken)
{
var tracksQuery = database.UserHasTracks
.AsNoTracking()
.Include(ut => ((ExternalTrack)ut.Track).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(ut => ((LocalTrack)ut.Track).Owner)
.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 pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
}
}
@@ -0,0 +1,55 @@
using ErrorOr;
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;
using Musify.Domain.Entities;
namespace Musify.Application.Tracks
{
public record GetTracksByUserIdQuery(
long UserId,
string? Name,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse> > >;
public class GetTracksByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetTracksByUserIdQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>> >
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse> >> 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 => ((ExternalTrack)ut.Track).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(ut => ((LocalTrack)ut.Track).Owner)
.Where(p => p.UserId == request.UserId);
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
.OrderBy(t => t.Track.CreatedAt)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
.ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
}
}
@@ -0,0 +1,143 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Services;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks
{
public record RequestTrackUploadUrlsCommand(
long UserId,
string PictureFileType,
string PictureContentType,
string AudioFileType,
string AudioContentType,
long? ExpectedPictureSizeBytes = null,
long? ExpectedAudioSizeBytes = null)
: ICommand<ErrorOr<TrackUploadUrlsResponse>>;
public class RequestTrackUploadUrlsCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestTrackUploadUrlsCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestTrackUploadUrlsCommand, ErrorOr<TrackUploadUrlsResponse>>
{
public async ValueTask<ErrorOr<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand 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 effectivePictureSize = request.ExpectedPictureSizeBytes
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
var effectiveAudioSize = request.ExpectedAudioSizeBytes
?? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes;
var pictureObjectName = $"{Guid.NewGuid()}.{request.PictureFileType.TrimStart('.').ToLowerInvariant()}";
var audioObjectName = $"{Guid.NewGuid()}.{request.AudioFileType.TrimStart('.').ToLowerInvariant()}";
var tempPictureKey = trackConfiguration.Routes.BuildTempPicturePath(
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, pictureObjectName);
var tempAudioKey = trackConfiguration.Routes.BuildTempAudioPath(
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, audioObjectName);
try
{
var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
var pictureUploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
tempPictureKey,
request.PictureContentType,
expiresIn,
cancellationToken);
var audioUploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
tempAudioKey,
request.AudioContentType,
expiresIn,
cancellationToken);
var expiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken);
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
uploadIntentConfiguration,
request.UserId, effectivePictureSize + effectiveAudioSize, 2, cancellationToken);
if (quotaCheck.IsError)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck.Errors;
}
var pictureIntent = new UploadIntent
{
UserId = request.UserId,
Bucket = storageConfiguration.Bucket,
Key = tempPictureKey,
ObjectName = pictureObjectName,
ContentType = request.PictureContentType,
ExpectedSizeBytes = effectivePictureSize,
Purpose = UploadIntentPurpose.TrackPicture,
ExpiresAt = expiresAt,
};
var audioIntent = new UploadIntent
{
UserId = request.UserId,
Bucket = storageConfiguration.Bucket,
Key = tempAudioKey,
ObjectName = audioObjectName,
ContentType = request.AudioContentType,
ExpectedSizeBytes = effectiveAudioSize,
Purpose = UploadIntentPurpose.TrackAudio,
ExpiresAt = expiresAt,
};
await database.UploadIntents.AddAsync(pictureIntent, cancellationToken);
await database.UploadIntents.AddAsync(audioIntent, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued upload intents {PictureIntentId}/{AudioIntentId} for user {UserId} (TrackPicture/TrackAudio)",
pictureIntent.Id, audioIntent.Id, request.UserId);
return new TrackUploadUrlsResponse(
pictureIntent.Id,
audioIntent.Id,
storageConfiguration.Bucket,
tempPictureKey,
pictureObjectName,
request.PictureContentType,
pictureUploadUrl,
tempAudioKey,
audioObjectName,
request.AudioContentType,
audioUploadUrl,
uploadIntentConfiguration.UploadUrlExpiresInSeconds);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate track upload URLs for user {UserId}", request.UserId);
return Error.Failure(description: "Failed to generate upload URLs");
}
}
}
}
@@ -0,0 +1,44 @@
using System.Linq;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks.Responses
{
public record TrackApplicationResponse(
Guid Id,
string Title,
string? Artist,
TrackSource Source,
string? ExternalId,
ProcessingStatus AudioStatus,
double Duration,
int ListensCount,
long? OwnerUserId,
DateTime CreatedAt,
DateTime UpdatedAt)
{
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new(
track.Id,
track.Title,
FormatArtist(track),
track is ExternalTrack external ? external.Source : TrackSource.Local,
(track as ExternalTrack)?.ExternalId,
track.Audio.TranscodeStatus,
track.DurationSeconds,
listensCount,
(track as LocalTrack)?.OwnerUserId,
track.CreatedAt,
track.UpdatedAt);
private static string? FormatArtist(Track track) => track switch
{
LocalTrack local => local.Owner.Name,
ExternalTrack { TrackArtists.Count: > 0 } external =>
string.Join(", ", external.TrackArtists
.OrderBy(trackArtist => trackArtist.Position)
.Select(trackArtist => trackArtist.Artist.Name)),
_ => null
};
}
}
@@ -0,0 +1,7 @@
namespace Musify.Application.Tracks.Responses
{
public record TrackStreamResponse(
string ManifestUrl,
string Ticket,
int ExpiresInSeconds);
}
@@ -0,0 +1,16 @@
namespace Musify.Application.Tracks.Responses
{
public record TrackUploadUrlsResponse(
Guid PictureIntentId,
Guid AudioIntentId,
string Bucket,
string PictureKey,
string PictureName,
string PictureContentType,
string PictureUploadUrl,
string AudioKey,
string AudioName,
string AudioContentType,
string AudioUploadUrl,
int ExpiresInSeconds);
}
@@ -0,0 +1,59 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Users.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.Users
{
public record CreateUserCommand(long Id,
string Name,
string? FirstName,
string? SecondName)
: ICommand<ErrorOr<UserApplicationResponse>>
{
public static User ToEntity(CreateUserCommand command)
{
return new User
{
Id = command.Id,
Name = command.Name,
NormalizedName = command.Name.ToUpperInvariant(),
FirstName = command.FirstName,
SecondName = command.SecondName
};
}
}
public class CreateUserCommandHandler(IDatabase database, ILogger<CreateUserCommandHandler> logger)
: ICommandHandler<CreateUserCommand, ErrorOr<UserApplicationResponse> >
{
public async ValueTask<ErrorOr<UserApplicationResponse>> Handle(CreateUserCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users.AnyAsync(u => u.Id == request.Id, cancellationToken);
if (userExists)
{
logger.LogInformation("User {UserId} already exists", request.Id);
return Error.Conflict(description: $"User {request.Id} already exists");
}
var newUser = CreateUserCommand.ToEntity(request);
try
{
await database.Users.AddAsync(newUser, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to create user {UserId}", request.Id);
return Error.Failure(description: $"Failed to create user {request.Id}");
}
logger.LogInformation("Created user {UserId}", newUser.Id);
return UserApplicationResponse.FromEntity(newUser);
}
}
}
@@ -0,0 +1,29 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Users.Responses;
namespace Musify.Application.Users
{
public record GetUserByIdQuery(long Id)
: IQuery<ErrorOr<UserApplicationResponse> >;
public class GetUserByIdQueryHandler(IDatabase database)
: IQueryHandler<GetUserByIdQuery, ErrorOr<UserApplicationResponse> >
{
public async ValueTask<ErrorOr<UserApplicationResponse>> Handle(GetUserByIdQuery request, CancellationToken cancellationToken)
{
var user = await database.Users
.AsNoTracking()
.Where(u => u.Id == request.Id)
.Select(u => UserApplicationResponse.FromEntity(u))
.SingleOrDefaultAsync(cancellationToken);
if (user is null)
return Error.NotFound();
return user;
}
}
}
@@ -0,0 +1,37 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch)
: IQuery<ErrorOr<PaginatedResponse<UserApplicationResponse> >>;
public class GetUsersQueryHandler(IDatabase database)
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserApplicationResponse> > >
{
public async ValueTask<ErrorOr<PaginatedResponse<UserApplicationResponse> >> Handle(GetUsersQuery request, CancellationToken cancellationToken)
{
var user = database.Users
.AsNoTracking()
.AsQueryable();
if (!string.IsNullOrEmpty(request.UsernameSearch))
{
var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant();
user = user.Where(u => u.NormalizedName.Contains(normalizedUsername));
}
var totalCount = await user.CountAsync(cancellationToken);
var pagedUser = await user
.Select(u => UserApplicationResponse.FromEntity(u))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserApplicationResponse>.FromPagedList(pagedUser);
}
}
}
@@ -0,0 +1,26 @@
using Musify.Domain.Entities;
namespace Musify.Application.Users.Responses
{
public record UserApplicationResponse(
long Id,
string Name,
string? FirstName,
string? SecondName,
string? ProfilePictureUrl,
DateTime CreatedAt,
DateTime UpdatedAt)
{
public static UserApplicationResponse FromEntity(User user)
{
return new UserApplicationResponse(
user.Id,
user.Name,
user.FirstName,
user.SecondName,
user.ProfilePictureUrl,
user.CreatedAt,
user.UpdatedAt);
}
}
}
@@ -0,0 +1,57 @@
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 SyncUserCommand(
long Id,
string Name,
string? FirstName,
string? SecondName,
string? ProfilePictureUrl)
: ICommand<ErrorOr<Success>>;
public class SyncUserCommandHandler(IDatabase database, ILogger<SyncUserCommandHandler> logger)
: ICommandHandler<SyncUserCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(SyncUserCommand request, CancellationToken cancellationToken)
{
var user = await database.Users
.SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken);
if (user is null)
{
await database.Users.AddAsync(new User
{
Id = request.Id,
Name = request.Name,
NormalizedName = request.Name.ToUpperInvariant(),
FirstName = request.FirstName,
SecondName = request.SecondName,
ProfilePictureUrl = request.ProfilePictureUrl
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Provisioned user {UserId}", request.Id);
return new Success();
}
user.Name = request.Name;
user.NormalizedName = request.Name.ToUpperInvariant();
user.FirstName = request.FirstName;
user.SecondName = request.SecondName;
user.ProfilePictureUrl = request.ProfilePictureUrl;
var written = await database.SaveChangesAsync(cancellationToken);
if (written > 0)
logger.LogInformation("Synced profile for user {UserId}", request.Id);
return new Success();
}
}
}
@@ -0,0 +1,39 @@
using ErrorOr;
using Mediator;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Services;
namespace Musify.Application.YouTube
{
public record GetYouTubeAlbumQuery(string AlbumId)
: IQuery<ErrorOr<YouTubeAlbumDetail>>;
public class GetYouTubeAlbumQueryHandler(
IYouTubeMusicService youTubeMusicService,
YouTubeTrackProvisioner provisioner,
ILogger<GetYouTubeAlbumQueryHandler> logger)
: IQueryHandler<GetYouTubeAlbumQuery, ErrorOr<YouTubeAlbumDetail>>
{
public async ValueTask<ErrorOr<YouTubeAlbumDetail>> Handle(GetYouTubeAlbumQuery request, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(request.AlbumId))
return Error.Validation(description: "The album id must not be empty.");
var result = await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken);
if (result.IsError)
return result;
try
{
await provisioner.MaterializeAlbumAsync(request.AlbumId, result.Value, cancellationToken);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to materialize YouTube album {AlbumId}", request.AlbumId);
}
return result;
}
}
}
@@ -0,0 +1,76 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.YouTube.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.YouTube
{
public record ResolveYouTubeTrackStreamCommand(string VideoId, long UserId)
: ICommand<ErrorOr<YouTubeStreamResponse>>;
public class ResolveYouTubeTrackStreamCommandHandler(
IDatabase database,
IYouTubeMusicService youTubeMusicService,
YouTubeTrackProvisioner provisioner,
TrackStreamIssuer streamIssuer,
ILogger<ResolveYouTubeTrackStreamCommandHandler> logger)
: ICommandHandler<ResolveYouTubeTrackStreamCommand, ErrorOr<YouTubeStreamResponse>>
{
public async ValueTask<ErrorOr<YouTubeStreamResponse>> Handle(ResolveYouTubeTrackStreamCommand request, CancellationToken cancellationToken)
{
var track = await provisioner.FindAsync(request.VideoId, cancellationToken);
if (track != null&& track.Audio.IsProcessed)
{
var serverStream = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
return new YouTubeStreamResponse(
track.Id,
YouTubeStreamMode.Server,
serverStream.ManifestUrl,
serverStream.Ticket,
serverStream.ExpiresInSeconds);
}
var streamInfo = await youTubeMusicService.GetAudioStreamAsync(request.VideoId, cancellationToken);
if (streamInfo.IsError)
return streamInfo.Errors;
if (track == null)
{
var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken);
if (!provisionResult.IsError)
track = provisionResult.Value.Track;
else
logger.LogWarning("Could not provision YouTube track {VideoId}: {Error}", request.VideoId, provisionResult.FirstError.Description);
}
if (track != null)
{
await database.ListeningHistories.AddAsync(new ListeningHistory
{
UserId = request.UserId,
TrackId = track.Id,
}, cancellationToken);
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException exception)
{
logger.LogWarning(exception, "Failed to record listening history for YouTube video {VideoId}", request.VideoId);
}
}
return new YouTubeStreamResponse(
track?.Id,
YouTubeStreamMode.YouTube,
streamInfo.Value.Url, null,
streamInfo.Value.ExpiresInSeconds);
}
}
}
@@ -0,0 +1,15 @@
namespace Musify.Application.YouTube.Responses
{
public enum YouTubeStreamMode
{
Server = 0,
YouTube = 1
}
public record YouTubeStreamResponse(
Guid? TrackId,
YouTubeStreamMode Mode,
string StreamUrl,
string? Ticket,
int ExpiresInSeconds);
}
@@ -0,0 +1,21 @@
using ErrorOr;
using Mediator;
using Musify.Application.Contracts;
namespace Musify.Application.YouTube
{
public record SearchYouTubeAlbumsQuery(string Query, string ContinuationToken)
: IQuery<ErrorOr<YouTubeAlbumSearchResult>>;
public class SearchYouTubeAlbumsQueryHandler(IYouTubeMusicService youTubeMusicService)
: IQueryHandler<SearchYouTubeAlbumsQuery, ErrorOr<YouTubeAlbumSearchResult>>
{
public async ValueTask<ErrorOr<YouTubeAlbumSearchResult>> Handle(SearchYouTubeAlbumsQuery request, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(request.Query))
return Error.Validation(description: "The search query must not be empty.");
return await youTubeMusicService.SearchAlbumsAsync(request.Query, request.ContinuationToken, cancellationToken);
}
}
}
@@ -0,0 +1,21 @@
using ErrorOr;
using Mediator;
using Musify.Application.Contracts;
namespace Musify.Application.YouTube
{
public record SearchYouTubeTracksQuery(string Query, string ContinuationToken)
: IQuery<ErrorOr<YouTubeSearchResult>>;
public class SearchYouTubeTracksQueryHandler(IYouTubeMusicService youTubeMusicService)
: IQueryHandler<SearchYouTubeTracksQuery, ErrorOr<YouTubeSearchResult>>
{
public async ValueTask<ErrorOr<YouTubeSearchResult>> Handle(SearchYouTubeTracksQuery request, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(request.Query))
return Error.Validation(description: "The search query must not be empty.");
return await youTubeMusicService.SearchSongsAsync(request.Query, request.ContinuationToken, cancellationToken);
}
}
}
@@ -0,0 +1,9 @@
namespace Musify.Domain.Abstractions
{
public interface IAuditable
{
DateTime CreatedAt { get; set; }
DateTime UpdatedAt { get; set; }
}
}
@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
using Musify.Domain.Abstractions;
using Musify.Domain.ValueObjects;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
public abstract 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 LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Required]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<AlbumHasTrack> AlbumTracks { get; set; } = new List<AlbumHasTrack>();
}
}
@@ -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; }
}
}

Some files were not shown because too many files have changed in this diff Show More