refactor: standardize project names to Musify.* and flatten layout

- Flatten nested per-repo folders into flat Musify.* projects at root
- Rename assemblies/csproj: Domain->Musify.Domain, Application->Musify.Application,
  Infrastructure->Musify.Infrastructure, WebApi->Musify.Api, Worker->Musify.Worker,
  StreamingGateway->Musify.StreamingGateway
- Rename WebApi.* namespaces to Musify.Api.*
- Update all ProjectReference paths and the solution file
- Consolidate stray per-repo dotfiles; add root .gitattributes
- Move flutter client out to _archive

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 03:32:48 +02:00
co-authored by Claude Opus 4.8
parent 8ab4d27287
commit f6d668d201
259 changed files with 82 additions and 1711 deletions
@@ -0,0 +1,124 @@
using Ardalis.Result;
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.Commands;
using Musify.Application.Tracks.Responses;
using Musify.Application.Extensions;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks.Handlers
{
public class CreateTrackCommandHandler(
IDatabase database,
IEventBus eventBus,
UploadIntentValidator uploadIntentValidator,
ILogger<CreateTrackCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreateTrackCommand, Result<TrackApplicationResponse>>
{
public async ValueTask<Result<TrackApplicationResponse>> Handle(CreateTrackCommand 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 Result.NotFound($"User {request.UserId} not found");
}
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.PictureIntentId, request.UserId, cancellationToken);
if (!pictureValidation.IsSuccess)
return pictureValidation.As<UploadIntent, TrackApplicationResponse>();
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.AudioIntentId, request.UserId, cancellationToken);
if (!audioValidation.IsSuccess)
return audioValidation.As<UploadIntent, TrackApplicationResponse>();
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 Track
{
Title = request.Title,
NormalizedTitle = request.Title.ToUpperInvariant(),
OriginalPictureName = pictureIntent.ObjectName,
OriginalAudioName = audioIntent.ObjectName,
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
MediumPictureName = trackConfiguration.Routes.PresetMediumPicture,
LargePictureName = trackConfiguration.Routes.PresetLargePicture,
PicturesProcessingStatus = ProcessingStatus.Pending,
AudioTranscodeProcessingStatus = ProcessingStatus.Pending
};
await database.Tracks.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 Result.Error($"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 Result.Error($"Failed to create track for {request.Title}");
}
return Result.Created(TrackApplicationResponse.FromEntity(trackEntity));
}
}
}
@@ -0,0 +1,70 @@
using Ardalis.Result;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Tracks.Commands;
using Musify.Domain.ValueObjects;
using Musify.Application.Events;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks.Handlers
{
public class DeleteTrackCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeleteTrackCommandHandler> logger)
: ICommandHandler<DeleteTrackCommand, Result>
{
public async ValueTask<Result> 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 Result.NotFound();
}
var isOwner = await database.UserHasTracks
.AsNoTracking()
.AnyAsync(ut => ut.TrackId == request.TrackId && ut.UserId == request.UserId, cancellationToken);
if (!isOwner)
{
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
return Result.Unauthorized();
}
if (track.AudioTranscodeProcessingStatus == ProcessingStatus.Processing
|| track.AudioTranscodeProcessingStatus == ProcessingStatus.Pending)
{
logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId);
return Result.Conflict("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 Result.Error($"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 Result.Error($"Failed to delete track {request.TrackId}");
}
return Result.NoContent();
}
}
}
@@ -0,0 +1,25 @@
using Ardalis.Result;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Queries;
using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks.Handlers
{
public class GetTrackByIdQueryHandler(IDatabase database)
: IQueryHandler<GetTrackByIdQuery, Result<TrackApplicationResponse> >
{
public async ValueTask<Result<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
{
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId)
.Select(t => TrackApplicationResponse.FromEntity(t))
.SingleOrDefaultAsync(cancellationToken);
return track is null
? Result<TrackApplicationResponse>.NotFound()
: Result.Success(track);
}
}
}
@@ -0,0 +1,54 @@
using Ardalis.Result;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Queries;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks.Handlers
{
public class GetTrackStreamQueryHandler(
IDatabase database,
IStreamTicketService ticketService,
TrackConfiguration trackConfiguration,
StreamGatewayConfiguration streamGatewayConfiguration,
ILogger<GetTrackStreamQueryHandler> logger)
: IQueryHandler<GetTrackStreamQuery, Result<TrackStreamResponse>>
{
public async ValueTask<Result<TrackStreamResponse>> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken)
{
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId)
.Select(t => new { t.Id, t.AudioTranscodeProcessingStatus, t.AudioFolderName })
.SingleOrDefaultAsync(cancellationToken);
if (track is null)
return Result<TrackStreamResponse>.NotFound();
if (string.IsNullOrWhiteSpace(track.AudioFolderName)
|| track.AudioTranscodeProcessingStatus != ProcessingStatus.Completed)
{
logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId);
return Result<TrackStreamResponse>.Conflict("Track audio is not available for streaming yet.");
}
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(track.AudioFolderName);
var keyPrefix = $"{folderPath}/";
var ticket = ticketService.IssueTicket(request.UserId, keyPrefix);
var manifestUrl = string.Join('/',
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
"media",
folderPath,
streamGatewayConfiguration.AudioFileName);
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId);
return Result.Success(new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds));
}
}
}
@@ -0,0 +1,41 @@
using Ardalis.Result;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Pagination;
using Musify.Application.Tracks.Queries;
using Musify.Application.Tracks.Responses;
using X.PagedList.EF;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks.Handlers
{
public class GetTracksByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetTracksByUserIdQuery, Result<PaginatedResponse<TrackApplicationResponse>> >
{
public async ValueTask<Result<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 => ut.Track)
.Where(p => p.UserId == request.UserId);
if (request.Name is not null)
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
}
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedTracks = await tracksQuery
.OrderBy(t => t.Track.CreatedAt)
.Select(t => TrackApplicationResponse.FromEntity(t.Track))
.ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
return Result.Success(PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks));
}
}
}
@@ -0,0 +1,29 @@
using Ardalis.Result;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Pagination;
using Musify.Application.Tracks.Queries;
using Musify.Application.Tracks.Responses;
using X.PagedList.EF;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks.Handlers
{
public class GetTracksQueryHandler(IDatabase database)
: IQueryHandler<GetTracksQuery, Result<PaginatedResponse<TrackApplicationResponse>> >
{
public async ValueTask<Result<PaginatedResponse<TrackApplicationResponse> >> Handle(GetTracksQuery request, CancellationToken cancellationToken)
{
var tracksQuery = database.UserHasTracks
.AsNoTracking()
.OrderBy(t => t.Id)
.Include(ut => ut.Track)
.Select(t => TrackApplicationResponse.FromEntity(t.Track));
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedTracks = await tracksQuery.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return Result.Success(PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks));
}
}
}
@@ -0,0 +1,134 @@
using Ardalis.Result;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Services;
using Musify.Application.Tracks.Commands;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks.Handlers
{
public class RequestTrackUploadUrlsCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestTrackUploadUrlsCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestTrackUploadUrlsCommand, Result<TrackUploadUrlsResponse>>
{
public async ValueTask<Result<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 Result.NotFound($"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.IsSuccess)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck;
}
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 Result.Success(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 Result.Error("Failed to generate upload URLs");
}
}
}
}