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,47 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Authentication": {
"Scopes": [ "openid", "profile", "email" ],
"RequireHttpsMetadata": false
},
"Database": {
"ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres"
},
"MassTransit": {
"Address": "rabbitmq://localhost:5672",
"Username": "guest",
"Password": "guest"
},
"InfrastructureStorage": {
"Address": "http://localhost:8333",
"AccessKey": "admin_access_key",
"SecretAccessKey": "admin_secret_key",
"ForcePathStyle": true,
"UseHttp": true
},
"ApplicationStorage": {
"Bucket": "webapi-storage"
},
"StreamGateway": {
"PublicBaseUrl": "http://localhost:8081"
},
"StreamTicket": {
"PrivateKeyPath": "../../deploy/keys/stream_private.pem"
},
"OpenTelemetry": {
"OtlpEndpoint": "http://localhost:4317"
},
"YouTube": {
"GeographicalLocation": "ES"
},
"Workers": {
"Routes": {
"TemporaryFilesDirectory": "../../deploy/.tmp"
}
}
}
+139
View File
@@ -0,0 +1,139 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Authentication": {
"MetadataAddress": "",
"IssuerAddress": "",
"AudienceAddress": "",
"ClientId": "",
"AuthorizationEndpoint": "",
"TokenEndpoint": "",
"Scopes": [],
"ScalarRedirectUri": "",
"RequireHttpsMetadata": true
},
"Database": {
"ConnectionString": ""
},
"MassTransit": {
"Address": "",
"Username": "",
"Password": ""
},
"InfrastructureStorage": {
"Address": "",
"AccessKey": "",
"SecretAccessKey": "",
"ForcePathStyle": false,
"UseHttp": false
},
"ApplicationStorage": {
"Bucket": ""
},
"StreamGateway": {
"PublicBaseUrl": "",
"AudioFileName": "audio.m4a"
},
"StreamTicket": {
"PrivateKeyPath": "",
"Audience": "media-gateway",
"Issuer": "musify-webapi",
"TicketTtlSeconds": 3600
},
"OpenTelemetry": {
"OtlpEndpoint": ""
},
"YouTube": {
"GeographicalLocation": "US",
"StreamUrlCacheSeconds": 1800,
"SearchContinuationCacheSeconds": 600,
"SearchResultsCacheSeconds": 900,
"SongInfoCacheSeconds": 86400,
"ThumbnailSize": 240,
"ArtworkSize": 544
},
"Mix": {
"ArtistMixCount": 3,
"ItemsPerMix": 12,
"SeedArtistCount": 3,
"YouTubeResultsPerSeed": 12
},
"PlayList": {
"Routes": {
"UploadsFolder": "uploads",
"ParentFolders": "PlayLists",
"OriginalPicturesFolder": "OriginalPictures",
"SmallPicturesFolder": "SmallPictures",
"MediumPicturesFolder": "MediumPictures",
"LargePicturesFolder": "LargePictures"
},
"PicturesSizes": {
"SmallPictureWidth": 128,
"SmallPictureHeight": 128,
"MediumPictureWidth": 256,
"MediumPictureHeight": 256,
"LargePictureWidth": 512,
"LargePictureHeight": 512
}
},
"Track": {
"Routes": {
"UploadsFolder": "uploads",
"ParentFolder": "Tracks",
"OriginalPicturesFolder": "OriginalPictures",
"SmallPicturesFolder": "SmallPictures",
"MediumPicturesFolder": "MediumPictures",
"LargePicturesFolder": "LargePictures",
"PresetOriginalPicture": "PresetOriginalPicture.webp",
"PresetSmallPicture": "PresetSmallPicture.webp",
"PresetMediumPicture": "PresetMediumPicture.webp",
"PresetLargePicture": "PresetLargePicture.webp",
"OriginalAudiosFolder": "OriginalAudios",
"ProcessedAudioFolder": "ProcessedAudios"
},
"PicturesSizes": {
"SmallPictureWidth": 128,
"SmallPictureHeight": 128,
"MediumPictureWidth": 256,
"MediumPictureHeight": 256,
"LargePictureWidth": 512,
"LargePictureHeight": 512
}
},
"AudioTranscoder": {
"TranscodingTimeout": "00:05:00",
"Ffmpeg": {
"ExecutableName": "ffmpeg",
"OutputFileName": "audio.m4a",
"AudioCodec": "aac",
"AudioBitrate": "128k",
"AudioChannels": 2,
"AudioSampleRate": 48000,
"AudioProfile": "aac_low",
"AdditionalArguments": ""
}
},
"UploadIntent": {
"UploadUrlExpiresInSeconds": 120,
"MaxActiveUploadIntentsPerUser": 5,
"MaxActiveUploadBytesPerUser": 209715200,
"MaxUploadBytes": 104857600,
"DefaultExpectedPictureSizeBytes": 10485760,
"DefaultExpectedAudioSizeBytes": 104857600,
"ExpirationJobIntervalSeconds": 300,
"ExpiredIntentsRetentionDays": 7,
"TempRootPrefix": "temp",
"TempUploadsRetentionDays": 3,
"TempCleanupJobIntervalSeconds": 3600
},
"Workers": {
"Routes": {
"TemporaryFilesDirectory": ""
}
}
}
@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Api.Authentication;
public sealed class AuthenticationConfiguration
{
public const string SectionName = "Authentication";
[Required]
[Url]
public required string MetadataAddress { get; set; }
[Required]
public required string IssuerAddress { get; set; }
[Required]
public required string AudienceAddress { get; set; }
[Required]
public required string ClientId { get; set; }
public string? ClientSecret { get; set; }
[Required]
[Url]
public required string AuthorizationEndpoint { get; set; }
[Required]
[Url]
public required string TokenEndpoint { get; set; }
[Required]
[MinLength(1)]
public required string[] Scopes { get; set; }
public string? ScalarRedirectUri { get; set; }
public bool RequireHttpsMetadata { get; set; } = true;
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
namespace Musify.Api.Authentication;
public static class AuthenticationExtensions
{
public static IServiceCollection AddAuthenticationConfiguration(
this IServiceCollection services,
IConfiguration configuration)
{
services
.AddOptionsWithValidateOnStart<AuthenticationConfiguration>()
.Bind(configuration.GetRequiredSection(AuthenticationConfiguration.SectionName))
.ValidateDataAnnotations();
services.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<AuthenticationConfiguration>>().Value);
services.AddMemoryCache();
services.AddScoped<JwtBearerEventsHandler>();
services.ConfigureOptions<JwtBearerOptionsSetup>();
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
services.AddAuthorization();
return services;
}
}
@@ -0,0 +1,43 @@
using System.Reflection;
using System.Security.Claims;
namespace Musify.Api.Authentication;
public sealed class CurrentUser : IBindableFromHttpContext<CurrentUser>
{
private CurrentUser(ClaimsPrincipal principal)
{
Principal = principal;
var isAuthenticated = principal.Identity?.IsAuthenticated;
IsAuthenticated = isAuthenticated.HasValue && isAuthenticated.Value;
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (long.TryParse(rawId, out var id) && id != 0)
Id = id;
Username = principal.FindFirstValue(ClaimTypes.Name);
FirstName = principal.FindFirstValue(ClaimTypes.GivenName);
LastName = principal.FindFirstValue(ClaimTypes.Surname);
}
public ClaimsPrincipal Principal { get; }
public bool IsAuthenticated { get; }
public long? Id { get; }
public string? Username { get; }
public string? FirstName { get; }
public string? LastName { get; }
public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid.");
public bool HasClaim(string type) => Principal.HasClaim(c => c.Type == type);
public string? FindClaim(string type) => Principal.FindFirstValue(type);
public static ValueTask<CurrentUser?> BindAsync(HttpContext context, ParameterInfo parameter) =>
ValueTask.FromResult<CurrentUser?>(new CurrentUser(context.User));
}
@@ -0,0 +1,66 @@
using Mediator;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Caching.Memory;
using Musify.Application.Users;
using System.Security.Claims;
namespace Musify.Api.Authentication;
public sealed class JwtBearerEventsHandler(
IMediator mediator,
IMemoryCache cache,
ILogger<JwtBearerEventsHandler> logger) : JwtBearerEvents
{
private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15);
public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext)
{
var principal = tokenValidatedContext.Principal!;
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (!long.TryParse(rawId, out var userId))
{
logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId);
return;
}
var cacheKey = $"user-synced:{userId}";
if (cache.TryGetValue(cacheKey, out _))
return;
var username = principal.FindFirstValue(ClaimTypes.Name)
?? principal.FindFirstValue("preferred_username")
?? principal.FindFirstValue(ClaimTypes.Email)
?? rawId!;
var firstName = principal.FindFirstValue(ClaimTypes.GivenName);
var lastName = principal.FindFirstValue(ClaimTypes.Surname);
var profilePictureUrl = principal.FindFirstValue("picture");
try
{
await mediator.Send(
new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl),
tokenValidatedContext.HttpContext.RequestAborted);
cache.Set(cacheKey, true, UserSyncCacheTtl);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId);
}
}
public override Task AuthenticationFailed(AuthenticationFailedContext context)
{
logger.LogWarning(context.Exception, "JWT authentication failed");
return Task.CompletedTask;
}
public override Task Challenge(JwtBearerChallengeContext context)
{
if (context.AuthenticateFailure is not null)
logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription);
return Task.CompletedTask;
}
}
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
namespace Musify.Api.Authentication;
public sealed class JwtBearerOptionsSetup(IOptions<AuthenticationConfiguration> options)
: IConfigureNamedOptions<JwtBearerOptions>
{
private readonly AuthenticationConfiguration _configuration = options.Value;
public void Configure(string? name, JwtBearerOptions options)
{
if (name != JwtBearerDefaults.AuthenticationScheme)
{
return;
}
Configure(options);
}
public void Configure(JwtBearerOptions options)
{
options.MetadataAddress = _configuration.MetadataAddress;
options.RequireHttpsMetadata = _configuration.RequireHttpsMetadata;
options.Audience = _configuration.AudienceAddress;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = _configuration.IssuerAddress,
ValidAudience = _configuration.AudienceAddress,
RoleClaimType = ClaimTypes.Role,
NameClaimType = ClaimTypes.Name,
};
options.EventsType = typeof(JwtBearerEventsHandler);
}
}
@@ -0,0 +1,6 @@
namespace Musify.Api.DataTransferObjects.Albums;
public record CreateAlbumRequest(
string Title,
string? Description,
int? ReleaseYear);
@@ -0,0 +1,6 @@
namespace Musify.Api.DataTransferObjects.Albums;
public record UpdateAlbumRequest(
string NewTitle,
string? NewDescription,
int? NewReleaseYear);
@@ -0,0 +1,3 @@
namespace Musify.Api.DataTransferObjects.PlayLists;
public record AddYouTubeTrackRequest(string VideoId);
@@ -0,0 +1,6 @@
namespace Musify.Api.DataTransferObjects.PlayLists;
public record CreatePlayListRequest(
string Name,
string? Description,
Guid? PictureIntentId);
@@ -0,0 +1,6 @@
namespace Musify.Api.DataTransferObjects.PlayLists;
public record RequestPlayListPictureUploadRequest(
string FileType,
string ContentType,
long? ExpectedSizeBytes = null);
@@ -0,0 +1,6 @@
namespace Musify.Api.DataTransferObjects.PlayLists;
public record UpdatePlayListRequest(
string? NewName,
string? NewDescription,
Guid? NewPictureIntentId);
@@ -0,0 +1,6 @@
namespace Musify.Api.DataTransferObjects.Tracks;
public record CreateTrackRequest(
string Title,
Guid PictureIntentId,
Guid AudioIntentId);
@@ -0,0 +1,9 @@
namespace Musify.Api.DataTransferObjects.Tracks;
public record RequestTrackUploadUrlsRequest(
string PictureFileType,
string PictureContentType,
string AudioFileType,
string AudioContentType,
long? ExpectedPictureSizeBytes = null,
long? ExpectedAudioSizeBytes = null);
@@ -0,0 +1,7 @@
namespace Musify.Api.DataTransferObjects.Users;
public record CreateUserRequest(
long Id,
string Name,
string? FirstName,
string? SecondName);
@@ -0,0 +1,214 @@
using Mediator;
using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.Extensions;
using Musify.Api.Filters;
using Musify.Application.Albums;
using Musify.Application.Albums.Responses;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
namespace Musify.Api.Endpoints;
public static class AlbumEndpoints
{
public static IEndpointRouteBuilder MapAlbumEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/albums")
.WithTags("Albums");
group.MapGet("/", GetAlbums)
.WithName("GetAlbums")
.WithSummary("Get Paginated Albums.")
.Produces<PaginatedResponse<AlbumApplicationResponse>>();
group.MapGet("/{id}", GetAlbumById)
.WithName("GetAlbumById")
.WithSummary("Get An Album By Id.")
.Produces<AlbumApplicationResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/users/{userId}", GetAlbumsByUserId)
.WithName("GetAlbumsByUserId")
.WithSummary("Get Paginated Albums For A User.")
.Produces<PaginatedResponse<AlbumApplicationResponse>>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/recent", GetRecentlyListenedAlbums)
.WithName("GetRecentlyListenedAlbums")
.WithSummary("Get The Albums The Current User Listened To Most Recently.")
.RequireAuthorization()
.Produces<IReadOnlyList<AlbumApplicationResponse>>()
.Produces(StatusCodes.Status401Unauthorized);
group.MapGet("/{albumId}/tracks", GetAlbumTracks)
.WithName("GetAlbumTracks")
.WithSummary("Get The Tracks Of An Album Ordered By Track Number.")
.Produces<PaginatedResponse<TrackApplicationResponse>>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/", CreateAlbum)
.WithName("CreateAlbum")
.WithSummary("Create A New Album.")
.AddEndpointFilter<ValidationFilter<CreateAlbumRequest>>()
.RequireAuthorization()
.Produces<AlbumApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPut("/{albumId}", UpdateAlbum)
.WithName("UpdateAlbum")
.WithSummary("Update An Album.")
.AddEndpointFilter<ValidationFilter<UpdateAlbumRequest>>()
.RequireAuthorization()
.Produces<AlbumApplicationResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapDelete("/{albumId}", DeleteAlbum)
.WithName("DeleteAlbum")
.WithSummary("Delete An Album.")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{albumId}/tracks/{trackId}", AddTrackToAlbum)
.WithName("AddTrackToAlbum")
.WithSummary("Add One Of Your Uploaded Tracks To An Album.")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapDelete("/{albumId}/tracks/{trackId}", RemoveTrackFromAlbum)
.WithName("RemoveTrackFromAlbum")
.WithSummary("Remove A Track From An Album.")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
return app;
}
private static async Task<IResult> GetAlbums(
IMediator mediator,
CancellationToken cancellationToken,
string? title,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetAlbumById(
IMediator mediator,
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetAlbumByIdQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetAlbumsByUserId(
IMediator mediator,
long userId,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetRecentlyListenedAlbums(
IMediator mediator,
CurrentUser currentUser,
CancellationToken cancellationToken,
int limit = 12)
{
var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetAlbumTracks(
IMediator mediator,
Guid albumId,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 50)
{
var result = await mediator.Send(new GetAlbumTracksQuery(albumId, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateAlbum(
IMediator mediator,
CurrentUser currentUser,
CreateAlbumRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear),
cancellationToken);
return result.ToCreatedResult(album => $"/albums/{album.Id}");
}
private static async Task<IResult> UpdateAlbum(
IMediator mediator,
CurrentUser currentUser,
Guid albumId,
UpdateAlbumRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new UpdateAlbumCommand(currentUser.RequiredId, albumId, request.NewTitle, request.NewDescription, request.NewReleaseYear),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteAlbum(
IMediator mediator,
CurrentUser currentUser,
Guid albumId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new DeleteAlbumCommand(currentUser.RequiredId, albumId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddTrackToAlbum(
IMediator mediator,
CurrentUser currentUser,
Guid albumId,
Guid trackId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new AddTrackToAlbumCommand(currentUser.RequiredId, albumId, trackId),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveTrackFromAlbum(
IMediator mediator,
CurrentUser currentUser,
Guid albumId,
Guid trackId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new RemoveTrackFromAlbumCommand(currentUser.RequiredId, albumId, trackId),
cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,51 @@
using Mediator;
using Musify.Api.Authentication;
using Musify.Api.Extensions;
using Musify.Application.Mixes;
using Musify.Application.Mixes.Responses;
namespace Musify.Api.Endpoints;
public static class MixEndpoints
{
public static IEndpointRouteBuilder MapMixEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/mixes")
.WithTags("Mixes")
.RequireAuthorization();
group.MapGet("/", GetMixes)
.WithName("GetMixes")
.WithSummary("Get The Server-Generated Mixes Of The Current User.")
.Produces<IReadOnlyList<MixApplicationResponse>>()
.Produces(StatusCodes.Status401Unauthorized);
group.MapGet("/{id}", GetMixById)
.WithName("GetMixById")
.WithSummary("Get A Mix With All Of Its Songs.")
.Produces<MixApplicationResponse>()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
return app;
}
private static async Task<IResult> GetMixes(
IMediator mediator,
CurrentUser currentUser,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetMixById(
IMediator mediator,
CurrentUser currentUser,
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMixByIdQuery(currentUser.RequiredId, id), cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,272 @@
using Mediator;
using Musify.Application.PlayLists;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Tracks.Responses;
using Musify.Application.Shared;
using Musify.Application.Contracts;
using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.PlayLists;
using Musify.Api.Extensions;
using Musify.Api.Filters;
namespace Musify.Api.Endpoints;
public static class PlayListEndpoints
{
public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/playlists")
.WithTags("PlayLists");
group.MapGet("/", GetPlayLists)
.WithName("GetPlayLists")
.WithSummary("Get Paginated PlayLists.")
.Produces<PaginatedResponse<PlayListApplicationResponse>>();
group.MapGet("/{id}", GetPlayListById)
.WithName("GetPlayListById")
.WithSummary("Get A PlayList By Id.")
.Produces<PlayListApplicationResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/users/{userId}", GetPlayListsByUserId)
.WithName("GetPlayListsByUserId")
.WithSummary("Get Paginated PlayLists For A User.")
.Produces<PaginatedResponse<PlayListApplicationResponse>>();
group.MapGet("/{playlistId}/tracks", GetPlayListTracks)
.WithName("GetPlayListTracks")
.WithSummary("Get Paginated Tracks Of A PlayList.")
.Produces<PaginatedResponse<TrackApplicationResponse>>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/cover", GetPlayListCover)
.WithName("GetPlayListCover")
.WithSummary("Get A PlayList Cover Image.")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/", CreatePlayList)
.WithName("CreatePlayList")
.WithSummary("Create A New PlayList.")
.AddEndpointFilter<ValidationFilter<CreatePlayListRequest>>()
.RequireAuthorization()
.Produces<PlayListApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPut("/{playlistId}", UpdatePlayList)
.WithName("UpdatePlayList")
.WithSummary("Update An Existing PlayList.")
.AddEndpointFilter<ValidationFilter<UpdatePlayListRequest>>()
.RequireAuthorization()
.Produces<PlayListApplicationResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapDelete("/{playlistId}", DeletePlayList)
.WithName("DeletePlayList")
.WithSummary("Delete A PlayList.")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/upload-picture", RequestPlayListPictureUpload)
.WithName("RequestPlayListPictureUpload")
.WithSummary("Request A Pre-Signed URL To Upload A PlayList Picture.")
.AddEndpointFilter<ValidationFilter<RequestPlayListPictureUploadRequest>>()
.RequireAuthorization()
.Produces<PlayListPictureUploadResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{playlistId}/tracks/{trackId}", AddTrackToPlayList)
.WithName("AddTrackToPlayList")
.WithSummary("Add A Track To A PlayList.")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapPost("/{playlistId}/youtube-tracks", AddYouTubeTrackToPlayList)
.WithName("AddYouTubeTrackToPlayList")
.WithSummary("Add A YouTube Track To A PlayList, Downloading It In The Background If Needed.")
.AddEndpointFilter<ValidationFilter<AddYouTubeTrackRequest>>()
.RequireAuthorization()
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList)
.WithName("RemoveTrackFromPlayList")
.WithSummary("Remove A Track From A PlayList.")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
return app;
}
private static async Task<IResult> GetPlayLists(
IMediator mediator,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetPlayListsQuery(pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetPlayListById(
IMediator mediator,
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetPlayListByIdQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetPlayListsByUserId(
IMediator mediator,
long userId,
CancellationToken cancellationToken,
string? name,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetPlayListTracks(
IMediator mediator,
Guid playlistId,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetPlayListCover(
IMediator mediator,
IStorageService storageService,
HttpResponse response,
Guid id,
CancellationToken cancellationToken,
string size = "medium")
{
var result = await mediator.Send(new GetPlayListCoverQuery(id, size), cancellationToken);
if (result.IsError)
return Results.NotFound();
var location = result.Value;
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.Stream(stream, location.ContentType);
}
private static async Task<IResult> CreatePlayList(
IMediator mediator,
CurrentUser currentUser,
CreatePlayListRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId),
cancellationToken);
return result.ToCreatedResult(playList => $"/playlists/{playList.Id}");
}
private static async Task<IResult> UpdatePlayList(
IMediator mediator,
CurrentUser currentUser,
Guid playlistId,
UpdatePlayListRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new UpdatePlayListCommand(currentUser.RequiredId, playlistId, request.NewName, request.NewDescription, request.NewPictureIntentId),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeletePlayList(
IMediator mediator,
CurrentUser currentUser,
Guid playlistId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new DeletePlayListCommand(currentUser.RequiredId, playlistId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RequestPlayListPictureUpload(
IMediator mediator,
CurrentUser currentUser,
RequestPlayListPictureUploadRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new RequestPlayListPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddTrackToPlayList(
IMediator mediator,
CurrentUser currentUser,
Guid playlistId,
Guid trackId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, trackId),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddYouTubeTrackToPlayList(
IMediator mediator,
CurrentUser currentUser,
Guid playlistId,
AddYouTubeTrackRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new AddYouTubeTrackToPlayListCommand(
currentUser.RequiredId,
playlistId,
request.VideoId),
cancellationToken);
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
}
private static async Task<IResult> RemoveTrackFromPlayList(
IMediator mediator,
CurrentUser currentUser,
Guid playlistId,
Guid trackId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new RemoveTrackFromPlayListCommand(currentUser.RequiredId, playlistId, trackId),
cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,186 @@
using Mediator;
using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
using Musify.Application.Shared;
using Musify.Application.Contracts;
using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Extensions;
using Musify.Api.Filters;
namespace Musify.Api.Endpoints;
public static class TrackEndpoints
{
public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/tracks")
.WithTags("Tracks");
group.MapGet("/", GetTracks)
.WithName("GetTracks")
.WithSummary("Get Paginated Tracks.")
.Produces<PaginatedResponse<TrackApplicationResponse>>();
group.MapGet("/{id}", GetTrackById)
.WithName("GetTrackById")
.WithSummary("Get A Track By Id.")
.Produces<TrackApplicationResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/cover", GetTrackCover)
.WithName("GetTrackCover")
.WithSummary("Get A Track Cover Image.")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/stream", GetTrackStream)
.WithName("GetTrackStream")
.WithSummary("Get A Streaming Manifest URL And Ticket For A Track.")
.RequireAuthorization()
.Produces<TrackStreamResponse>()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/users/{userId}", GetTracksByUserId)
.WithName("GetTracksByUserId")
.WithSummary("Get Paginated Tracks For A User.")
.Produces<PaginatedResponse<TrackApplicationResponse>>();
group.MapPost("/", CreateTrack)
.WithName("CreateTrack")
.WithSummary("Create A New Track.")
.AddEndpointFilter<ValidationFilter<CreateTrackRequest>>()
.RequireAuthorization()
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapDelete("/{trackId}", DeleteTrack)
.WithName("DeleteTrack")
.WithSummary("Delete A Track.")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapPost("/upload-urls", RequestTrackUploadUrls)
.WithName("RequestTrackUploadUrls")
.WithSummary("Request Pre-Signed URLs To Upload Track Picture And Audio.")
.AddEndpointFilter<ValidationFilter<RequestTrackUploadUrlsRequest>>()
.RequireAuthorization()
.Produces<TrackUploadUrlsResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
return app;
}
private static async Task<IResult> GetTracks(
IMediator mediator,
CancellationToken cancellationToken,
string? name,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTrackById(
IMediator mediator,
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTrackCover(
IMediator mediator,
IStorageService storageService,
HttpResponse response,
Guid id,
CancellationToken cancellationToken,
string size = "medium")
{
var result = await mediator.Send(new GetTrackCoverQuery(id, size), cancellationToken);
if (result.IsError)
return Results.NotFound();
var location = result.Value;
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.Stream(stream, location.ContentType);
}
private static async Task<IResult> GetTrackStream(
IMediator mediator,
CurrentUser currentUser,
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.RequiredId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTracksByUserId(
IMediator mediator,
long userId,
CancellationToken cancellationToken,
string? name,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateTrack(
IMediator mediator,
CurrentUser currentUser,
CreateTrackRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId),
cancellationToken);
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
}
private static async Task<IResult> DeleteTrack(
IMediator mediator,
CurrentUser currentUser,
Guid trackId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, trackId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RequestTrackUploadUrls(
IMediator mediator,
CurrentUser currentUser,
RequestTrackUploadUrlsRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new RequestTrackUploadUrlsCommand(
currentUser.RequiredId,
request.PictureFileType,
request.PictureContentType,
request.AudioFileType,
request.AudioContentType,
request.ExpectedPictureSizeBytes,
request.ExpectedAudioSizeBytes),
cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,87 @@
using Mediator;
using Musify.Api.DataTransferObjects.Users;
using Musify.Api.Extensions;
using Musify.Api.Filters;
using Musify.Application.Shared;
using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
using Musify.Application.Users;
using Musify.Application.Users.Responses;
namespace Musify.Api.Endpoints;
public static class UserEndpoints
{
public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/users")
.WithTags("Users");
group.MapGet("/", GetUsers)
.WithName("GetUsers")
.WithSummary("Get Paginated Users.")
.Produces<PaginatedResponse<UserApplicationResponse>>();
group.MapGet("/{id}", GetUserById)
.WithName("GetUserById")
.WithSummary("Get A User By Id.")
.Produces<UserApplicationResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/listening-history", GetListeningHistory)
.WithName("GetListeningHistory")
.WithSummary("Get A User'S Listening History.")
.Produces<IEnumerable<TrackApplicationResponse>>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/", CreateUser)
.WithName("CreateUser")
.WithSummary("Create A New User.")
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>()
.Produces<UserApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status409Conflict);
return app;
}
private static async Task<IResult> GetUsers(
IMediator mediator,
CancellationToken cancellationToken,
string? usernameSearch,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetUserById(
IMediator mediator,
long id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IEnumerable<TrackApplicationResponse>> GetListeningHistory(
IMediator mediator,
long id,
CancellationToken cancellationToken)
{
return await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken);
}
private static async Task<IResult> CreateUser(
IMediator mediator,
CreateUserRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName),
cancellationToken);
return result.ToCreatedResult(user => $"/users/{user.Id}");
}
}
@@ -0,0 +1,99 @@
using Mediator;
using Musify.Api.Authentication;
using Musify.Api.Extensions;
using Musify.Application.Contracts;
using Musify.Application.YouTube;
using Musify.Application.YouTube.Responses;
namespace Musify.Api.Endpoints;
public static class YouTubeEndpoints
{
public static IEndpointRouteBuilder MapYouTubeEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/youtube")
.WithTags("YouTube");
group.MapGet("/search", SearchTracks)
.WithName("SearchYouTubeTracks")
.WithSummary("Search Songs On YouTube Music.")
.RequireAuthorization()
.Produces<YouTubeSearchResult>()
.Produces(StatusCodes.Status401Unauthorized)
.ProducesValidationProblem();
group.MapGet("/albums", SearchAlbums)
.WithName("SearchYouTubeAlbums")
.WithSummary("Search Albums On YouTube Music.")
.RequireAuthorization()
.Produces<YouTubeAlbumSearchResult>()
.Produces(StatusCodes.Status401Unauthorized)
.ProducesValidationProblem();
group.MapGet("/albums/{albumId}", GetAlbum)
.WithName("GetYouTubeAlbum")
.WithSummary("Get A YouTube Music Album With Its Tracklist.")
.RequireAuthorization()
.Produces<YouTubeAlbumDetail>()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/tracks/{videoId}/stream", GetTrackStream)
.WithName("GetYouTubeTrackStream")
.WithSummary("Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise.")
.RequireAuthorization()
.Produces<YouTubeStreamResponse>()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
return app;
}
private static async Task<IResult> GetTrackStream(
IMediator mediator,
CurrentUser currentUser,
string videoId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new ResolveYouTubeTrackStreamCommand(videoId, currentUser.RequiredId),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SearchAlbums(
IMediator mediator,
CancellationToken cancellationToken,
string query,
string? continuation = null)
{
var result = await mediator.Send(
new SearchYouTubeAlbumsQuery(query, continuation ?? string.Empty),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetAlbum(
IMediator mediator,
string albumId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetYouTubeAlbumQuery(albumId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SearchTracks(
IMediator mediator,
CancellationToken cancellationToken,
string query,
string? continuation = null)
{
var result = await mediator.Send(
new SearchYouTubeTracksQuery(query, continuation ?? string.Empty),
cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,43 @@
using ErrorOr;
using IResult = Microsoft.AspNetCore.Http.IResult;
namespace Musify.Api.Extensions;
public static class ErrorOrHttpExtensions
{
public static IResult ToHttpResult<T>(this ErrorOr<T> result)
{
if (result.IsError)
return Problem(result.Errors);
return result.Value is Success
? Results.NoContent()
: Results.Ok(result.Value);
}
public static IResult ToCreatedResult<T>(this ErrorOr<T> result, Func<T, string> locationFactory) =>
result.IsError
? Problem(result.Errors)
: Results.Created(locationFactory(result.Value), result.Value);
private static IResult Problem(List<Error> errors)
{
if (errors.All(e => e.Type == ErrorType.Validation))
return Results.ValidationProblem(ToValidationDictionary(errors));
var error = errors[0];
return error.Type switch
{
ErrorType.NotFound => Results.NotFound(error.Description),
ErrorType.Unauthorized => Results.Unauthorized(),
ErrorType.Forbidden => Results.StatusCode(StatusCodes.Status403Forbidden),
ErrorType.Conflict => Results.Conflict(error.Description),
_ => Results.Problem(error.Description)
};
}
private static Dictionary<string, string[]> ToValidationDictionary(IEnumerable<Error> errors) =>
errors
.GroupBy(e => string.IsNullOrEmpty(e.Code) ? "general" : e.Code)
.ToDictionary(g => g.Key, g => g.Select(e => e.Description).ToArray());
}
@@ -0,0 +1,30 @@
using FluentValidation;
using Musify.Infrastructure.MassTransit;
using Musify.Infrastructure.Persistence;
using Musify.Application;
using Musify.Infrastructure.Services;
using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.PlayLists;
using Musify.Api.OpenApi;
using Musify.Api.Scalar;
namespace Musify.Api.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddApplication(configuration);
services.AddDatabase(configuration);
services.AddStorageService(configuration);
services.AddStreamTicketService(configuration);
services.AddYouTubeMusicService(configuration);
services.AddMassTransitClient(configuration);
services.AddAuthenticationConfiguration(configuration);
services.AddOpenApiConfiguration();
services.AddScalarConfiguration();
services.AddValidatorsFromAssemblyContaining<CreatePlayListRequest>();
return services;
}
}
@@ -0,0 +1,20 @@
using FluentValidation;
namespace Musify.Api.Filters;
public sealed class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var argument = context.Arguments.OfType<T>().FirstOrDefault();
if (argument is null)
return await next(context);
var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted);
return result.IsValid
? await next(context)
: Results.ValidationProblem(result.ToDictionary());
}
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<UserSecretsId>1c3fa41c-6f07-433b-af79-1f67fad338fd</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ErrorOr" />
<PackageReference Include="AWSSDK.S3" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
<PackageReference Include="MassTransit" />
<PackageReference Include="MassTransit.EntityFrameworkCore" />
<PackageReference Include="MassTransit.RabbitMQ" />
<PackageReference Include="Mediator.Abstractions" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Api" />
<PackageReference Include="OpenTelemetry.Api.ProviderBuilderExtensions" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="X.PagedList.EF" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\Musify.Application\Musify.Application.csproj" />
<ProjectReference Include="..\..\Core\Musify.Domain\Musify.Domain.csproj" />
<ProjectReference Include="..\..\Core\Musify.Infrastructure\Musify.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.OpenApi;
namespace Musify.Api.OpenApi;
public static class OpenApiExtensions
{
public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services)
{
services.AddOpenApi();
services.ConfigureOptions<OpenApiOptionsSetup>();
return services;
}
}
@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Musify.Api.Authentication;
namespace Musify.Api.OpenApi;
public sealed class OpenApiOptionsSetup(IOptions<AuthenticationConfiguration> options)
: IConfigureNamedOptions<OpenApiOptions>
{
private const string SecuritySchemeId = "OAuth2";
private readonly AuthenticationConfiguration _configuration = options.Value;
public void Configure(string? name, OpenApiOptions options) => Configure(options);
public void Configure(OpenApiOptions options)
{
options.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes[SecuritySchemeId] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
AuthorizationCode = new OpenApiOAuthFlow
{
AuthorizationUrl = new Uri(_configuration.AuthorizationEndpoint),
TokenUrl = new Uri(_configuration.TokenEndpoint),
Scopes = _configuration.Scopes.ToDictionary(scope => scope, scope => scope),
},
},
};
return Task.CompletedTask;
} );
options.AddOperationTransformer((operation, context, _) =>
{
var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata
.OfType<IAuthorizeData>()
.Any();
var allowsAnonymous = context.Description.ActionDescriptor.EndpointMetadata
.OfType<IAllowAnonymous>()
.Any();
if (!requiresAuthorization || allowsAnonymous)
{
return Task.CompletedTask;
}
operation.Security ??= new List<OpenApiSecurityRequirement>();
operation.Security.Add(new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] =
_configuration.Scopes.ToList(),
} );
return Task.CompletedTask;
} );
}
}
+48
View File
@@ -0,0 +1,48 @@
using System.Text.Json.Serialization;
using Musify.Infrastructure.Observability;
using Scalar.AspNetCore;
using Musify.Api.Endpoints;
using Musify.Api.Extensions;
var builder = WebApplication.CreateBuilder();
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
const string devCorsPolicy = "dev-cors";
builder.Services
.AddApplicationServices(builder.Configuration)
.AddObservability(builder.Configuration);
builder.Services.AddProblemDetails();
if (builder.Environment.IsDevelopment())
{
builder.Services.AddCors(options => options.AddPolicy(devCorsPolicy, policy =>
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
}
var app = builder.Build();
app.UseExceptionHandler();
if (app.Environment.IsDevelopment())
{
app.UseCors(devCorsPolicy);
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseAuthentication();
app.UseAuthorization();
app
.MapAlbumEndpoints()
.MapMixEndpoints()
.MapPlayListEndpoints()
.MapTrackEndpoints()
.MapUserEndpoints()
.MapYouTubeEndpoints();
app.Run();
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5111",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,11 @@
namespace Musify.Api.Scalar;
public static class ScalarExtensions
{
public static IServiceCollection AddScalarConfiguration(this IServiceCollection services)
{
services.ConfigureOptions<ScalarOptionsSetup>();
return services;
}
}
@@ -0,0 +1,32 @@
using Microsoft.Extensions.Options;
using Scalar.AspNetCore;
using Musify.Api.Authentication;
namespace Musify.Api.Scalar;
public sealed class ScalarOptionsSetup(IOptions<AuthenticationConfiguration> options)
: IConfigureOptions<ScalarOptions>
{
private const string SecuritySchemeId = "OAuth2";
private readonly AuthenticationConfiguration _configuration = options.Value;
public void Configure(ScalarOptions scalarOptions)
{
scalarOptions
.WithTitle("Musify API")
.AddPreferredSecuritySchemes(SecuritySchemeId)
.AddAuthorizationCodeFlow(SecuritySchemeId, flow =>
{
flow.ClientId = _configuration.ClientId;
flow.ClientSecret = _configuration.ClientSecret;
flow.AuthorizationUrl = _configuration.AuthorizationEndpoint;
flow.TokenUrl = _configuration.TokenEndpoint;
flow.Pkce = Pkce.Sha256;
flow.SelectedScopes = _configuration.Scopes;
flow.RedirectUri = _configuration.ScalarRedirectUri;
flow.RefreshUrl = _configuration.TokenEndpoint;
} )
.HideModels();
}
}
@@ -0,0 +1,8 @@
namespace Musify.Api.Validators.Albums;
public static class AlbumReleaseYear
{
public const int Earliest = 1877;
public static int Latest => DateTime.UtcNow.Year + 1;
}
@@ -0,0 +1,21 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Albums;
namespace Musify.Api.Validators.Albums;
public sealed class CreateAlbumRequestValidator : AbstractValidator<CreateAlbumRequest>
{
public CreateAlbumRequestValidator()
{
RuleFor(x => x.Title)
.NotEmpty()
.MaximumLength(200);
RuleFor(x => x.Description)
.MaximumLength(256);
RuleFor(x => x.ReleaseYear)
.InclusiveBetween(AlbumReleaseYear.Earliest, AlbumReleaseYear.Latest)
.When(x => x.ReleaseYear.HasValue);
}
}
@@ -0,0 +1,21 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Albums;
namespace Musify.Api.Validators.Albums;
public sealed class UpdateAlbumRequestValidator : AbstractValidator<UpdateAlbumRequest>
{
public UpdateAlbumRequestValidator()
{
RuleFor(x => x.NewTitle)
.NotEmpty()
.MaximumLength(200);
RuleFor(x => x.NewDescription)
.MaximumLength(256);
RuleFor(x => x.NewReleaseYear)
.InclusiveBetween(AlbumReleaseYear.Earliest, AlbumReleaseYear.Latest)
.When(x => x.NewReleaseYear.HasValue);
}
}
@@ -0,0 +1,14 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.PlayLists;
namespace Musify.Api.Validators.PlayLists;
public sealed class AddYouTubeTrackRequestValidator : AbstractValidator<AddYouTubeTrackRequest>
{
public AddYouTubeTrackRequestValidator()
{
RuleFor(x => x.VideoId)
.NotEmpty()
.Matches("^[A-Za-z0-9_-]{11}$");
}
}
@@ -0,0 +1,17 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.PlayLists;
namespace Musify.Api.Validators.PlayLists;
public sealed class CreatePlayListRequestValidator : AbstractValidator<CreatePlayListRequest>
{
public CreatePlayListRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(50);
RuleFor(x => x.Description)
.MaximumLength(256);
}
}
@@ -0,0 +1,20 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.PlayLists;
namespace Musify.Api.Validators.PlayLists;
public sealed class RequestPlayListPictureUploadRequestValidator : AbstractValidator<RequestPlayListPictureUploadRequest>
{
public RequestPlayListPictureUploadRequestValidator()
{
RuleFor(x => x.FileType)
.NotEmpty();
RuleFor(x => x.ContentType)
.NotEmpty();
RuleFor(x => x.ExpectedSizeBytes)
.GreaterThan(0)
.When(x => x.ExpectedSizeBytes.HasValue);
}
}
@@ -0,0 +1,19 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.PlayLists;
namespace Musify.Api.Validators.PlayLists;
public sealed class UpdatePlayListRequestValidator : AbstractValidator<UpdatePlayListRequest>
{
public UpdatePlayListRequestValidator()
{
RuleFor(x => x.NewName)
.NotEmpty()
.MaximumLength(50)
.When(x => x.NewName is not null);
RuleFor(x => x.NewDescription)
.MaximumLength(256)
.When(x => x.NewDescription is not null);
}
}
@@ -0,0 +1,20 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Tracks;
namespace Musify.Api.Validators.Tracks;
public sealed class CreateTrackRequestValidator : AbstractValidator<CreateTrackRequest>
{
public CreateTrackRequestValidator()
{
RuleFor(x => x.Title)
.NotEmpty()
.MaximumLength(200);
RuleFor(x => x.PictureIntentId)
.NotEmpty();
RuleFor(x => x.AudioIntentId)
.NotEmpty();
}
}
@@ -0,0 +1,30 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Tracks;
namespace Musify.Api.Validators.Tracks;
public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator<RequestTrackUploadUrlsRequest>
{
public RequestTrackUploadUrlsRequestValidator()
{
RuleFor(x => x.PictureFileType)
.NotEmpty();
RuleFor(x => x.PictureContentType)
.NotEmpty();
RuleFor(x => x.AudioFileType)
.NotEmpty();
RuleFor(x => x.AudioContentType)
.NotEmpty();
RuleFor(x => x.ExpectedPictureSizeBytes)
.GreaterThan(0)
.When(x => x.ExpectedPictureSizeBytes.HasValue);
RuleFor(x => x.ExpectedAudioSizeBytes)
.GreaterThan(0)
.When(x => x.ExpectedAudioSizeBytes.HasValue);
}
}
@@ -0,0 +1,25 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Users;
namespace Musify.Api.Validators.Users;
public sealed class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserRequestValidator()
{
RuleFor(x => x.Id)
.NotEmpty();
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(48);
RuleFor(x => x.FirstName)
.MaximumLength(48)
.When(x => !string.IsNullOrWhiteSpace(x.FirstName));
RuleFor(x => x.SecondName)
.MaximumLength(48)
.When(x => !string.IsNullOrWhiteSpace(x.SecondName));
}
}
+6
View File
@@ -0,0 +1,6 @@
@WebApi_HostAddress = http://localhost:5111
GET {{WebApi_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,51 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using Musify.StreamingGateway.Configuration;
namespace Musify.StreamingGateway.Authentication;
public sealed class TicketValidator : IDisposable
{
private readonly StreamTicketValidationOptions _options;
private readonly RSA _rsa;
private readonly TokenValidationParameters _validationParameters;
private readonly JsonWebTokenHandler _handler = new();
public TicketValidator(IOptions<StreamTicketValidationOptions> options)
{
_options = options.Value;
_rsa = RSA.Create();
_rsa.ImportFromPem(File.ReadAllText(_options.PublicKeyPath));
_validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = _options.Issuer,
ValidateAudience = true,
ValidAudience = _options.Audience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new RsaSecurityKey(_rsa),
ValidAlgorithms = [SecurityAlgorithms.RsaSha256],
ClockSkew = TimeSpan.FromSeconds(30),
};
}
public async Task<string> ValidateTicketAsync(string? token)
{
if (string.IsNullOrWhiteSpace(token))
return string.Empty;
var result = await _handler.ValidateTokenAsync(token, _validationParameters);
if (!result.IsValid)
return string.Empty;
var prefix = result.ClaimsIdentity?.FindFirst("prefix")?.Value;
return string.IsNullOrEmpty(prefix)? string.Empty : prefix;
}
public void Dispose() => _rsa.Dispose();
}
@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.StreamingGateway.Configuration;
public sealed class StreamTicketValidationOptions
{
public const string SectionName = "StreamTicket";
[Required]
public required string PublicKeyPath { get; set; }
[Required]
public string Audience { get; set; } = "media-gateway";
[Required]
public string Issuer { get; set; } = "musify-webapi";
public string QueryParameterName { get; set; } = "t";
public string HeaderName { get; set; } = "X-Stream-Ticket";
public string MediaPathPrefix { get; set; } = "/media";
}
@@ -0,0 +1,74 @@
using Microsoft.Extensions.Options;
using Musify.StreamingGateway.Authentication;
using Musify.StreamingGateway.Configuration;
namespace Musify.StreamingGateway.Middleware;
public sealed class TicketValidationMiddleware(
RequestDelegate next,
TicketValidator ticketValidator,
IOptions<StreamTicketValidationOptions> options,
ILogger<TicketValidationMiddleware> logger)
{
private readonly StreamTicketValidationOptions options = options.Value;
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path;
if (!path.StartsWithSegments(options.MediaPathPrefix, out var remaining))
{
await next(context);
return;
}
var token = context.Request.Query[options.QueryParameterName].ToString();
if (string.IsNullOrEmpty(token))
{
token = context.Request.Headers[options.HeaderName].ToString();
}
var prefix = await ticketValidator.ValidateTicketAsync(token);
if (string.IsNullOrEmpty(prefix))
{
logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path);
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty;
if (HasTraversalSegment(objectKey))
{
logger.LogWarning("Rejected media request {ObjectKey}: contains a path traversal segment", objectKey);
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
var prefixBoundary = prefix.EndsWith('/') ? prefix : prefix + "/";
if (!string.Equals(objectKey, prefix, StringComparison.Ordinal)
&& !objectKey.StartsWith(prefixBoundary, StringComparison.Ordinal))
{
logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
StripQueryParameter(context, options.QueryParameterName);
await next(context);
}
private static bool HasTraversalSegment(string objectKey) =>
objectKey.Split('/').Any(segment => segment is ".." or ".");
private static void StripQueryParameter(HttpContext context, string name)
{
var remaining = context.Request.Query
.Where(pair => !string.Equals(pair.Key, name, StringComparison.Ordinal))
.SelectMany(pair => pair.Value.Select(value => new KeyValuePair<string, string?>(pair.Key, value)))
.ToList();
context.Request.QueryString = QueryString.Create(remaining);
}
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<UserSecretsId>media-gateway-2f1c9b6a-7d3e-4c0a-9a21-0b8e5d4c3f10</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" />
<PackageReference Include="Yarp.ReverseProxy" />
</ItemGroup>
</Project>
@@ -0,0 +1,42 @@
using Musify.StreamingGateway.Authentication;
using Musify.StreamingGateway.Configuration;
using Musify.StreamingGateway.Middleware;
var builder = WebApplication.CreateBuilder();
builder.Services
.AddOptions<StreamTicketValidationOptions>()
.Bind(builder.Configuration.GetRequiredSection(StreamTicketValidationOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddSingleton<TicketValidator>();
const string corsPolicy = "media-clients";
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy =>
{
if (builder.Environment.IsDevelopment())
policy.AllowAnyOrigin();
else
policy.WithOrigins(allowedOrigins);
policy.AllowAnyHeader()
.WithMethods("GET", "HEAD")
.WithExposedHeaders("Content-Range", "Accept-Ranges", "Content-Length");
} ));
builder.Services
.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapGet("/health", () => Results.Ok("healthy"))
.WithSummary("Health Check Endpoint");
app.UseCors(corsPolicy);
app.UseMiddleware<TicketValidationMiddleware>();
app.MapReverseProxy();
app.Run();
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"StreamingGateway": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:8081",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,5 @@
{
"StreamTicket": {
"PublicKeyPath": "../../deploy/keys/stream_public.pem"
}
}
@@ -0,0 +1,41 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Yarp": "Information"
}
},
"AllowedHosts": "*",
"StreamTicket": {
"PublicKeyPath": "",
"Audience": "media-gateway",
"Issuer": "musify-webapi",
"QueryParameterName": "t",
"MediaPathPrefix": "/media"
},
"Cors": {
"AllowedOrigins": []
},
"ReverseProxy": {
"Routes": {
"media": {
"ClusterId": "seaweed-filer",
"Match": { "Path": "/media/{**rest}" },
"Transforms": [
{ "PathPattern": "/buckets/webapi-storage/{**rest}" }
]
}
},
"Clusters": {
"seaweed-filer": {
"Destinations": {
"filer": { "Address": "http://localhost:8888/" }
}
}
}
}
}
@@ -0,0 +1,42 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Database": {
"ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres"
},
"MassTransit": {
"Address": "rabbitmq://localhost:5672",
"Username": "guest",
"Password": "guest"
},
"OpenTelemetry": {
"OtlpEndpoint": "http://localhost:4317"
},
"YouTube": {
"GeographicalLocation": "ES"
},
"InfrastructureStorage": {
"Address": "http://localhost:8333",
"AccessKey": "admin_access_key",
"SecretAccessKey": "admin_secret_key",
"ForcePathStyle": true,
"UseHttp": true
},
"ApplicationStorage": {
"Bucket": "webapi-storage"
},
"AudioTranscoder": {
"Routes": {
"WorkingDirectory": "../../deploy/.tmp"
}
},
"Workers": {
"Routes": {
"TemporaryFilesDirectory": "../../deploy/.tmp"
}
}
}
@@ -0,0 +1,107 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Database": {
"ConnectionString": ""
},
"MassTransit": {
"Address": "",
"Username": "",
"Password": ""
},
"OpenTelemetry": {
"OtlpEndpoint": ""
},
"InfrastructureStorage": {
"Address": "",
"AccessKey": "",
"SecretAccessKey": "",
"ForcePathStyle": false,
"UseHttp": false
},
"ApplicationStorage": {
"Bucket": ""
},
"Mix": {
"ArtistMixCount": 3,
"ItemsPerMix": 12,
"SeedArtistCount": 3,
"YouTubeResultsPerSeed": 12
},
"PlayList": {
"Routes": {
"ParentFolders": "PlayLists",
"OriginalPicturesFolder": "OriginalPictures",
"SmallPicturesFolder": "SmallPictures",
"MediumPicturesFolder": "MediumPictures",
"LargePicturesFolder": "LargePictures"
},
"PicturesSizes": {
"SmallPictureWidth": 128,
"SmallPictureHeight": 128,
"MediumPictureWidth": 256,
"MediumPictureHeight": 256,
"LargePictureWidth": 512,
"LargePictureHeight": 512
},
"SeedPresetPictures": true,
"OverwritePresetPictures": false
},
"Track": {
"Routes": {
"ParentFolder": "Tracks",
"OriginalPicturesFolder": "OriginalPictures",
"SmallPicturesFolder": "SmallPictures",
"MediumPicturesFolder": "MediumPictures",
"LargePicturesFolder": "LargePictures",
"PresetOriginalPicture": "PresetOriginalPicture.webp",
"PresetSmallPicture": "PresetSmallPicture.webp",
"PresetMediumPicture": "PresetMediumPicture.webp",
"PresetLargePicture": "PresetLargePicture.webp",
"OriginalAudiosFolder": "OriginalAudios",
"ProcessedAudioFolder": "ProcessedAudios"
},
"PicturesSizes": {
"SmallPictureWidth": 128,
"SmallPictureHeight": 128,
"MediumPictureWidth": 256,
"MediumPictureHeight": 256,
"LargePictureWidth": 512,
"LargePictureHeight": 512
}
},
"AudioTranscoder": {
"Routes": {
"WorkingDirectory": ""
},
"TranscodingTimeout": "00:05:00"
},
"YtDlp": {
"ExecutableName": "yt-dlp",
"Format": "bestaudio",
"AdditionalArguments": "",
"DownloadTimeout": "00:05:00"
},
"Workers": {
"Routes": {
"TemporaryFilesDirectory": ""
}
},
"UploadIntent": {
"UploadUrlExpiresInSeconds": 120,
"MaxActiveUploadIntentsPerUser": 5,
"MaxActiveUploadBytesPerUser": 209715200,
"MaxUploadBytes": 104857600,
"DefaultExpectedPictureSizeBytes": 10485760,
"DefaultExpectedAudioSizeBytes": 104857600,
"ExpirationJobIntervalSeconds": 300,
"ExpiredIntentsRetentionDays": 7,
"TempRootPrefix": "temp",
"TempUploadsRetentionDays": 3,
"TempCleanupJobIntervalSeconds": 3600
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<UserSecretsId>dotnet-PictureWorker-1dfff89f-0602-41b7-8eaf-4da2c3358789</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" />
<PackageReference Include="MassTransit" />
<PackageReference Include="MassTransit.RabbitMQ" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<PackageReference Include="MimeMapping" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="SixLabors.ImageSharp" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\Musify.Application\Musify.Application.csproj" />
<ProjectReference Include="..\..\Core\Musify.Domain\Musify.Domain.csproj" />
<ProjectReference Include="..\..\Core\Musify.Infrastructure\Musify.Infrastructure.csproj" />
</ItemGroup>
</Project>
+58
View File
@@ -0,0 +1,58 @@
using Hangfire;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Musify.Application.Configuration;
using Musify.Application.Services;
using Musify.Infrastructure.Jobs;
using Musify.Infrastructure.MassTransit;
using Musify.Infrastructure.Persistence;
using Musify.Infrastructure.Services;
var builder = Host.CreateApplicationBuilder();
// Registering Mediator pulls in every handler in Musify.Application, including ones
// (e.g. CreatePlayList, CreateTrack) that depend on IEventBus, which the Worker never
// registers because it only consumes events, it never publishes them. Only the handlers
// this process actually dispatches (GenerateMixesForUserCommand) are ever constructed,
// so the default eager validate-on-build check is too strict for this host.
builder.ConfigureContainer(new DefaultServiceProviderFactory(new ServiceProviderOptions
{
ValidateOnBuild = false,
ValidateScopes = true
}));
var configuration = builder.Configuration;
var services = builder.Services;
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.AddStorageService(configuration);
services.AddAudioTranscoder(configuration);
services.AddYouTubeDownloader(configuration);
services.AddHttpClient();
services.AddPictureService(configuration);
services.AddPlayListPresetSeeder(configuration);
services.AddDatabase(configuration);
services.AddMassTransitConsumers(configuration);
services.AddUploadIntentJobs(configuration);
services.AddMediator();
services.AddScoped<YouTubeTrackProvisioner>();
services.AddYouTubeMusicService(configuration);
services.AddDailyMixGenerationJob(configuration);
var host = builder.Build();
using (var scope = host.Services.CreateScope())
{
var recurringJobManager = scope.ServiceProvider.GetRequiredService<IRecurringJobManager>();
recurringJobManager.AddOrUpdate<DailyMixGenerationJob>(
"daily-mix-generation",
job => job.RunAsync(CancellationToken.None),
Cron.Daily(),
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
}
host.Run();
@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"Worker": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}