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