From 90e842d08416313d744a7e1ae080ad0f49a41fdf Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 21 Jul 2026 18:13:07 +0200 Subject: [PATCH] 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. --- backend/Directory.Packages.props | 2 + backend/Musify.Api/AppSettings.json | 6 + backend/Musify.Api/Endpoints/MixEndpoints.cs | 51 + backend/Musify.Api/Program.cs | 1 + .../ApplicationDependencyInjection.cs | 1 + .../Configuration/MixConfiguration.cs | 21 + .../Musify.Application/Contracts/IDatabase.cs | 4 + .../Mixes/GenerateMixesForUser.cs | 343 +++++ .../Musify.Application/Mixes/GetMixById.cs | 30 + .../Mixes/GetMixesByUserId.cs | 37 + .../Mixes/Responses/MixApplicationResponse.cs | 27 + .../Responses/MixItemApplicationResponse.cs | 27 + backend/Musify.Domain/Entities/Mix.cs | 42 + backend/Musify.Domain/Entities/MixItem.cs | 50 + .../ValueObjects/MixItemSource.cs | 8 + .../Jobs/DailyMixGenerationJob.cs | 37 + .../Musify.Infrastructure.csproj | 2 + .../Persistence/Database.cs | 23 + .../20260721155102_AddMixes.Designer.cs | 1183 +++++++++++++++++ .../Migrations/20260721155102_AddMixes.cs | 97 ++ .../Migrations/DatabaseModelSnapshot.cs | 120 ++ .../Services/ServicesDependencyInjection.cs | 17 + backend/Musify.Worker/AppSettings.json | 6 + backend/Musify.Worker/Program.cs | 33 +- web-player/src/lib/api/schema.d.ts | 122 ++ web-player/src/lib/collections.ts | 9 + .../src/lib/components/ui/MixArt.svelte | 39 + .../src/lib/components/ui/MixCard.svelte | 31 + web-player/src/lib/config.ts | 1 + web-player/src/lib/mixes.ts | 37 + web-player/src/lib/server/youtube.ts | 24 +- .../routes/(app)/mixes/[id]/+page.server.ts | 28 + .../src/routes/(app)/mixes/[id]/+page.svelte | 189 +++ .../(app)/playlists/[id]/+page.server.ts | 3 +- web-player/src/routes/+page.server.ts | 16 +- web-player/src/routes/+page.svelte | 13 + 36 files changed, 2667 insertions(+), 13 deletions(-) create mode 100644 backend/Musify.Api/Endpoints/MixEndpoints.cs create mode 100644 backend/Musify.Application/Configuration/MixConfiguration.cs create mode 100644 backend/Musify.Application/Mixes/GenerateMixesForUser.cs create mode 100644 backend/Musify.Application/Mixes/GetMixById.cs create mode 100644 backend/Musify.Application/Mixes/GetMixesByUserId.cs create mode 100644 backend/Musify.Application/Mixes/Responses/MixApplicationResponse.cs create mode 100644 backend/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs create mode 100644 backend/Musify.Domain/Entities/Mix.cs create mode 100644 backend/Musify.Domain/Entities/MixItem.cs create mode 100644 backend/Musify.Domain/ValueObjects/MixItemSource.cs create mode 100644 backend/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs create mode 100644 backend/Musify.Infrastructure/Persistence/Migrations/20260721155102_AddMixes.Designer.cs create mode 100644 backend/Musify.Infrastructure/Persistence/Migrations/20260721155102_AddMixes.cs create mode 100644 web-player/src/lib/components/ui/MixArt.svelte create mode 100644 web-player/src/lib/components/ui/MixCard.svelte create mode 100644 web-player/src/lib/mixes.ts create mode 100644 web-player/src/routes/(app)/mixes/[id]/+page.server.ts create mode 100644 web-player/src/routes/(app)/mixes/[id]/+page.svelte diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 5f73e88..7af8198 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -9,6 +9,8 @@ + + diff --git a/backend/Musify.Api/AppSettings.json b/backend/Musify.Api/AppSettings.json index 623f06f..ea63454 100644 --- a/backend/Musify.Api/AppSettings.json +++ b/backend/Musify.Api/AppSettings.json @@ -57,6 +57,12 @@ "ThumbnailSize": 240, "ArtworkSize": 544 }, + "Mix": { + "ArtistMixCount": 3, + "ItemsPerMix": 12, + "SeedArtistCount": 3, + "YouTubeResultsPerSeed": 12 + }, "PlayList": { "Routes": { "UploadsFolder": "uploads", diff --git a/backend/Musify.Api/Endpoints/MixEndpoints.cs b/backend/Musify.Api/Endpoints/MixEndpoints.cs new file mode 100644 index 0000000..41a79cd --- /dev/null +++ b/backend/Musify.Api/Endpoints/MixEndpoints.cs @@ -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>() + .Produces(StatusCodes.Status401Unauthorized); + + group.MapGet("/{id}", GetMixById) + .WithName("GetMixById") + .WithSummary("Get A Mix With All Of Its Songs.") + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + return app; + } + + private static async Task GetMixes( + IMediator mediator, + CurrentUser currentUser, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetMixById( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMixByIdQuery(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } +} diff --git a/backend/Musify.Api/Program.cs b/backend/Musify.Api/Program.cs index bb161d2..514a28d 100644 --- a/backend/Musify.Api/Program.cs +++ b/backend/Musify.Api/Program.cs @@ -39,6 +39,7 @@ app.UseAuthorization(); app .MapAlbumEndpoints() + .MapMixEndpoints() .MapPlayListEndpoints() .MapTrackEndpoints() .MapUserEndpoints() diff --git a/backend/Musify.Application/ApplicationDependencyInjection.cs b/backend/Musify.Application/ApplicationDependencyInjection.cs index a214939..fd8c5d5 100644 --- a/backend/Musify.Application/ApplicationDependencyInjection.cs +++ b/backend/Musify.Application/ApplicationDependencyInjection.cs @@ -18,6 +18,7 @@ public static class ApplicationDependencyInjection services.AddValidatedOptions(configuration, ApplicationStorageConfiguration.SectionName); services.AddValidatedOptions(configuration, PlayListConfiguration.SectionName); services.AddValidatedOptions(configuration, TrackConfiguration.SectionName); + services.AddValidatedOptions(configuration, MixConfiguration.SectionName); services.AddValidatedOptions(configuration, StreamGatewayConfiguration.SectionName); services.AddValidatedOptions(configuration, UploadIntentConfiguration.SectionName); diff --git a/backend/Musify.Application/Configuration/MixConfiguration.cs b/backend/Musify.Application/Configuration/MixConfiguration.cs new file mode 100644 index 0000000..64d1dff --- /dev/null +++ b/backend/Musify.Application/Configuration/MixConfiguration.cs @@ -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; + } +} diff --git a/backend/Musify.Application/Contracts/IDatabase.cs b/backend/Musify.Application/Contracts/IDatabase.cs index 40c2f8f..49006fa 100644 --- a/backend/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Musify.Application/Contracts/IDatabase.cs @@ -29,6 +29,10 @@ namespace Musify.Application.Contracts DbSet PlayListHasTracks { get; } + DbSet Mixes { get; } + + DbSet MixItems { get; } + DbSet Uploads { get; } DbSet UploadIntents { get; } diff --git a/backend/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Musify.Application/Mixes/GenerateMixesForUser.cs new file mode 100644 index 0000000..6dd853c --- /dev/null +++ b/backend/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -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>; + + public class GenerateMixesForUserCommandHandler( + IDatabase database, + IYouTubeMusicService youTubeMusicService, + MixConfiguration mixConfiguration, + ILogger logger) + : ICommandHandler> + { + 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 Items); + + private const int TitleLength = 300; + private const int ArtistLength = 300; + private const int ThumbnailUrlLength = 512; + private const int MixTitleLength = 100; + + public async ValueTask> 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(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> GetSeedArtistsAsync( + IReadOnlyList 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>> SearchYouTubeAsync( + IReadOnlyList seedArtists, + HashSet excludedVideoIds, + CancellationToken cancellationToken) + { + var byArtist = new Dictionary>(); + + 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(); + 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>> GetLocalCandidatesAsync( + IReadOnlyList seedArtists, + IReadOnlyList 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>(); + 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> LoadCandidatesAsync( + IReadOnlyList 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 BuildDrafts( + IReadOnlyList seedArtists, + IReadOnlyDictionary> localByArtist, + IReadOnlyDictionary> youTubeByArtist) + { + var drafts = new List(); + + 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 Take(IEnumerable items) => + [.. items.Take(mixConfiguration.ItemsPerMix)]; + + private static string Truncate(string value, int maxLength) => + value.Length <= maxLength ? value : value[..maxLength]; + + private static List Shuffle(IEnumerable items) => + [.. items.OrderBy(_ => Guid.NewGuid())]; + + private static List Interleave(IReadOnlyList first, IReadOnlyList second) + { + var merged = new List(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 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); + } + } + } + } +} diff --git a/backend/Musify.Application/Mixes/GetMixById.cs b/backend/Musify.Application/Mixes/GetMixById.cs new file mode 100644 index 0000000..a841fd5 --- /dev/null +++ b/backend/Musify.Application/Mixes/GetMixById.cs @@ -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>; + + public class GetMixByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> 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); + } + } +} diff --git a/backend/Musify.Application/Mixes/GetMixesByUserId.cs b/backend/Musify.Application/Mixes/GetMixesByUserId.cs new file mode 100644 index 0000000..a2d1526 --- /dev/null +++ b/backend/Musify.Application/Mixes/GetMixesByUserId.cs @@ -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>>; + + public class GetMixesByUserIdQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> 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(); + } + } +} diff --git a/backend/Musify.Application/Mixes/Responses/MixApplicationResponse.cs b/backend/Musify.Application/Mixes/Responses/MixApplicationResponse.cs new file mode 100644 index 0000000..68e5e03 --- /dev/null +++ b/backend/Musify.Application/Mixes/Responses/MixApplicationResponse.cs @@ -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 Items) + { + public const int CoverItemCount = 4; + + public static MixApplicationResponse FromEntity( + Mix mix, + int itemCount, + IReadOnlyList items) => + new( + mix.Id, + mix.Title, + mix.Subtitle, + mix.GeneratedOn, + itemCount, + [.. items.Select(MixItemApplicationResponse.FromEntity)]); + } +} diff --git a/backend/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs b/backend/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs new file mode 100644 index 0000000..1a31a6c --- /dev/null +++ b/backend/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs @@ -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); + } +} diff --git a/backend/Musify.Domain/Entities/Mix.cs b/backend/Musify.Domain/Entities/Mix.cs new file mode 100644 index 0000000..1907fca --- /dev/null +++ b/backend/Musify.Domain/Entities/Mix.cs @@ -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 Items { get; set; } = new List(); + } +} diff --git a/backend/Musify.Domain/Entities/MixItem.cs b/backend/Musify.Domain/Entities/MixItem.cs new file mode 100644 index 0000000..20b3005 --- /dev/null +++ b/backend/Musify.Domain/Entities/MixItem.cs @@ -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; } + } +} diff --git a/backend/Musify.Domain/ValueObjects/MixItemSource.cs b/backend/Musify.Domain/ValueObjects/MixItemSource.cs new file mode 100644 index 0000000..b143c8d --- /dev/null +++ b/backend/Musify.Domain/ValueObjects/MixItemSource.cs @@ -0,0 +1,8 @@ +namespace Musify.Domain.ValueObjects +{ + public enum MixItemSource + { + Musify = 0, + YouTube = 1 + } +} diff --git a/backend/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs b/backend/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs new file mode 100644 index 0000000..16a701c --- /dev/null +++ b/backend/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs @@ -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 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"); + } + } +} diff --git a/backend/Musify.Infrastructure/Musify.Infrastructure.csproj b/backend/Musify.Infrastructure/Musify.Infrastructure.csproj index fc7bbca..48ef7ef 100644 --- a/backend/Musify.Infrastructure/Musify.Infrastructure.csproj +++ b/backend/Musify.Infrastructure/Musify.Infrastructure.csproj @@ -6,6 +6,8 @@ + + diff --git a/backend/Musify.Infrastructure/Persistence/Database.cs b/backend/Musify.Infrastructure/Persistence/Database.cs index 5707040..ea0a6be 100644 --- a/backend/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Musify.Infrastructure/Persistence/Database.cs @@ -137,6 +137,25 @@ namespace Musify.Infrastructure.Persistence .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) .IsUnique(); + modelBuilder.Entity() + .HasIndex(mix => new { mix.UserId, mix.Position }); + + modelBuilder.Entity() + .HasOne(item => item.Mix) + .WithMany(mix => mix.Items) + .HasForeignKey(item => item.MixId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(item => item.Track) + .WithMany() + .HasForeignKey(item => item.TrackId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(item => new { item.MixId, item.Position }); + var playListProcessing = modelBuilder.Entity(); playListProcessing.HasKey(state => state.CorrelationId); playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); @@ -167,6 +186,10 @@ namespace Musify.Infrastructure.Persistence public DbSet PlayListHasTracks => Set(); + public DbSet Mixes => Set(); + + public DbSet MixItems => Set(); + public DbSet Uploads => Set(); public DbSet UploadIntents => Set(); diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260721155102_AddMixes.Designer.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260721155102_AddMixes.Designer.cs new file mode 100644 index 0000000..d1d84af --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260721155102_AddMixes.Designer.cs @@ -0,0 +1,1183 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260721155102_AddMixes")] + partial class AddMixes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Artist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.ToTable("Artists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GeneratedOn") + .HasColumnType("date"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Artist") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + + b.UseTptMappingStrategy(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => + { + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("ArtistId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("TrackId", "ArtistId"); + + b.HasIndex("ArtistId"); + + b.ToTable("TrackArtist"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("ExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId", "ExternalId") + .IsUnique(); + + b.ToTable("UserAlbums", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalTracks", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("LocalTracks", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + 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") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("DownloadRequested") + .HasColumnType("boolean") + .HasColumnName("DownloadRequested"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("LastRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastRetryAt"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("RetryCount"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => + { + b.HasOne("Musify.Domain.Entities.Artist", "Artist") + .WithMany("TrackArtists") + .HasForeignKey("ArtistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.ExternalTrack", "Track") + .WithMany("TrackArtists") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => + { + b.HasOne("Musify.Domain.Entities.Album", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.UserAlbum", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.LocalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260721155102_AddMixes.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260721155102_AddMixes.cs new file mode 100644 index 0000000..f04305d --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260721155102_AddMixes.cs @@ -0,0 +1,97 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddMixes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Mixes", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false), + Title = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Subtitle = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + GeneratedOn = table.Column(type: "date", nullable: false), + Position = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + MixId = table.Column(type: "uuid", nullable: false), + Position = table.Column(type: "integer", nullable: false), + Source = table.Column(type: "integer", nullable: false), + TrackId = table.Column(type: "uuid", nullable: true), + VideoId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + Title = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + Artist = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + ThumbnailUrl = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + DurationSeconds = table.Column(type: "integer", nullable: false), + IsExplicit = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MixItems"); + + migrationBuilder.DropTable( + name: "Mixes"); + } + } +} diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 3f5bdc4..3c9d5d9 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -316,6 +316,92 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("ListeningHistory"); }); + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GeneratedOn") + .HasColumnType("date"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Artist") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("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("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"); diff --git a/backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index e38e9b6..83744c4 100644 --- a/backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -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(); + hangfireConfiguration.UsePostgreSqlStorage(options => + options.UseNpgsqlConnection(databaseConfiguration.ConnectionString)); + }); + + serviceDescriptors.AddHangfireServer(); + serviceDescriptors.AddScoped(); + + return serviceDescriptors; + } + public static IServiceCollection AddStreamTicketService(this IServiceCollection serviceDescriptors, IConfiguration configuration) { serviceDescriptors diff --git a/backend/Musify.Worker/AppSettings.json b/backend/Musify.Worker/AppSettings.json index 1ab96fb..c3a6934 100644 --- a/backend/Musify.Worker/AppSettings.json +++ b/backend/Musify.Worker/AppSettings.json @@ -26,6 +26,12 @@ "ApplicationStorage": { "Bucket": "" }, + "Mix": { + "ArtistMixCount": 3, + "ItemsPerMix": 12, + "SeedArtistCount": 3, + "YouTubeResultsPerSeed": 12 + }, "PlayList": { "Routes": { "ParentFolders": "PlayLists", diff --git a/backend/Musify.Worker/Program.cs b/backend/Musify.Worker/Program.cs index 5178d26..15fe304 100644 --- a/backend/Musify.Worker/Program.cs +++ b/backend/Musify.Worker/Program.cs @@ -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(configuration, ApplicationStorageConfiguration.SectionName); services.AddValidatedOptions(configuration, PlayListConfiguration.SectionName); services.AddValidatedOptions(configuration, TrackConfiguration.SectionName); +services.AddValidatedOptions(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(); +services.AddYouTubeMusicService(configuration); +services.AddDailyMixGenerationJob(configuration); var host = builder.Build(); -host.Run(); \ No newline at end of file +using (var scope = host.Services.CreateScope()) +{ + var recurringJobManager = scope.ServiceProvider.GetRequiredService(); + recurringJobManager.AddOrUpdate( + "daily-mix-generation", + job => job.RunAsync(CancellationToken.None), + Cron.Daily(), + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); +} + +host.Run(); diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 4190136..a30993b 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -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?: { diff --git a/web-player/src/lib/collections.ts b/web-player/src/lib/collections.ts index 8fd37f3..4a6fd65 100644 --- a/web-player/src/lib/collections.ts +++ b/web-player/src/lib/collections.ts @@ -1,3 +1,12 @@ +export function shuffle(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( current: T[], incoming: T[], diff --git a/web-player/src/lib/components/ui/MixArt.svelte b/web-player/src/lib/components/ui/MixArt.svelte new file mode 100644 index 0000000..5cae09d --- /dev/null +++ b/web-player/src/lib/components/ui/MixArt.svelte @@ -0,0 +1,39 @@ + + +{#if tiles.length >= 4} +
+ {#each tiles as item (mixItemKey(item))} + + {/each} +
+{:else if tiles.length > 0} + +{:else} +
+{/if} diff --git a/web-player/src/lib/components/ui/MixCard.svelte b/web-player/src/lib/components/ui/MixCard.svelte new file mode 100644 index 0000000..23864dc --- /dev/null +++ b/web-player/src/lib/components/ui/MixCard.svelte @@ -0,0 +1,31 @@ + + + + +
{mix.title}
+
+ {count} + {count === 1 ? 'canción' : 'canciones'} · Musify y YouTube Music +
+
diff --git a/web-player/src/lib/config.ts b/web-player/src/lib/config.ts index dfc0b7b..c8880c9 100644 --- a/web-player/src/lib/config.ts +++ b/web-player/src/lib/config.ts @@ -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; diff --git a/web-player/src/lib/mixes.ts b/web-player/src/lib/mixes.ts new file mode 100644 index 0000000..3ad5f97 --- /dev/null +++ b/web-player/src/lib/mixes.ts @@ -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 + } + }; +} diff --git a/web-player/src/lib/server/youtube.ts b/web-player/src/lib/server/youtube.ts index a1b1d44..528d316 100644 --- a/web-player/src/lib/server/youtube.ts +++ b/web-player/src/lib/server/youtube.ts @@ -28,23 +28,31 @@ function pickRandomFillerQuery(): string { return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)]; } -export function shuffle(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, accessToken: string | null | undefined, limit: number, - seed?: string + seedQuery?: string, + userId?: string ): Promise { 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, diff --git a/web-player/src/routes/(app)/mixes/[id]/+page.server.ts b/web-player/src/routes/(app)/mixes/[id]/+page.server.ts new file mode 100644 index 0000000..2243490 --- /dev/null +++ b/web-player/src/routes/(app)/mixes/[id]/+page.server.ts @@ -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 +}; diff --git a/web-player/src/routes/(app)/mixes/[id]/+page.svelte b/web-player/src/routes/(app)/mixes/[id]/+page.svelte new file mode 100644 index 0000000..7684ecf --- /dev/null +++ b/web-player/src/routes/(app)/mixes/[id]/+page.svelte @@ -0,0 +1,189 @@ + + + + {mix.title} · Musify + + + + + + + + + Volver al inicio + + +
+ +
+

+ + Hecha para ti +

+

+ {mix.title} +

+ {#if mix.subtitle} +

{mix.subtitle}

+ {/if} +

+ {items.length} + {items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)} +

+
+ {#if fromMusify > 0} + {fromMusify} de Musify + {/if} + {#if fromYouTube > 0} + {fromYouTube} de YouTube Music + {/if} +
+
+
+ +
+ + +
+ + {#if form?.message} + {form.message} + {/if} + + + {#snippet headers()} + # + Título + + {/snippet} + {#each items as item, i (mixItemKey(item))} + (contextMenu = contextMenuStateFor(e, targets[i]))}> + playFrom(i)} + /> + playFrom(i)} + > + {#snippet badge()} + {#if item.source === 'YouTube'} + + {/if} + {/snippet} + + {fmtTime(Number(item.durationSeconds))} + + {/each} + +
+ +{#if contextMenu} + (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} diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts index f70d351..2b5ef71 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts @@ -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'; diff --git a/web-player/src/routes/+page.server.ts b/web-player/src/routes/+page.server.ts index 0fc9ba5..db57720 100644 --- a/web-player/src/routes/+page.server.ts +++ b/web-player/src/routes/+page.server.ts @@ -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 ?? [] }; diff --git a/web-player/src/routes/+page.svelte b/web-player/src/routes/+page.svelte index ddb95b2..7f74147 100644 --- a/web-player/src/routes/+page.svelte +++ b/web-player/src/routes/+page.svelte @@ -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 @@ {/if} +{#if mixes.length > 0} +
+ + + {#each mixes as mix, i (mix.id)} + + {/each} + +
+{/if} + {#if playlists.length > 0}