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
+122
View File
@@ -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?: {