feat: recommend server-generated mixes from Musify and YouTube Music
Mixes are their own concept, not playlists: they live in their own Mixes/MixItems tables, cannot be edited and never show up among the user's playlists. Each item is either a Musify track or a YouTube Music reference, so YouTube songs stream directly instead of being downloaded. A daily Hangfire job seeds the mixes from the listening history: one discovery mix, one daily mix and one per top artist. The per-user YouTube filler query was already in the working tree and rides along here because it shares files with this change.
This commit is contained in:
@@ -9,6 +9,8 @@
|
||||
<PackageVersion Include="DispatchR.Mediator" Version="2.2.0" />
|
||||
<PackageVersion Include="ErrorOr" Version="2.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.EntityFrameworkCore" Version="8.5.9" />
|
||||
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.5.9" />
|
||||
|
||||
@@ -57,6 +57,12 @@
|
||||
"ThumbnailSize": 240,
|
||||
"ArtworkSize": 544
|
||||
},
|
||||
"Mix": {
|
||||
"ArtistMixCount": 3,
|
||||
"ItemsPerMix": 12,
|
||||
"SeedArtistCount": 3,
|
||||
"YouTubeResultsPerSeed": 12
|
||||
},
|
||||
"PlayList": {
|
||||
"Routes": {
|
||||
"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
|
||||
.MapAlbumEndpoints()
|
||||
.MapMixEndpoints()
|
||||
.MapPlayListEndpoints()
|
||||
.MapTrackEndpoints()
|
||||
.MapUserEndpoints()
|
||||
|
||||
@@ -18,6 +18,7 @@ public static class ApplicationDependencyInjection
|
||||
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
|
||||
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
|
||||
services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName);
|
||||
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
|
||||
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName);
|
||||
services.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
|
||||
|
||||
|
||||
@@ -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<Mix> Mixes { get; }
|
||||
|
||||
DbSet<MixItem> MixItems { get; }
|
||||
|
||||
DbSet<Upload> Uploads { 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>
|
||||
<PackageReference Include="AWSSDK.S3" />
|
||||
<PackageReference Include="Hangfire.NetCore" />
|
||||
<PackageReference Include="Hangfire.PostgreSql" />
|
||||
<PackageReference Include="MassTransit" />
|
||||
<PackageReference Include="MassTransit.EntityFrameworkCore" />
|
||||
<PackageReference Include="MassTransit.RabbitMQ" />
|
||||
|
||||
@@ -137,6 +137,25 @@ namespace Musify.Infrastructure.Persistence
|
||||
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
|
||||
.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>();
|
||||
playListProcessing.HasKey(state => state.CorrelationId);
|
||||
playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
|
||||
@@ -167,6 +186,10 @@ namespace Musify.Infrastructure.Persistence
|
||||
|
||||
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<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");
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -773,6 +859,35 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Musify.Domain.Entities.User", "User")
|
||||
@@ -1025,6 +1140,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("TrackArtists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.Mix", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
|
||||
{
|
||||
b.Navigation("PlayListTracks");
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Amazon.S3;
|
||||
using Hangfire;
|
||||
using Hangfire.PostgreSql;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -60,6 +62,21 @@ namespace Musify.Infrastructure.Services
|
||||
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)
|
||||
{
|
||||
serviceDescriptors
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
"ApplicationStorage": {
|
||||
"Bucket": ""
|
||||
},
|
||||
"Mix": {
|
||||
"ArtistMixCount": 3,
|
||||
"ItemsPerMix": 12,
|
||||
"SeedArtistCount": 3,
|
||||
"YouTubeResultsPerSeed": 12
|
||||
},
|
||||
"PlayList": {
|
||||
"Routes": {
|
||||
"ParentFolders": "PlayLists",
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
using Hangfire;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Infrastructure.Jobs;
|
||||
using Musify.Infrastructure.MassTransit;
|
||||
using Musify.Infrastructure.Persistence;
|
||||
using Musify.Infrastructure.Services;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
|
||||
// Registering Mediator pulls in every handler in Musify.Application, including ones
|
||||
// (e.g. CreatePlayList, CreateTrack) that depend on IEventBus, which the Worker never
|
||||
// registers because it only consumes events, it never publishes them. Only the handlers
|
||||
// this process actually dispatches (GenerateMixesForUserCommand) are ever constructed,
|
||||
// so the default eager validate-on-build check is too strict for this host.
|
||||
builder.ConfigureContainer(new DefaultServiceProviderFactory(new ServiceProviderOptions
|
||||
{
|
||||
ValidateOnBuild = false,
|
||||
ValidateScopes = true
|
||||
}));
|
||||
|
||||
var configuration = builder.Configuration;
|
||||
|
||||
var services = builder.Services;
|
||||
@@ -12,6 +28,7 @@ var services = builder.Services;
|
||||
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
|
||||
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
|
||||
services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName);
|
||||
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
|
||||
services.AddStorageService(configuration);
|
||||
services.AddAudioTranscoder(configuration);
|
||||
services.AddYouTubeDownloader(configuration);
|
||||
@@ -20,7 +37,21 @@ services.AddPictureService(configuration);
|
||||
services.AddDatabase(configuration);
|
||||
services.AddMassTransitConsumers(configuration);
|
||||
services.AddUploadIntentJobs(configuration);
|
||||
services.AddMediator();
|
||||
services.AddScoped<YouTubeTrackProvisioner>();
|
||||
services.AddYouTubeMusicService(configuration);
|
||||
services.AddDailyMixGenerationJob(configuration);
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
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;
|
||||
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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -585,6 +619,31 @@ export interface components {
|
||||
[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: {
|
||||
items: components["schemas"]["AlbumApplicationResponse"][];
|
||||
/** 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: {
|
||||
parameters: {
|
||||
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>(
|
||||
current: 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 HOME_LATEST_PAGE_SIZE = 12;
|
||||
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)];
|
||||
}
|
||||
|
||||
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]];
|
||||
function hash(text: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h = (h * 31 + text.charCodeAt(i)) | 0;
|
||||
}
|
||||
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(
|
||||
api: ReturnType<typeof createApiClient>,
|
||||
accessToken: string | null | undefined,
|
||||
limit: number,
|
||||
seed?: string
|
||||
seedQuery?: string,
|
||||
userId?: string
|
||||
): Promise<YoutubeFiller> {
|
||||
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 } } });
|
||||
return {
|
||||
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
|
||||
} from '$lib/server/api';
|
||||
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 type { YouTubeSong } from '$lib/types';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
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 { HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
|
||||
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}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 12 } }
|
||||
});
|
||||
const mixesPromise = api.GET('/mixes');
|
||||
const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
|
||||
params: { path: { id: user.sub } }
|
||||
});
|
||||
|
||||
const [latest, playlists, recentlyPlayed] = await Promise.all([
|
||||
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([
|
||||
latestPromise,
|
||||
playlistsPromise,
|
||||
mixesPromise,
|
||||
recentlyPlayedPromise
|
||||
]);
|
||||
const playlistItems = playlists.data?.items ?? [];
|
||||
@@ -34,8 +37,15 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
greeting: pickGreeting(new Date().getHours()),
|
||||
latest: shuffle((latest.data?.items ?? []).map((track) => ({ kind: 'local' as const, track }))),
|
||||
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,
|
||||
mixes: mixes.data ?? [],
|
||||
trackIds,
|
||||
recentlyPlayed: recentlyPlayed.data ?? []
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import Cover from '$lib/components/ui/Cover.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 EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||
@@ -106,6 +107,7 @@
|
||||
}
|
||||
}
|
||||
const playlists = $derived(data.playlists);
|
||||
const mixes = $derived(data.mixes);
|
||||
const trackIds = $derived(data.trackIds);
|
||||
|
||||
function playLatest(index: number) {
|
||||
@@ -224,6 +226,17 @@
|
||||
</section>
|
||||
{/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}
|
||||
<section class="page-x pt-5 pb-3 sm:pt-6">
|
||||
<SectionHeading title="Mis listas" />
|
||||
|
||||
Reference in New Issue
Block a user