Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
36 changed files with 2667 additions and 13 deletions
Showing only changes of commit 496a5f4a0b - Show all commits
+2
View File
@@ -9,6 +9,8 @@
<PackageVersion Include="DispatchR.Mediator" Version="2.2.0" /> <PackageVersion Include="DispatchR.Mediator" Version="2.2.0" />
<PackageVersion Include="ErrorOr" Version="2.1.1" /> <PackageVersion Include="ErrorOr" Version="2.1.1" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" /> <PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageVersion Include="Hangfire.NetCore" Version="1.8.24" />
<PackageVersion Include="Hangfire.PostgreSql" Version="1.21.1" />
<PackageVersion Include="MassTransit" Version="8.5.9" /> <PackageVersion Include="MassTransit" Version="8.5.9" />
<PackageVersion Include="MassTransit.EntityFrameworkCore" Version="8.5.9" /> <PackageVersion Include="MassTransit.EntityFrameworkCore" Version="8.5.9" />
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.5.9" /> <PackageVersion Include="MassTransit.RabbitMQ" Version="8.5.9" />
+6
View File
@@ -57,6 +57,12 @@
"ThumbnailSize": 240, "ThumbnailSize": 240,
"ArtworkSize": 544 "ArtworkSize": 544
}, },
"Mix": {
"ArtistMixCount": 3,
"ItemsPerMix": 12,
"SeedArtistCount": 3,
"YouTubeResultsPerSeed": 12
},
"PlayList": { "PlayList": {
"Routes": { "Routes": {
"UploadsFolder": "uploads", "UploadsFolder": "uploads",
@@ -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();
}
}
+1
View File
@@ -39,6 +39,7 @@ app.UseAuthorization();
app app
.MapAlbumEndpoints() .MapAlbumEndpoints()
.MapMixEndpoints()
.MapPlayListEndpoints() .MapPlayListEndpoints()
.MapTrackEndpoints() .MapTrackEndpoints()
.MapUserEndpoints() .MapUserEndpoints()
@@ -18,6 +18,7 @@ public static class ApplicationDependencyInjection
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName); services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName); services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName); services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName);
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName); services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName);
services.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName); services.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
@@ -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;
}
}
@@ -29,6 +29,10 @@ namespace Musify.Application.Contracts
DbSet<PlayListHasTrack> PlayListHasTracks { get; } DbSet<PlayListHasTrack> PlayListHasTracks { get; }
DbSet<Mix> Mixes { get; }
DbSet<MixItem> MixItems { get; }
DbSet<Upload> Uploads { get; } DbSet<Upload> Uploads { get; }
DbSet<UploadIntent> UploadIntents { get; } DbSet<UploadIntent> UploadIntents { get; }
@@ -0,0 +1,343 @@
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, int 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 de YouTube Music 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($"Mezcla: {seed.Name}", MixTitleLength),
$"Canciones de {seed.Name} en Musify y en YouTube Music.",
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);
}
var today = DateOnly.FromDateTime(DateTime.UtcNow);
for (var position = 0; position < drafts.Count; position++)
{
var draft = drafts[position];
var mix = new Mix
{
UserId = userId,
Title = draft.Title,
Subtitle = draft.Subtitle,
GeneratedOn = today,
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,27 @@
using Musify.Domain.Entities;
namespace Musify.Application.Mixes.Responses
{
public record MixApplicationResponse(
Guid Id,
string Title,
string? Subtitle,
DateOnly GeneratedOn,
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,
mix.GeneratedOn,
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,
int 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);
}
}
+42
View File
@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Musify.Domain.Abstractions;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
[Table("Mixes")]
public class Mix : IAuditable
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required long UserId { get; set; }
[Required]
[MaxLength(100)]
public required string Title { get; set; }
[MaxLength(256)]
public string? Subtitle { get; set; }
[Required]
public required DateOnly GeneratedOn { get; set; }
[Required]
public int Position { get; set; } = 0;
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Required]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
[ForeignKey(nameof(UserId))]
public User User { get; set; }
public ICollection<MixItem> Items { get; set; } = new List<MixItem>();
}
}
+50
View File
@@ -0,0 +1,50 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Musify.Domain.ValueObjects;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
[Table("MixItems")]
public class MixItem
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required Guid MixId { get; set; }
[Required]
public int Position { get; set; } = 0;
[Required]
public required MixItemSource Source { get; set; }
public Guid? TrackId { get; set; }
[MaxLength(64)]
public string? VideoId { get; set; }
[MaxLength(300)]
public string? Title { get; set; }
[MaxLength(300)]
public string? Artist { get; set; }
[MaxLength(512)]
public string? ThumbnailUrl { get; set; }
[Required]
public int DurationSeconds { get; set; } = 0;
[Required]
public bool IsExplicit { get; set; } = false;
[ForeignKey(nameof(MixId))]
public Mix Mix { get; set; }
[ForeignKey(nameof(TrackId))]
public Track? Track { get; set; }
}
}
@@ -0,0 +1,8 @@
namespace Musify.Domain.ValueObjects
{
public enum MixItemSource
{
Musify = 0,
YouTube = 1
}
}
@@ -0,0 +1,37 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Mixes;
namespace Musify.Infrastructure.Jobs
{
public class DailyMixGenerationJob(
IDatabase database,
IMediator mediator,
ILogger<DailyMixGenerationJob> logger)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
var userIds = await database.ListeningHistories
.AsNoTracking()
.Select(history => history.UserId)
.Distinct()
.ToListAsync(cancellationToken);
logger.LogInformation("Starting daily mix generation for {Count} users", userIds.Count);
foreach (var userId in userIds)
{
var result = await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken);
if (result.IsError)
{
logger.LogWarning("Failed to generate mixes for user {UserId}: {Error}",
userId, result.FirstError.Description);
}
}
logger.LogInformation("Finished daily mix generation");
}
}
}
@@ -6,6 +6,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AWSSDK.S3" /> <PackageReference Include="AWSSDK.S3" />
<PackageReference Include="Hangfire.NetCore" />
<PackageReference Include="Hangfire.PostgreSql" />
<PackageReference Include="MassTransit" /> <PackageReference Include="MassTransit" />
<PackageReference Include="MassTransit.EntityFrameworkCore" /> <PackageReference Include="MassTransit.EntityFrameworkCore" />
<PackageReference Include="MassTransit.RabbitMQ" /> <PackageReference Include="MassTransit.RabbitMQ" />
@@ -137,6 +137,25 @@ namespace Musify.Infrastructure.Persistence
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
.IsUnique(); .IsUnique();
modelBuilder.Entity<Mix>()
.HasIndex(mix => new { mix.UserId, mix.Position });
modelBuilder.Entity<MixItem>()
.HasOne(item => item.Mix)
.WithMany(mix => mix.Items)
.HasForeignKey(item => item.MixId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>()
.HasOne(item => item.Track)
.WithMany()
.HasForeignKey(item => item.TrackId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>()
.HasIndex(item => new { item.MixId, item.Position });
var playListProcessing = modelBuilder.Entity<PlayListProcessingState>(); var playListProcessing = modelBuilder.Entity<PlayListProcessingState>();
playListProcessing.HasKey(state => state.CorrelationId); playListProcessing.HasKey(state => state.CorrelationId);
playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
@@ -167,6 +186,10 @@ namespace Musify.Infrastructure.Persistence
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>(); public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
public DbSet<Mix> Mixes => Set<Mix>();
public DbSet<MixItem> MixItems => Set<MixItem>();
public DbSet<Upload> Uploads => Set<Upload>(); public DbSet<Upload> Uploads => Set<Upload>();
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>(); public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddMixes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Mixes",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false),
Title = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Subtitle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
GeneratedOn = table.Column<DateOnly>(type: "date", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Mixes", x => x.Id);
table.ForeignKey(
name: "FK_Mixes_User_UserId",
column: x => x.UserId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MixItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MixId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Source = table.Column<int>(type: "integer", nullable: false),
TrackId = table.Column<Guid>(type: "uuid", nullable: true),
VideoId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
Artist = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
ThumbnailUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
DurationSeconds = table.Column<int>(type: "integer", nullable: false),
IsExplicit = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MixItems", x => x.Id);
table.ForeignKey(
name: "FK_MixItems_Mixes_MixId",
column: x => x.MixId,
principalTable: "Mixes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MixItems_Tracks_TrackId",
column: x => x.TrackId,
principalTable: "Tracks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Mixes_UserId_Position",
table: "Mixes",
columns: new[] { "UserId", "Position" });
migrationBuilder.CreateIndex(
name: "IX_MixItems_MixId_Position",
table: "MixItems",
columns: new[] { "MixId", "Position" });
migrationBuilder.CreateIndex(
name: "IX_MixItems_TrackId",
table: "MixItems",
column: "TrackId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MixItems");
migrationBuilder.DropTable(
name: "Mixes");
}
}
}
@@ -316,6 +316,92 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("ListeningHistory"); b.ToTable("ListeningHistory");
}); });
modelBuilder.Entity("Musify.Domain.Entities.Mix", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("GeneratedOn")
.HasColumnType("date");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<string>("Subtitle")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId", "Position");
b.ToTable("Mixes");
});
modelBuilder.Entity("Musify.Domain.Entities.MixItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Artist")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<int>("DurationSeconds")
.HasColumnType("integer");
b.Property<bool>("IsExplicit")
.HasColumnType("boolean");
b.Property<Guid>("MixId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid?>("TrackId")
.HasColumnType("uuid");
b.Property<string>("VideoId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("TrackId");
b.HasIndex("MixId", "Position");
b.ToTable("MixItems");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -773,6 +859,35 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("Musify.Domain.Entities.Mix", b =>
{
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.MixItem", b =>
{
b.HasOne("Musify.Domain.Entities.Mix", "Mix")
.WithMany("Items")
.HasForeignKey("MixId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany()
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Mix");
b.Navigation("Track");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{ {
b.HasOne("Musify.Domain.Entities.User", "User") b.HasOne("Musify.Domain.Entities.User", "User")
@@ -1025,6 +1140,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("TrackArtists"); b.Navigation("TrackArtists");
}); });
modelBuilder.Entity("Musify.Domain.Entities.Mix", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{ {
b.Navigation("PlayListTracks"); b.Navigation("PlayListTracks");
@@ -1,4 +1,6 @@
using Amazon.S3; using Amazon.S3;
using Hangfire;
using Hangfire.PostgreSql;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -60,6 +62,21 @@ namespace Musify.Infrastructure.Services
return serviceDescriptors; return serviceDescriptors;
} }
public static IServiceCollection AddDailyMixGenerationJob(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors.AddHangfire((serviceProvider, hangfireConfiguration) =>
{
var databaseConfiguration = serviceProvider.GetRequiredService<DatabaseConfiguration>();
hangfireConfiguration.UsePostgreSqlStorage(options =>
options.UseNpgsqlConnection(databaseConfiguration.ConnectionString));
});
serviceDescriptors.AddHangfireServer();
serviceDescriptors.AddScoped<DailyMixGenerationJob>();
return serviceDescriptors;
}
public static IServiceCollection AddStreamTicketService(this IServiceCollection serviceDescriptors, IConfiguration configuration) public static IServiceCollection AddStreamTicketService(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{ {
serviceDescriptors serviceDescriptors
+6
View File
@@ -26,6 +26,12 @@
"ApplicationStorage": { "ApplicationStorage": {
"Bucket": "" "Bucket": ""
}, },
"Mix": {
"ArtistMixCount": 3,
"ItemsPerMix": 12,
"SeedArtistCount": 3,
"YouTubeResultsPerSeed": 12
},
"PlayList": { "PlayList": {
"Routes": { "Routes": {
"ParentFolders": "PlayLists", "ParentFolders": "PlayLists",
+32 -1
View File
@@ -1,10 +1,26 @@
using Hangfire;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Services;
using Musify.Infrastructure.Jobs;
using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.MassTransit;
using Musify.Infrastructure.Persistence; using Musify.Infrastructure.Persistence;
using Musify.Infrastructure.Services; using Musify.Infrastructure.Services;
var builder = Host.CreateApplicationBuilder(); 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 configuration = builder.Configuration;
var services = builder.Services; var services = builder.Services;
@@ -12,6 +28,7 @@ var services = builder.Services;
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName); services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName); services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName); services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName);
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
services.AddStorageService(configuration); services.AddStorageService(configuration);
services.AddAudioTranscoder(configuration); services.AddAudioTranscoder(configuration);
services.AddYouTubeDownloader(configuration); services.AddYouTubeDownloader(configuration);
@@ -20,7 +37,21 @@ services.AddPictureService(configuration);
services.AddDatabase(configuration); services.AddDatabase(configuration);
services.AddMassTransitConsumers(configuration); services.AddMassTransitConsumers(configuration);
services.AddUploadIntentJobs(configuration); services.AddUploadIntentJobs(configuration);
services.AddMediator();
services.AddScoped<YouTubeTrackProvisioner>();
services.AddYouTubeMusicService(configuration);
services.AddDailyMixGenerationJob(configuration);
var host = builder.Build(); var host = builder.Build();
host.Run(); 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();
+122
View File
@@ -126,6 +126,40 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/mixes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get The Server-Generated Mixes Of The Current User. */
get: operations["GetMixes"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mixes/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get A Mix With All Of Its Songs. */
get: operations["GetMixById"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/playlists": { "/playlists": {
parameters: { parameters: {
query?: never; query?: never;
@@ -585,6 +619,31 @@ export interface components {
[key: string]: string[]; [key: string]: string[];
}; };
}; };
MixApplicationResponse: {
/** Format: uuid */
id: string;
title: string;
subtitle: null | string;
/** Format: date */
generatedOn: string;
/** Format: int32 */
itemCount: number | string;
items: components["schemas"]["MixItemApplicationResponse"][];
};
MixItemApplicationResponse: {
source: components["schemas"]["MixItemSource"];
/** Format: uuid */
trackId: null | string;
videoId: null | string;
title: string;
artist: null | string;
thumbnailUrl: null | string;
/** Format: int32 */
durationSeconds: number | string;
isExplicit: boolean;
};
/** @enum {unknown} */
MixItemSource: "Musify" | "YouTube";
PaginatedResponseOfAlbumApplicationResponse: { PaginatedResponseOfAlbumApplicationResponse: {
items: components["schemas"]["AlbumApplicationResponse"][]; items: components["schemas"]["AlbumApplicationResponse"][];
/** Format: int32 */ /** Format: int32 */
@@ -1184,6 +1243,69 @@ export interface operations {
}; };
}; };
}; };
GetMixes: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MixApplicationResponse"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetMixById: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MixApplicationResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetPlayLists: { GetPlayLists: {
parameters: { parameters: {
query?: { query?: {
+9
View File
@@ -1,3 +1,12 @@
export function shuffle<T>(items: T[]): T[] {
const copy = [...items];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
export function appendUnique<T>( export function appendUnique<T>(
current: T[], current: T[],
incoming: T[], incoming: T[],
@@ -0,0 +1,39 @@
<script lang="ts">
import Cover from './Cover.svelte';
import { gradientForHue, hueFor } from '$lib/theme/color';
import { mixItemKey, type MixItem } from '$lib/mixes';
interface Props {
items: MixItem[];
hue: number;
class?: string;
}
let { items, hue, class: klass = '' }: Props = $props();
const tiles = $derived(items.slice(0, 4));
</script>
{#if tiles.length >= 4}
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
{#each tiles as item (mixItemKey(item))}
<Cover
trackId={item.trackId ?? item.videoId ?? ''}
src={item.thumbnailUrl}
hue={hueFor(mixItemKey(item))}
size="small"
class="h-full w-full"
/>
{/each}
</div>
{:else if tiles.length > 0}
<Cover
trackId={tiles[0].trackId ?? tiles[0].videoId ?? ''}
src={tiles[0].thumbnailUrl}
hue={hueFor(mixItemKey(tiles[0]))}
size="medium"
class={klass}
/>
{:else}
<div class={klass} style="background:{gradientForHue(hue)}"></div>
{/if}
@@ -0,0 +1,31 @@
<script lang="ts">
import MixArt from './MixArt.svelte';
import { hueFor } from '$lib/theme/color';
import type { Mix } from '$lib/mixes';
interface Props {
mix: Mix;
index?: number;
}
let { mix, index = 0 }: Props = $props();
const count = $derived(Number(mix.itemCount));
</script>
<a
href="/mixes/{mix.id}"
class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms"
>
<MixArt
items={mix.items}
hue={hueFor(mix.id)}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
/>
<div class="mt-2.5 truncate text-fg">{mix.title}</div>
<div class="mt-0.5 truncate text-sm text-fg-3">
{count}
{count === 1 ? 'canción' : 'canciones'} · Musify y YouTube Music
</div>
</a>
+1
View File
@@ -9,3 +9,4 @@ export const LIBRARY_PICKER_PAGE_SIZE = 50;
export const EXPLORE_ALBUMS_PAGE_SIZE = 18; export const EXPLORE_ALBUMS_PAGE_SIZE = 18;
export const HOME_LATEST_PAGE_SIZE = 12; export const HOME_LATEST_PAGE_SIZE = 12;
export const YOUTUBE_FILLER_LIMIT = 27; export const YOUTUBE_FILLER_LIMIT = 27;
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
+37
View File
@@ -0,0 +1,37 @@
import type { components } from '$lib/api/schema';
import type { TrackTarget } from '$lib/tracks';
export type Mix = components['schemas']['MixApplicationResponse'];
export type MixItem = components['schemas']['MixItemApplicationResponse'];
export function mixItemKey(item: MixItem): string {
return item.source === 'YouTube' ? `yt:${item.videoId}` : `mf:${item.trackId}`;
}
export function mixItemTarget(item: MixItem): TrackTarget {
if (item.source === 'YouTube') {
return {
kind: 'youtube',
song: {
videoId: item.videoId ?? '',
title: item.title,
artist: item.artist ?? '',
album: '',
durationSeconds: Number(item.durationSeconds),
thumbnailUrl: item.thumbnailUrl ?? '',
isExplicit: item.isExplicit
}
};
}
return {
kind: 'local',
track: {
id: item.trackId ?? '',
title: item.title,
artist: item.artist,
source: 'Local',
isExplicit: item.isExplicit
}
};
}
+16 -8
View File
@@ -28,23 +28,31 @@ function pickRandomFillerQuery(): string {
return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)]; return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)];
} }
export function shuffle<T>(items: T[]): T[] { function hash(text: string): number {
const copy = [...items]; let h = 0;
for (let i = copy.length - 1; i > 0; i--) { for (let i = 0; i < text.length; i++) {
const j = Math.floor(Math.random() * (i + 1)); h = (h * 31 + text.charCodeAt(i)) | 0;
[copy[i], copy[j]] = [copy[j], copy[i]];
} }
return copy; return Math.abs(h);
}
function pickFillerQueryForUser(userId: string, date: Date): string {
const dayKey = date.toISOString().slice(0, 10);
const index = hash(`${userId}:${dayKey}`) % YOUTUBE_FILLER_QUERIES.length;
return YOUTUBE_FILLER_QUERIES[index];
} }
export async function fetchYoutubeFiller( export async function fetchYoutubeFiller(
api: ReturnType<typeof createApiClient>, api: ReturnType<typeof createApiClient>,
accessToken: string | null | undefined, accessToken: string | null | undefined,
limit: number, limit: number,
seed?: string seedQuery?: string,
userId?: string
): Promise<YoutubeFiller> { ): Promise<YoutubeFiller> {
if (!accessToken) return { query: '', items: [], continuationToken: '' }; if (!accessToken) return { query: '', items: [], continuationToken: '' };
const query = seed?.trim() || pickRandomFillerQuery(); const query =
seedQuery?.trim() ||
(userId ? pickFillerQueryForUser(userId, new Date()) : pickRandomFillerQuery());
const { data } = await api.GET('/youtube/search', { params: { query: { query } } }); const { data } = await api.GET('/youtube/search', { params: { query: { query } } });
return { return {
query, query,
@@ -0,0 +1,28 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import { PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [mixRes, playlistsRes] = await Promise.all([
api.GET('/mixes/{id}', { params: { path: { id: params.id } } }),
api.GET('/playlists/users/{userId}', {
params: {
path: { userId: user.sub },
query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE }
}
})
]);
const mix = unwrapOrError(mixRes, 'Mezcla no encontrada.', 404);
return { mix, playlists: playlistsRes.data?.items ?? [] };
};
export const actions: Actions = {
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction
};
@@ -0,0 +1,189 @@
<script lang="ts">
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Sparkles from '@lucide/svelte/icons/sparkles';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import Shuffle from '@lucide/svelte/icons/shuffle';
import { player } from '$lib/player/player.svelte';
import { hueFor } from '$lib/theme/color';
import { fmtTime } from '$lib/format';
import { shuffle } from '$lib/collections';
import Page from '$lib/components/ui/Page.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import MixArt from '$lib/components/ui/MixArt.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import { mixItemKey, mixItemTarget } from '$lib/mixes';
import {
isTargetCurrent,
queueItemForTarget,
targetArtist,
targetCoverSrc,
targetId,
targetTitle
} from '$lib/tracks';
let { data, form } = $props();
const mix = $derived(data.mix);
const items = $derived(mix.items);
const targets = $derived(items.map(mixItemTarget));
const queue = $derived(targets.map(queueItemForTarget));
const totalSeconds = $derived(
items.reduce((total, item) => total + Number(item.durationSeconds), 0)
);
const fromYouTube = $derived(items.filter((item) => item.source === 'YouTube').length);
const fromMusify = $derived(items.length - fromYouTube);
const isCurrentQueue = $derived(targets.some(isTargetCurrent));
let contextMenu = $state<ContextMenuState | null>(null);
function playFrom(index: number) {
player.playOrToggle(queue, index);
}
function playAll() {
if (queue.length === 0) return;
if (isCurrentQueue) player.toggle();
else player.playQueue(queue, 0);
}
function playShuffled() {
if (queue.length === 0) return;
player.playQueue(shuffle(queue), 0);
}
</script>
<svelte:head>
<title>{mix.title} · Musify</title>
<meta
name="description"
content="Mezcla {mix.title}, hecha para ti con Musify y YouTube Music."
/>
</svelte:head>
<Page>
<a
href="/"
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
>
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
<ArrowLeft class="h-4 w-4" />
</span>
Volver al inicio
</a>
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<MixArt
{items}
hue={hueFor(mix.id)}
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
/>
<div class="min-w-0 flex-1">
<p class="inline-flex items-center gap-1.5 text-sm tracking-[0.14em] text-fg-3 uppercase">
<Sparkles class="h-3.5 w-3.5 text-accent" />
Hecha para ti
</p>
<h1
class="mt-1.5 text-3xl font-bold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
>
{mix.title}
</h1>
{#if mix.subtitle}
<p class="mt-3 text-base text-fg-2">{mix.subtitle}</p>
{/if}
<p class="mt-1 text-sm text-muted">
{items.length}
{items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
{#if fromMusify > 0}
<Badge>{fromMusify} de Musify</Badge>
{/if}
{#if fromYouTube > 0}
<Badge tone="accent">{fromYouTube} de YouTube Music</Badge>
{/if}
</div>
</div>
</div>
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
<Button variant="secondary" size="sm" onclick={playShuffled} disabled={queue.length === 0}>
<Shuffle class="h-4 w-4" />
Aleatorio
</Button>
</div>
{#if form?.message}
<Alert tone="danger" class="mt-4">{form.message}</Alert>
{/if}
<TrackTable columns="32px 1fr 64px" columnsMobile="28px 1fr 52px" class="mt-6 sm:mt-8">
{#snippet headers()}
<span class="text-center">#</span>
<span>Título</span>
<span class="hidden text-center sm:block">Duración</span>
{/snippet}
{#each items as item, i (mixItemKey(item))}
<TrackRow oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
<TrackIndexCell
index={i}
active={isTargetCurrent(targets[i])}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
<TrackTitleCell
trackId={targetId(targets[i])}
title={targetTitle(targets[i])}
artist={targetArtist(targets[i])}
coverSrc={targetCoverSrc(targets[i])}
hue={hueFor(mixItemKey(item))}
explicit={item.isExplicit}
onClick={() => playFrom(i)}
>
{#snippet badge()}
{#if item.source === 'YouTube'}
<Badge tone="accent" class="hidden sm:inline">YouTube</Badge>
{/if}
{/snippet}
</TrackTitleCell>
<TrackMeta>{fmtTime(Number(item.durationSeconds))}</TrackMeta>
</TrackRow>
{/each}
</TrackTable>
</Page>
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
playlists={data.playlists}
onClose={() => (contextMenu = null)}
onAddToQueue={() => {
const menu = contextMenu;
if (menu) {
const index = targets.findIndex((target) => targetId(target) === targetId(menu));
if (index >= 0) player.addToQueue(queue[index]);
}
contextMenu = null;
}}
/>
{/if}
@@ -8,7 +8,8 @@ import {
unwrapOrFail unwrapOrFail
} from '$lib/server/api'; } from '$lib/server/api';
import { uploadPresignedImage } from '$lib/server/upload'; import { uploadPresignedImage } from '$lib/server/upload';
import { fetchYoutubeFiller, shuffle } from '$lib/server/youtube'; import { fetchYoutubeFiller } from '$lib/server/youtube';
import { shuffle } from '$lib/collections';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions'; import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import type { YouTubeSong } from '$lib/types'; import type { YouTubeSong } from '$lib/types';
+13 -3
View File
@@ -1,6 +1,7 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, playlistCoverTrackIds, requireUser } from '$lib/server/api'; import { createApiClient, playlistCoverTrackIds, requireUser } from '$lib/server/api';
import { fetchYoutubeFiller, shuffle } from '$lib/server/youtube'; import { fetchYoutubeFiller } from '$lib/server/youtube';
import { shuffle } from '$lib/collections';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions'; import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import { HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config'; import { HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
import { pickGreeting } from '$lib/server/greeting'; import { pickGreeting } from '$lib/server/greeting';
@@ -15,13 +16,15 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const playlistsPromise = api.GET('/playlists/users/{userId}', { const playlistsPromise = api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 12 } } params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 12 } }
}); });
const mixesPromise = api.GET('/mixes');
const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', { const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
params: { path: { id: user.sub } } params: { path: { id: user.sub } }
}); });
const [latest, playlists, recentlyPlayed] = await Promise.all([ const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([
latestPromise, latestPromise,
playlistsPromise, playlistsPromise,
mixesPromise,
recentlyPlayedPromise recentlyPlayedPromise
]); ]);
const playlistItems = playlists.data?.items ?? []; const playlistItems = playlists.data?.items ?? [];
@@ -34,8 +37,15 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
greeting: pickGreeting(new Date().getHours()), greeting: pickGreeting(new Date().getHours()),
latest: shuffle((latest.data?.items ?? []).map((track) => ({ kind: 'local' as const, track }))), latest: shuffle((latest.data?.items ?? []).map((track) => ({ kind: 'local' as const, track }))),
latestHasNext: Boolean(latest.data?.hasNextPage), latestHasNext: Boolean(latest.data?.hasNextPage),
youtubeFiller: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT), youtubeFiller: fetchYoutubeFiller(
api,
locals.accessToken,
YOUTUBE_FILLER_LIMIT,
undefined,
user.sub
),
playlists: playlistItems, playlists: playlistItems,
mixes: mixes.data ?? [],
trackIds, trackIds,
recentlyPlayed: recentlyPlayed.data ?? [] recentlyPlayed: recentlyPlayed.data ?? []
}; };
+13
View File
@@ -15,6 +15,7 @@
import { hueFor } from '$lib/theme/color'; import { hueFor } from '$lib/theme/color';
import Cover from '$lib/components/ui/Cover.svelte'; import Cover from '$lib/components/ui/Cover.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte'; import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import MixCard from '$lib/components/ui/MixCard.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte'; import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte'; import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
@@ -106,6 +107,7 @@
} }
} }
const playlists = $derived(data.playlists); const playlists = $derived(data.playlists);
const mixes = $derived(data.mixes);
const trackIds = $derived(data.trackIds); const trackIds = $derived(data.trackIds);
function playLatest(index: number) { function playLatest(index: number) {
@@ -224,6 +226,17 @@
</section> </section>
{/if} {/if}
{#if mixes.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6">
<SectionHeading title="Hechas para ti" />
<MediaGrid>
{#each mixes as mix, i (mix.id)}
<MixCard {mix} index={i} />
{/each}
</MediaGrid>
</section>
{/if}
{#if playlists.length > 0} {#if playlists.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6"> <section class="page-x pt-5 pb-3 sm:pt-6">
<SectionHeading title="Mis listas" /> <SectionHeading title="Mis listas" />