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
+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,