feat: recommend server-generated mixes from Musify and YouTube Music

Mixes are their own concept, not playlists: they live in their own
Mixes/MixItems tables, cannot be edited and never show up among the
user's playlists. Each item is either a Musify track or a YouTube Music
reference, so YouTube songs stream directly instead of being downloaded.

A daily Hangfire job seeds the mixes from the listening history: one
discovery mix, one daily mix and one per top artist.

The per-user YouTube filler query was already in the working tree and
rides along here because it shares files with this change.
This commit is contained in:
2026-07-21 18:13:07 +02:00
parent 637e1ae583
commit 496a5f4a0b
36 changed files with 2667 additions and 13 deletions
@@ -137,6 +137,25 @@ namespace Musify.Infrastructure.Persistence
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
.IsUnique();
modelBuilder.Entity<Mix>()
.HasIndex(mix => new { mix.UserId, mix.Position });
modelBuilder.Entity<MixItem>()
.HasOne(item => item.Mix)
.WithMany(mix => mix.Items)
.HasForeignKey(item => item.MixId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>()
.HasOne(item => item.Track)
.WithMany()
.HasForeignKey(item => item.TrackId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>()
.HasIndex(item => new { item.MixId, item.Position });
var playListProcessing = modelBuilder.Entity<PlayListProcessingState>();
playListProcessing.HasKey(state => state.CorrelationId);
playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
@@ -167,6 +186,10 @@ namespace Musify.Infrastructure.Persistence
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
public DbSet<Mix> Mixes => Set<Mix>();
public DbSet<MixItem> MixItems => Set<MixItem>();
public DbSet<Upload> Uploads => Set<Upload>();
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddMixes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Mixes",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false),
Title = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Subtitle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
GeneratedOn = table.Column<DateOnly>(type: "date", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Mixes", x => x.Id);
table.ForeignKey(
name: "FK_Mixes_User_UserId",
column: x => x.UserId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MixItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MixId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Source = table.Column<int>(type: "integer", nullable: false),
TrackId = table.Column<Guid>(type: "uuid", nullable: true),
VideoId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
Artist = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
ThumbnailUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
DurationSeconds = table.Column<int>(type: "integer", nullable: false),
IsExplicit = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MixItems", x => x.Id);
table.ForeignKey(
name: "FK_MixItems_Mixes_MixId",
column: x => x.MixId,
principalTable: "Mixes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MixItems_Tracks_TrackId",
column: x => x.TrackId,
principalTable: "Tracks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Mixes_UserId_Position",
table: "Mixes",
columns: new[] { "UserId", "Position" });
migrationBuilder.CreateIndex(
name: "IX_MixItems_MixId_Position",
table: "MixItems",
columns: new[] { "MixId", "Position" });
migrationBuilder.CreateIndex(
name: "IX_MixItems_TrackId",
table: "MixItems",
column: "TrackId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MixItems");
migrationBuilder.DropTable(
name: "Mixes");
}
}
}
@@ -316,6 +316,92 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("ListeningHistory");
});
modelBuilder.Entity("Musify.Domain.Entities.Mix", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("GeneratedOn")
.HasColumnType("date");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<string>("Subtitle")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId", "Position");
b.ToTable("Mixes");
});
modelBuilder.Entity("Musify.Domain.Entities.MixItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Artist")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<int>("DurationSeconds")
.HasColumnType("integer");
b.Property<bool>("IsExplicit")
.HasColumnType("boolean");
b.Property<Guid>("MixId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid?>("TrackId")
.HasColumnType("uuid");
b.Property<string>("VideoId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("TrackId");
b.HasIndex("MixId", "Position");
b.ToTable("MixItems");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Property<Guid>("Id")
@@ -773,6 +859,35 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.Mix", b =>
{
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.MixItem", b =>
{
b.HasOne("Musify.Domain.Entities.Mix", "Mix")
.WithMany("Items")
.HasForeignKey("MixId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany()
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Mix");
b.Navigation("Track");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.HasOne("Musify.Domain.Entities.User", "User")
@@ -1025,6 +1140,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("TrackArtists");
});
modelBuilder.Entity("Musify.Domain.Entities.Mix", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Navigation("PlayListTracks");