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?: {
+9
View File
@@ -1,3 +1,12 @@
export function shuffle<T>(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<T>(
current: T[],
incoming: T[],
@@ -0,0 +1,39 @@
<script lang="ts">
import Cover from './Cover.svelte';
import { gradientForHue, hueFor } from '$lib/theme/color';
import { mixItemKey, type MixItem } from '$lib/mixes';
interface Props {
items: MixItem[];
hue: number;
class?: string;
}
let { items, hue, class: klass = '' }: Props = $props();
const tiles = $derived(items.slice(0, 4));
</script>
{#if tiles.length >= 4}
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
{#each tiles as item (mixItemKey(item))}
<Cover
trackId={item.trackId ?? item.videoId ?? ''}
src={item.thumbnailUrl}
hue={hueFor(mixItemKey(item))}
size="small"
class="h-full w-full"
/>
{/each}
</div>
{:else if tiles.length > 0}
<Cover
trackId={tiles[0].trackId ?? tiles[0].videoId ?? ''}
src={tiles[0].thumbnailUrl}
hue={hueFor(mixItemKey(tiles[0]))}
size="medium"
class={klass}
/>
{:else}
<div class={klass} style="background:{gradientForHue(hue)}"></div>
{/if}
@@ -0,0 +1,31 @@
<script lang="ts">
import MixArt from './MixArt.svelte';
import { hueFor } from '$lib/theme/color';
import type { Mix } from '$lib/mixes';
interface Props {
mix: Mix;
index?: number;
}
let { mix, index = 0 }: Props = $props();
const count = $derived(Number(mix.itemCount));
</script>
<a
href="/mixes/{mix.id}"
class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms"
>
<MixArt
items={mix.items}
hue={hueFor(mix.id)}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
/>
<div class="mt-2.5 truncate text-fg">{mix.title}</div>
<div class="mt-0.5 truncate text-sm text-fg-3">
{count}
{count === 1 ? 'canción' : 'canciones'} · Musify y YouTube Music
</div>
</a>
+1
View File
@@ -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;
+37
View File
@@ -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
}
};
}
+16 -8
View File
@@ -28,23 +28,31 @@ function pickRandomFillerQuery(): string {
return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)];
}
export function shuffle<T>(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<typeof createApiClient>,
accessToken: string | null | undefined,
limit: number,
seed?: string
seedQuery?: string,
userId?: string
): Promise<YoutubeFiller> {
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,