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
@@ -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" />