Design Prot Merge #1
@@ -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" />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
|||||||
+1183
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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Vendored
+122
@@ -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?: {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?? []
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user