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:
@@ -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';
|
||||
|
||||
|
||||
@@ -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 ?? []
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user