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
+42
View File
@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Musify.Domain.Abstractions;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
[Table("Mixes")]
public class Mix : IAuditable
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required long UserId { get; set; }
[Required]
[MaxLength(100)]
public required string Title { get; set; }
[MaxLength(256)]
public string? Subtitle { get; set; }
[Required]
public required DateOnly GeneratedOn { get; set; }
[Required]
public int Position { get; set; } = 0;
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Required]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
[ForeignKey(nameof(UserId))]
public User User { get; set; }
public ICollection<MixItem> Items { get; set; } = new List<MixItem>();
}
}
+50
View File
@@ -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; }
}
}