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 2cb51f540e
commit 90e842d084
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,
@@ -0,0 +1,28 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import { PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [mixRes, playlistsRes] = await Promise.all([
api.GET('/mixes/{id}', { params: { path: { id: params.id } } }),
api.GET('/playlists/users/{userId}', {
params: {
path: { userId: user.sub },
query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE }
}
})
]);
const mix = unwrapOrError(mixRes, 'Mezcla no encontrada.', 404);
return { mix, playlists: playlistsRes.data?.items ?? [] };
};
export const actions: Actions = {
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction
};
@@ -0,0 +1,189 @@
<script lang="ts">
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Sparkles from '@lucide/svelte/icons/sparkles';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import Shuffle from '@lucide/svelte/icons/shuffle';
import { player } from '$lib/player/player.svelte';
import { hueFor } from '$lib/theme/color';
import { fmtTime } from '$lib/format';
import { shuffle } from '$lib/collections';
import Page from '$lib/components/ui/Page.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import MixArt from '$lib/components/ui/MixArt.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import { mixItemKey, mixItemTarget } from '$lib/mixes';
import {
isTargetCurrent,
queueItemForTarget,
targetArtist,
targetCoverSrc,
targetId,
targetTitle
} from '$lib/tracks';
let { data, form } = $props();
const mix = $derived(data.mix);
const items = $derived(mix.items);
const targets = $derived(items.map(mixItemTarget));
const queue = $derived(targets.map(queueItemForTarget));
const totalSeconds = $derived(
items.reduce((total, item) => total + Number(item.durationSeconds), 0)
);
const fromYouTube = $derived(items.filter((item) => item.source === 'YouTube').length);
const fromMusify = $derived(items.length - fromYouTube);
const isCurrentQueue = $derived(targets.some(isTargetCurrent));
let contextMenu = $state<ContextMenuState | null>(null);
function playFrom(index: number) {
player.playOrToggle(queue, index);
}
function playAll() {
if (queue.length === 0) return;
if (isCurrentQueue) player.toggle();
else player.playQueue(queue, 0);
}
function playShuffled() {
if (queue.length === 0) return;
player.playQueue(shuffle(queue), 0);
}
</script>
<svelte:head>
<title>{mix.title} · Musify</title>
<meta
name="description"
content="Mezcla {mix.title}, hecha para ti con Musify y YouTube Music."
/>
</svelte:head>
<Page>
<a
href="/"
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
>
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
<ArrowLeft class="h-4 w-4" />
</span>
Volver al inicio
</a>
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<MixArt
{items}
hue={hueFor(mix.id)}
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
/>
<div class="min-w-0 flex-1">
<p class="inline-flex items-center gap-1.5 text-sm tracking-[0.14em] text-fg-3 uppercase">
<Sparkles class="h-3.5 w-3.5 text-accent" />
Hecha para ti
</p>
<h1
class="mt-1.5 text-3xl font-bold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
>
{mix.title}
</h1>
{#if mix.subtitle}
<p class="mt-3 text-base text-fg-2">{mix.subtitle}</p>
{/if}
<p class="mt-1 text-sm text-muted">
{items.length}
{items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
{#if fromMusify > 0}
<Badge>{fromMusify} de Musify</Badge>
{/if}
{#if fromYouTube > 0}
<Badge tone="accent">{fromYouTube} de YouTube Music</Badge>
{/if}
</div>
</div>
</div>
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
<Button variant="secondary" size="sm" onclick={playShuffled} disabled={queue.length === 0}>
<Shuffle class="h-4 w-4" />
Aleatorio
</Button>
</div>
{#if form?.message}
<Alert tone="danger" class="mt-4">{form.message}</Alert>
{/if}
<TrackTable columns="32px 1fr 64px" columnsMobile="28px 1fr 52px" class="mt-6 sm:mt-8">
{#snippet headers()}
<span class="text-center">#</span>
<span>Título</span>
<span class="hidden text-center sm:block">Duración</span>
{/snippet}
{#each items as item, i (mixItemKey(item))}
<TrackRow oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
<TrackIndexCell
index={i}
active={isTargetCurrent(targets[i])}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
<TrackTitleCell
trackId={targetId(targets[i])}
title={targetTitle(targets[i])}
artist={targetArtist(targets[i])}
coverSrc={targetCoverSrc(targets[i])}
hue={hueFor(mixItemKey(item))}
explicit={item.isExplicit}
onClick={() => playFrom(i)}
>
{#snippet badge()}
{#if item.source === 'YouTube'}
<Badge tone="accent" class="hidden sm:inline">YouTube</Badge>
{/if}
{/snippet}
</TrackTitleCell>
<TrackMeta>{fmtTime(Number(item.durationSeconds))}</TrackMeta>
</TrackRow>
{/each}
</TrackTable>
</Page>
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
playlists={data.playlists}
onClose={() => (contextMenu = null)}
onAddToQueue={() => {
const menu = contextMenu;
if (menu) {
const index = targets.findIndex((target) => targetId(target) === targetId(menu));
if (index >= 0) player.addToQueue(queue[index]);
}
contextMenu = null;
}}
/>
{/if}
@@ -8,7 +8,8 @@ import {
unwrapOrFail
} from '$lib/server/api';
import { uploadPresignedImage } from '$lib/server/upload';
import { fetchYoutubeFiller, shuffle } from '$lib/server/youtube';
import { fetchYoutubeFiller } from '$lib/server/youtube';
import { shuffle } from '$lib/collections';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import type { YouTubeSong } from '$lib/types';
+13 -3
View File
@@ -1,6 +1,7 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, playlistCoverTrackIds, requireUser } from '$lib/server/api';
import { fetchYoutubeFiller, shuffle } from '$lib/server/youtube';
import { fetchYoutubeFiller } from '$lib/server/youtube';
import { shuffle } from '$lib/collections';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import { HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
import { pickGreeting } from '$lib/server/greeting';
@@ -15,13 +16,15 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const playlistsPromise = api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 12 } }
});
const mixesPromise = api.GET('/mixes');
const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
params: { path: { id: user.sub } }
});
const [latest, playlists, recentlyPlayed] = await Promise.all([
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([
latestPromise,
playlistsPromise,
mixesPromise,
recentlyPlayedPromise
]);
const playlistItems = playlists.data?.items ?? [];
@@ -34,8 +37,15 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
greeting: pickGreeting(new Date().getHours()),
latest: shuffle((latest.data?.items ?? []).map((track) => ({ kind: 'local' as const, track }))),
latestHasNext: Boolean(latest.data?.hasNextPage),
youtubeFiller: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
youtubeFiller: fetchYoutubeFiller(
api,
locals.accessToken,
YOUTUBE_FILLER_LIMIT,
undefined,
user.sub
),
playlists: playlistItems,
mixes: mixes.data ?? [],
trackIds,
recentlyPlayed: recentlyPlayed.data ?? []
};
+13
View File
@@ -15,6 +15,7 @@
import { hueFor } from '$lib/theme/color';
import Cover from '$lib/components/ui/Cover.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import MixCard from '$lib/components/ui/MixCard.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
@@ -106,6 +107,7 @@
}
}
const playlists = $derived(data.playlists);
const mixes = $derived(data.mixes);
const trackIds = $derived(data.trackIds);
function playLatest(index: number) {
@@ -224,6 +226,17 @@
</section>
{/if}
{#if mixes.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6">
<SectionHeading title="Hechas para ti" />
<MediaGrid>
{#each mixes as mix, i (mix.id)}
<MixCard {mix} index={i} />
{/each}
</MediaGrid>
</section>
{/if}
{#if playlists.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6">
<SectionHeading title="Mis listas" />