feat: bring over the albums, explore and player work from the worktree

Moves the remaining uncommitted work: the home gets quick mixes and a
"Tus álbumes" shelf, saving a YouTube album now goes through
addYoutubeAlbumToPlaylistAction instead of the SaveYouTubeAlbum command,
and UserAlbum drops its unused ExternalId.

The mixes section keeps its current shape (no refresh action, no badge,
hidden when empty) rather than the older version the worktree carried.
This commit is contained in:
2026-07-21 18:18:23 +02:00
parent b75c97a696
commit 2f98982d83
29 changed files with 1742 additions and 406 deletions
@@ -50,15 +50,6 @@ public static class AlbumEndpoints
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
group.MapPost("/youtube/{albumId}", SaveYouTubeAlbum)
.WithName("SaveYouTubeAlbum")
.WithSummary("Save A YouTube Music Album To Your Library With All Of Its Tracks.")
.RequireAuthorization()
.Produces<AlbumApplicationResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapPut("/{albumId}", UpdateAlbum) group.MapPut("/{albumId}", UpdateAlbum)
.WithName("UpdateAlbum") .WithName("UpdateAlbum")
.WithSummary("Update An Album.") .WithSummary("Update An Album.")
@@ -152,19 +143,6 @@ public static class AlbumEndpoints
return result.ToCreatedResult(album => $"/albums/{album.Id}"); return result.ToCreatedResult(album => $"/albums/{album.Id}");
} }
private static async Task<IResult> SaveYouTubeAlbum(
IMediator mediator,
CurrentUser currentUser,
string albumId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new SaveYouTubeAlbumCommand(currentUser.RequiredId, albumId),
cancellationToken);
return result.ToCreatedResult(album => $"/albums/{album.Id}");
}
private static async Task<IResult> UpdateAlbum( private static async Task<IResult> UpdateAlbum(
IMediator mediator, IMediator mediator,
CurrentUser currentUser, CurrentUser currentUser,
@@ -1,112 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Domain.Entities;
namespace Musify.Application.Albums
{
public record SaveYouTubeAlbumCommand(long UserId, string AlbumId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class SaveYouTubeAlbumCommandHandler(
IDatabase database,
IYouTubeMusicService youTubeMusicService,
YouTubeTrackProvisioner provisioner,
ILogger<SaveYouTubeAlbumCommandHandler> logger)
: ICommandHandler<SaveYouTubeAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(SaveYouTubeAlbumCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists)
return Error.NotFound(description: $"User {request.UserId} not found");
var alreadySaved = await database.UserAlbums
.AsNoTracking()
.AnyAsync(album => album.OwnerUserId == request.UserId && album.ExternalId == request.AlbumId, cancellationToken);
if (alreadySaved)
{
logger.LogInformation("Album {AlbumId} is already saved for user {UserId}", request.AlbumId, request.UserId);
return Error.Conflict(description: "The album is already in your library.");
}
var detailResult = await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken);
if (detailResult.IsError)
return detailResult.Errors;
var detail = detailResult.Value;
if (detail.Tracks.Count == 0)
return Error.NotFound(description: "The album has no playable tracks.");
var title = Truncate(detail.Album.Title, 200);
var album = new UserAlbum
{
OwnerUserId = request.UserId,
ExternalId = request.AlbumId,
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Description = Truncate(detail.Description, 256),
ReleaseYear = detail.Album.ReleaseYear
};
await database.UserAlbums.AddAsync(album, cancellationToken);
var linkedTrackIds = new HashSet<Guid>();
foreach (var track in detail.Tracks)
{
var provisionResult = await provisioner.GetOrCreateAsync(track.VideoId, cancellationToken);
if (provisionResult.IsError)
{
logger.LogWarning("Skipped track {VideoId} of album {AlbumId}: {Error}",
track.VideoId, request.AlbumId, provisionResult.FirstError.Description);
continue;
}
if (!linkedTrackIds.Add(provisionResult.Value.Track.Id))
continue;
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
{
AlbumId = album.Id,
TrackId = provisionResult.Value.Track.Id,
TrackNumber = track.TrackNumber
}, cancellationToken);
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save YouTube album {AlbumId} for user {UserId}", request.AlbumId, request.UserId);
return Error.Failure(description: $"Failed to save album {request.AlbumId}");
}
var albumTracks = database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == album.Id);
var trackCount = await albumTracks.CountAsync(cancellationToken);
var coverTrackIds = await albumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Saved YouTube album {AlbumId} as {AlbumGuid} with {TrackCount} tracks",
request.AlbumId, album.Id, trackCount);
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
}
private static string Truncate(string value, int maxLength) =>
value.Length <= maxLength ? value : value[..maxLength];
}
}
@@ -9,9 +9,6 @@ namespace Musify.Domain.Entities
[Required] [Required]
public required long OwnerUserId { get; set; } public required long OwnerUserId { get; set; }
[MaxLength(64)]
public string? ExternalId { get; set; }
[ForeignKey(nameof(OwnerUserId))] [ForeignKey(nameof(OwnerUserId))]
public User Owner { get; set; } public User Owner { get; set; }
} }
@@ -111,10 +111,6 @@ namespace Musify.Infrastructure.Persistence
modelBuilder.Entity<UserAlbum>() modelBuilder.Entity<UserAlbum>()
.ToTable("UserAlbums"); .ToTable("UserAlbums");
modelBuilder.Entity<UserAlbum>()
.HasIndex(album => new { album.OwnerUserId, album.ExternalId })
.IsUnique();
modelBuilder.Entity<UserAlbum>() modelBuilder.Entity<UserAlbum>()
.HasOne(album => album.Owner) .HasOne(album => album.Owner)
.WithMany() .WithMany()
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RemoveUserAlbumExternalId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_UserAlbums_OwnerUserId_ExternalId",
table: "UserAlbums");
migrationBuilder.DropColumn(
name: "ExternalId",
table: "UserAlbums");
migrationBuilder.CreateIndex(
name: "IX_UserAlbums_OwnerUserId",
table: "UserAlbums",
column: "OwnerUserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_UserAlbums_OwnerUserId",
table: "UserAlbums");
migrationBuilder.AddColumn<string>(
name: "ExternalId",
table: "UserAlbums",
type: "character varying(64)",
maxLength: 64,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_UserAlbums_OwnerUserId_ExternalId",
table: "UserAlbums",
columns: new[] { "OwnerUserId", "ExternalId" },
unique: true);
}
}
}
@@ -728,15 +728,10 @@ namespace Musify.Infrastructure.Persistence.Migrations
{ {
b.HasBaseType("Musify.Domain.Entities.Album"); b.HasBaseType("Musify.Domain.Entities.Album");
b.Property<string>("ExternalId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long>("OwnerUserId") b.Property<long>("OwnerUserId")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.HasIndex("OwnerUserId", "ExternalId") b.HasIndex("OwnerUserId");
.IsUnique();
b.ToTable("UserAlbums", (string)null); b.ToTable("UserAlbums", (string)null);
}); });
@@ -162,7 +162,7 @@ namespace Musify.Infrastructure.Services
return new YouTubeAlbumDetail( return new YouTubeAlbumDetail(
album, album,
info.Description ?? string.Empty, StripWikipediaAttribution(info.Description),
(int)info.Duration.TotalSeconds, (int)info.Duration.TotalSeconds,
tracks); tracks);
} }
@@ -173,6 +173,16 @@ namespace Musify.Infrastructure.Services
} }
} }
private static string StripWikipediaAttribution(string? description)
{
if (string.IsNullOrEmpty(description))
return string.Empty;
var markerIndex = description.IndexOf("From Wikipedia", StringComparison.OrdinalIgnoreCase);
var cleaned = markerIndex >= 0 ? description[..markerIndex] : description;
return cleaned.TrimEnd();
}
private YouTubeAlbumResult ToAlbumResult(AlbumSearchResult album) => private YouTubeAlbumResult ToAlbumResult(AlbumSearchResult album) =>
new( new(
album.Id, album.Id,
@@ -16,6 +16,9 @@
"OpenTelemetry": { "OpenTelemetry": {
"OtlpEndpoint": "http://localhost:4317" "OtlpEndpoint": "http://localhost:4317"
}, },
"YouTube": {
"GeographicalLocation": "ES"
},
"InfrastructureStorage": { "InfrastructureStorage": {
"Address": "http://localhost:8333", "Address": "http://localhost:8333",
"AccessKey": "admin_access_key", "AccessKey": "admin_access_key",
-60
View File
@@ -73,23 +73,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/albums/youtube/{albumId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Save A YouTube Music Album To Your Library With All Of Its Tracks. */
post: operations["SaveYouTubeAlbum"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/albums/{albumId}": { "/albums/{albumId}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1042,49 +1025,6 @@ export interface operations {
}; };
}; };
}; };
SaveYouTubeAlbum: {
parameters: {
query?: never;
header?: never;
path: {
albumId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AlbumApplicationResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
UpdateAlbum: { UpdateAlbum: {
parameters: { parameters: {
query?: never; query?: never;
@@ -0,0 +1,112 @@
<script lang="ts" module>
const MENU_WIDTH = 240;
const SUBMENU_WIDTH = 256;
export type AlbumMenuState = { x: number; y: number; openLeft: boolean } & (
{ kind: 'local'; albumId: string } | { kind: 'youtube'; albumId: string }
);
export function albumContextMenuStateFor(
event: MouseEvent,
kind: 'local' | 'youtube',
albumId: string
): AlbumMenuState {
event.preventDefault();
return {
kind,
albumId,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
menu: AlbumMenuState;
playlists: { id: string; name: string }[];
onClose: () => void;
}
let { menu, playlists, onClose }: Props = $props();
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div
class="fixed inset-0 z-30"
role="presentation"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
></div>
<div
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
style="left:{menu.x}px; top:{menu.y}px;"
>
{#if playlists.length > 0}
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm text-fg-2 transition group-hover/addmenu:bg-hover"
>
<span class="flex-1 cursor-default text-base">Añadir álbum a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
</div>
<div
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {menu.openLeft
? 'right-full pr-1.5'
: 'left-full pl-1.5'}"
>
<div
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 shadow-menu max-sm:w-full"
>
<div class="max-h-80 overflow-y-auto">
{#each playlists as playlist (playlist.id)}
<form
method="POST"
action={menu.kind === 'youtube'
? '?/addYoutubeAlbumToPlaylist'
: '?/addAlbumToPlaylist'}
use:enhance={() =>
({ update }) => {
onClose();
return update({ reset: false, invalidateAll: false });
}}
>
<input type="hidden" name="playlistId" value={playlist.id} />
<input type="hidden" name="albumId" value={menu.albumId} />
<button
type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg transition hover:bg-hover"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={[]}
hue={hueFor(playlist.id)}
size="small"
class="h-9 w-9 shrink-0 rounded-control"
/>
<span class="truncate text-fg-2">{playlist.name}</span>
</button>
</form>
{/each}
</div>
</div>
</div>
</div>
{:else}
<p class="px-2 py-2.5 text-sm text-fg-3">No tienes playlists todavía.</p>
{/if}
</div>
+2 -2
View File
@@ -81,7 +81,7 @@
class="sticky top-0 hidden h-screen shrink-0 flex-col gap-7 border-r border-line px-4.5 py-6.5 md:flex" class="sticky top-0 hidden h-screen shrink-0 flex-col gap-7 border-r border-line px-4.5 py-6.5 md:flex"
style="width:var(--mf-sidebar-w)" style="width:var(--mf-sidebar-w)"
> >
<a href="/" class="px-3 text-xl font-extrabold tracking-wide">Musify</a> <a href="/" class="px-3 text-xl font-medium tracking-wide">Musify</a>
<div class="relative flex flex-col gap-7" bind:this={navBox}> <div class="relative flex flex-col gap-7" bind:this={navBox}>
<div <div
@@ -132,7 +132,7 @@
{user.name.charAt(0)} {user.name.charAt(0)}
</span> </span>
{/if} {/if}
<span class="truncate leading-[1.35]">{user.name}</span> <span class="truncate leading-[1.35] text-fg-3">{user.name}</span>
</button> </button>
</div> </div>
</aside> </aside>
@@ -11,6 +11,7 @@
typeLabel?: string; typeLabel?: string;
coverSrc?: string | null; coverSrc?: string | null;
coverSize?: 'small' | 'medium' | 'large'; coverSize?: 'small' | 'medium' | 'large';
coverShape?: 'square' | 'round';
hue: number; hue: number;
explicit?: boolean; explicit?: boolean;
active: boolean; active: boolean;
@@ -28,6 +29,7 @@
typeLabel, typeLabel,
coverSrc, coverSrc,
coverSize = 'large', coverSize = 'large',
coverShape = 'square',
hue, hue,
explicit = false, explicit = false,
active, active,
@@ -58,7 +60,9 @@
{hue} {hue}
size={coverSize} size={coverSize}
alt={title} alt={title}
class="aspect-square w-full rounded-art shadow-art" class="aspect-square w-full shadow-art {coverShape === 'round'
? 'rounded-art-round'
: 'rounded-art'}"
> >
<PlayPauseOverlay {active} {playing} /> <PlayPauseOverlay {active} {playing} />
</Cover> </Cover>
@@ -3,8 +3,19 @@
import TransportControls from './TransportControls.svelte'; import TransportControls from './TransportControls.svelte';
import SeekBar from './SeekBar.svelte'; import SeekBar from './SeekBar.svelte';
import VolumeControl from './VolumeControl.svelte'; import VolumeControl from './VolumeControl.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import { player } from '$lib/player/player.svelte';
</script> </script>
{#if player.error}
<div
class="fixed inset-x-0 z-40 page-x py-2 md:left-[var(--mf-sidebar-w)] md:z-50"
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-player-h))"
>
<Alert tone="danger">{player.error}</Alert>
</div>
{/if}
<div <div
class="animate-enter fixed right-0 left-0 z-40 h-[var(--mf-player-h)] border-t border-line bg-bg md:left-[var(--mf-sidebar-w)] md:z-50" class="animate-enter fixed right-0 left-0 z-40 h-[var(--mf-player-h)] border-t border-line bg-bg md:left-[var(--mf-sidebar-w)] md:z-50"
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b))" style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b))"
@@ -14,6 +14,7 @@
trackIds?: (string | number)[]; trackIds?: (string | number)[];
coverSrc?: string | null; coverSrc?: string | null;
index?: number; index?: number;
onContextMenu?: (event: MouseEvent) => void;
} }
let { let {
@@ -26,7 +27,8 @@
trackCount, trackCount,
trackIds = [], trackIds = [],
coverSrc, coverSrc,
index = 0 index = 0,
onContextMenu
}: Props = $props(); }: Props = $props();
const meta = $derived( const meta = $derived(
@@ -47,6 +49,7 @@
href={href ?? `/albums/${id}`} href={href ?? `/albums/${id}`}
class="group animate-enter min-w-0 text-left" class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms" style="animation-delay:{index * 45}ms"
oncontextmenu={onContextMenu}
> >
{#if coverSrc} {#if coverSrc}
<Cover <Cover
@@ -11,6 +11,7 @@
alt?: string; alt?: string;
class?: string; class?: string;
children?: Snippet; children?: Snippet;
oncontextmenu?: (event: MouseEvent) => void;
} }
let { let {
@@ -20,7 +21,8 @@
src, src,
alt = '', alt = '',
class: klass = '', class: klass = '',
children children,
oncontextmenu
}: Props = $props(); }: Props = $props();
let failed = $state(false); let failed = $state(false);
@@ -40,6 +42,8 @@
<div <div
class="relative overflow-hidden {klass}" class="relative overflow-hidden {klass}"
style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`} style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`}
role="presentation"
{oncontextmenu}
> >
{#if !failed} {#if !failed}
<img <img
@@ -37,7 +37,7 @@
<div class="relative w-full"> <div class="relative w-full">
{#if Icon} {#if Icon}
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted"> <span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted">
<Icon class="h-4 w-4" /> <Icon class="h-5 w-5" />
</span> </span>
{/if} {/if}
<input <input
@@ -52,7 +52,7 @@
{autocomplete} {autocomplete}
{oninput} {oninput}
bind:value bind:value
class="w-full rounded-control border border-line bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {Icon class="w-full rounded-control bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {Icon
? 'pr-4 pl-11' ? 'pr-4 pl-11'
: 'px-4'} {klass}" : 'px-4'} {klass}"
/> />
@@ -1,10 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte';
interface Props { interface Props {
title: string; title: string;
class?: string; class?: string;
actions?: Snippet;
} }
let { title, class: klass = '' }: Props = $props(); let { title, class: klass = '', actions }: Props = $props();
</script> </script>
<h2 class="mb-4 text-xl font-bold text-fg sm:mb-6 sm:text-2xl {klass}">{title}</h2> {#if actions}
<div class="mb-4 flex items-center justify-between gap-4 sm:mb-6 {klass}">
<h2 class="text-xl font-bold text-fg sm:text-2xl">{title}</h2>
{@render actions()}
</div>
{:else}
<h2 class="mb-4 text-xl font-bold text-fg sm:mb-6 sm:text-2xl {klass}">{title}</h2>
{/if}
+3 -1
View File
@@ -6,7 +6,9 @@ export const ALBUMS_PAGE_SIZE = 50;
export const ALBUM_TRACKS_PAGE_SIZE = 200; export const ALBUM_TRACKS_PAGE_SIZE = 200;
export const ALBUM_EARLIEST_YEAR = 1877; export const ALBUM_EARLIEST_YEAR = 1877;
export const LIBRARY_PICKER_PAGE_SIZE = 50; export const LIBRARY_PICKER_PAGE_SIZE = 50;
export const EXPLORE_ALBUMS_PAGE_SIZE = 18; export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
export const EXPLORE_ALBUM_SLOTS = 10;
export const HOME_LATEST_PAGE_SIZE = 12; export const HOME_LATEST_PAGE_SIZE = 12;
export const HOME_ALBUMS_PAGE_SIZE = 12;
export const YOUTUBE_FILLER_LIMIT = 27; export const YOUTUBE_FILLER_LIMIT = 27;
export const PLAYLIST_PICKER_PAGE_SIZE = 50; export const PLAYLIST_PICKER_PAGE_SIZE = 50;
+15 -3
View File
@@ -75,6 +75,15 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
}); });
} }
async function readErrorMessage(res: Response): Promise<string> {
try {
const body = (await res.json()) as { message?: string };
return body?.message || `Error ${res.status}`;
} catch {
return `Error ${res.status}`;
}
}
const RECENT_LIMIT = 15; const RECENT_LIMIT = 15;
const EMPTY: PlayerTrack = { const EMPTY: PlayerTrack = {
@@ -286,13 +295,16 @@ class PlayerState {
if (token !== this.#loadToken) return; if (token !== this.#loadToken) return;
this.loading = false; this.loading = false;
this.playing = false; this.playing = false;
this.error = 'No se pudo reproducir la pista.'; this.error =
exception instanceof Error && exception.message
? exception.message
: 'No se pudo reproducir la pista.';
} }
} }
async #resolveLocalSrc(id: string): Promise<string> { async #resolveLocalSrc(id: string): Promise<string> {
const res = await fetch(`/api/tracks/${id}/stream`); const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(String(res.status)); if (!res.ok) throw new Error(await readErrorMessage(res));
const { manifestUrl, ticket } = (await res.json()) as { const { manifestUrl, ticket } = (await res.json()) as {
manifestUrl: string; manifestUrl: string;
ticket: string; ticket: string;
@@ -302,7 +314,7 @@ class PlayerState {
async #resolveYouTubeSrc(videoId: string): Promise<string> { async #resolveYouTubeSrc(videoId: string): Promise<string> {
const res = await fetch(`/api/youtube/tracks/${videoId}/stream`); const res = await fetch(`/api/youtube/tracks/${videoId}/stream`);
if (!res.ok) throw new Error(String(res.status)); if (!res.ok) throw new Error(await readErrorMessage(res));
const { mode, streamUrl, ticket } = (await res.json()) as { const { mode, streamUrl, ticket } = (await res.json()) as {
mode: string; mode: string;
streamUrl: string; streamUrl: string;
@@ -1,5 +1,6 @@
import { fail, type RequestEvent } from '@sveltejs/kit'; import { fail, type RequestEvent } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api'; import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) { export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) {
const accessToken = requireAccessTokenAction(locals); const accessToken = requireAccessTokenAction(locals);
@@ -59,3 +60,79 @@ export async function addYouTubeToPlaylistAction({ request, params, locals, fetc
return { added: true, videoId, playlistId }; return { added: true, videoId, playlistId };
} }
export async function addAlbumToPlaylistAction({ request, params, locals, fetch }: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const albumId = String(form.get('albumId') ?? '');
if (!playlistId || !albumId) {
return fail(400, { message: 'Faltan datos del álbum.' });
}
const api = createApiClient({ fetch, accessToken });
const { data: tracks, error: tracksErr } = await api.GET('/albums/{albumId}/tracks', {
params: { path: { albumId }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } }
});
if (tracksErr || !tracks) {
return fail(502, { message: 'No se pudo cargar el álbum.', albumId });
}
let added = 0;
for (const track of tracks.items) {
const { error: err } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId, trackId: String(track.id) } }
});
if (!err) added++;
}
return { addedAlbum: true, albumId, playlistId, added };
}
export async function addYoutubeAlbumToPlaylistAction({
request,
params,
locals,
fetch
}: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const albumId = String(form.get('albumId') ?? '');
if (!playlistId || !albumId) {
return fail(400, { message: 'Faltan datos del álbum.' });
}
const api = createApiClient({ fetch, accessToken });
const { data: detail, error: detailErr } = await api.GET('/youtube/albums/{albumId}', {
params: { path: { albumId } }
});
if (detailErr || !detail) {
return fail(502, { message: 'No se pudo cargar el álbum de YouTube.', albumId });
}
let added = 0;
for (const track of detail.tracks) {
const { error: err } = await api.POST('/playlists/{playlistId}/youtube-tracks', {
params: { path: { playlistId } },
body: {
videoId: track.videoId,
title: track.title,
artist: detail.album.artist,
durationSeconds: Number(track.durationSeconds),
thumbnailUrl: detail.album.thumbnailUrl
}
});
if (!err) added++;
}
return { addedAlbum: true, albumId, playlistId, added };
}
@@ -1,12 +1,9 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit'; import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
import { import {
createApiClient, addYouTubeToPlaylistAction,
requireAccessTokenAction, addYoutubeAlbumToPlaylistAction
requireUser, } from '$lib/server/playlistActions';
unwrapOrError
} from '$lib/server/api';
import { addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const user = requireUser(locals, url); const user = requireUser(locals, url);
@@ -26,27 +23,5 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
export const actions: Actions = { export const actions: Actions = {
addYouTubeToPlaylist: addYouTubeToPlaylistAction, addYouTubeToPlaylist: addYouTubeToPlaylistAction,
addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction
save: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
const {
data,
error: err,
response
} = await api.POST('/albums/youtube/{albumId}', {
params: { path: { albumId: params.albumId } }
});
if (response?.status === 409) {
return fail(409, { message: 'Ese álbum ya está en tu biblioteca.' });
}
if (err || !data) {
return fail(502, { message: 'No se pudo guardar el álbum.' });
}
redirect(303, `/albums/${data.id}`);
}
}; };
@@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Play from '@lucide/svelte/icons/play'; import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause'; import Pause from '@lucide/svelte/icons/pause';
import Plus from '@lucide/svelte/icons/plus';
import { player, type QueueItem } from '$lib/player/player.svelte'; import { player, type QueueItem } from '$lib/player/player.svelte';
import { hueFor } from '$lib/theme/color'; import { hueFor } from '$lib/theme/color';
import { fmtTime } from '$lib/format'; import { fmtTime } from '$lib/format';
@@ -20,6 +18,10 @@
contextMenuStateFor, contextMenuStateFor,
type ContextMenuState type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte'; } from '$lib/components/TrackContextMenu.svelte';
import AlbumContextMenu, {
albumContextMenuStateFor,
type AlbumMenuState
} from '$lib/components/AlbumContextMenu.svelte';
import type { TrackTarget } from '$lib/tracks'; import type { TrackTarget } from '$lib/tracks';
let { data, form } = $props(); let { data, form } = $props();
@@ -28,7 +30,7 @@
const tracks = $derived(data.album.tracks); const tracks = $derived(data.album.tracks);
let contextMenu = $state<ContextMenuState | null>(null); let contextMenu = $state<ContextMenuState | null>(null);
let saving = $state(false); let albumMenu = $state<AlbumMenuState | null>(null);
const queue = $derived<QueueItem[]>( const queue = $derived<QueueItem[]>(
tracks.map((track) => ({ tracks.map((track) => ({
@@ -94,6 +96,7 @@
size="large" size="large"
alt={album.title} alt={album.title}
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44" class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
oncontextmenu={(e) => (albumMenu = albumContextMenuStateFor(e, 'youtube', album.albumId))}
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase"> <p class="text-sm tracking-[0.14em] text-fg-3 uppercase">
@@ -127,22 +130,6 @@
Reproducir Reproducir
{/if} {/if}
</Button> </Button>
<form
method="POST"
action="?/save"
use:enhance={() => {
saving = true;
return async ({ update }) => {
await update();
saving = false;
};
}}
>
<Button type="submit" size="sm" variant="secondary" loading={saving}>
<Plus class="h-4 w-4" strokeWidth={2} />
Guardar en mi biblioteca
</Button>
</form>
</div> </div>
{#if form?.message} {#if form?.message}
@@ -150,7 +137,7 @@
{/if} {/if}
{#if data.album.description} {#if data.album.description}
<p class="mt-6 max-w-3xl text-sm text-fg-3">{data.album.description}</p> <p class="mt-6 text-sm text-fg-3">{data.album.description}</p>
{/if} {/if}
<TrackTable columns="32px 1fr 64px" columnsMobile="28px 1fr 52px" class="mt-6 sm:mt-8"> <TrackTable columns="32px 1fr 64px" columnsMobile="28px 1fr 52px" class="mt-6 sm:mt-8">
@@ -197,3 +184,11 @@
}} }}
/> />
{/if} {/if}
{#if albumMenu}
<AlbumContextMenu
menu={albumMenu}
playlists={data.playlists}
onClose={() => (albumMenu = null)}
/>
{/if}
@@ -1,7 +1,12 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api'; import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
import { fetchYoutubeFiller } from '$lib/server/youtube'; import { fetchYoutubeFiller } from '$lib/server/youtube';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions'; import {
addAlbumToPlaylistAction,
addTrackAction,
addYouTubeToPlaylistAction,
addYoutubeAlbumToPlaylistAction
} from '$lib/server/playlistActions';
import { import {
EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_ALBUMS_PAGE_SIZE,
EXPLORE_PAGE_SIZE as PAGE_SIZE, EXPLORE_PAGE_SIZE as PAGE_SIZE,
@@ -55,7 +60,6 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
tracks, tracks,
albums: albumsRes?.data?.items ?? [], albums: albumsRes?.data?.items ?? [],
youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [], youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [],
youtubeAlbumsContinuation: youtubeAlbumsRes?.data?.continuationToken ?? '',
ytResults: searchRes?.data ?? null, ytResults: searchRes?.data ?? null,
ytError: Boolean(searchRes?.error), ytError: Boolean(searchRes?.error),
youtubeFiller: query youtubeFiller: query
@@ -67,5 +71,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
export const actions: Actions = { export const actions: Actions = {
addTrack: addTrackAction, addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction addYouTubeToPlaylist: addYouTubeToPlaylistAction,
addAlbumToPlaylist: addAlbumToPlaylistAction,
addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction
}; };
+99 -110
View File
@@ -1,8 +1,6 @@
<script lang="ts"> <script lang="ts">
import Search from '@lucide/svelte/icons/search'; import Search from '@lucide/svelte/icons/search';
import Music from '@lucide/svelte/icons/music'; import Music from '@lucide/svelte/icons/music';
import Disc from '@lucide/svelte/icons/disc-3';
import Users from '@lucide/svelte/icons/users';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
@@ -12,12 +10,10 @@
import Alert from '$lib/components/ui/Alert.svelte'; import Alert from '$lib/components/ui/Alert.svelte';
import Input from '$lib/components/ui/Input.svelte'; import Input from '$lib/components/ui/Input.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
import ChipGroup, { type ChipOption } from '$lib/components/ui/ChipGroup.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.svelte'; import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config'; import { EXPLORE_ALBUM_SLOTS, EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config';
import { hueFor } from '$lib/theme/color'; import { hueFor } from '$lib/theme/color';
import type { YouTubeSong } from '$lib/types'; import type { YouTubeSong } from '$lib/types';
import TrackTile from '$lib/components/TrackTile.svelte'; import TrackTile from '$lib/components/TrackTile.svelte';
@@ -25,6 +21,10 @@
contextMenuStateFor, contextMenuStateFor,
type ContextMenuState type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte'; } from '$lib/components/TrackContextMenu.svelte';
import AlbumContextMenu, {
albumContextMenuStateFor,
type AlbumMenuState
} from '$lib/components/AlbumContextMenu.svelte';
import { appendUnique } from '$lib/collections'; import { appendUnique } from '$lib/collections';
import { import {
isTargetCurrent, isTargetCurrent,
@@ -42,21 +42,11 @@
let searchValue = $state(untrack(() => data.query)); let searchValue = $state(untrack(() => data.query));
let searchTimeout: ReturnType<typeof setTimeout> | undefined; let searchTimeout: ReturnType<typeof setTimeout> | undefined;
const filterOptions: ChipOption[] = [
{ value: 'tracks', label: 'Canciones', icon: Music },
{ value: 'albums', label: 'Álbumes', icon: Disc },
{ value: 'users', label: 'Usuarios', icon: Users, disabled: true, hint: 'Próximamente' }
];
let filters = $state<string[]>(['tracks', 'albums']);
const showTracks = $derived(filters.includes('tracks'));
const showAlbums = $derived(filters.includes('albums'));
let ytItems = $state<YouTubeSong[]>([]); let ytItems = $state<YouTubeSong[]>([]);
let ytContinuation = $state(''); let ytContinuation = $state('');
let ytSearchTerm = $state(''); let ytSearchTerm = $state('');
let contextMenu = $state<ContextMenuState | null>(null); let contextMenu = $state<ContextMenuState | null>(null);
let albumMenu = $state<AlbumMenuState | null>(null);
type LocalTrack = (typeof data.tracks.items)[number]; type LocalTrack = (typeof data.tracks.items)[number];
@@ -67,6 +57,7 @@
$effect(() => { $effect(() => {
contextMenu = null; contextMenu = null;
albumMenu = null;
localItems = data.tracks.items; localItems = data.tracks.items;
localPage = Number(data.tracks.pageNumber); localPage = Number(data.tracks.pageNumber);
localHasNext = Boolean(data.tracks.hasNextPage); localHasNext = Boolean(data.tracks.hasNextPage);
@@ -99,54 +90,29 @@
...ytItems.map((song) => ({ kind: 'youtube' as const, song })) ...ytItems.map((song) => ({ kind: 'youtube' as const, song }))
]); ]);
const hasMore = $derived(showTracks && (localHasNext || ytContinuation !== '')); const hasMore = $derived(localHasNext || ytContinuation !== '');
const albums = $derived(data.albums); const albums = $derived(data.albums);
const youtubeAlbums = $derived(data.youtubeAlbums);
type LocalAlbum = (typeof data.albums)[number];
type YouTubeAlbum = (typeof data.youtubeAlbums)[number]; type YouTubeAlbum = (typeof data.youtubeAlbums)[number];
type AlbumEntry = { kind: 'local'; album: LocalAlbum } | { kind: 'youtube'; album: YouTubeAlbum };
let youtubeAlbums = $state<YouTubeAlbum[]>([]); const albumEntries = $derived<AlbumEntry[]>(
let ytAlbumsContinuation = $state(''); [
let loadingAlbums = $state(false); ...albums.map((album) => ({ kind: 'local' as const, album })),
...youtubeAlbums.map((album) => ({ kind: 'youtube' as const, album }))
$effect(() => { ].slice(0, EXPLORE_ALBUM_SLOTS)
youtubeAlbums = appendUnique([], data.youtubeAlbums, (album) => album.albumId);
ytAlbumsContinuation = data.youtubeAlbumsContinuation;
});
const hasAlbums = $derived(albums.length > 0 || youtubeAlbums.length > 0);
async function loadMoreAlbums() {
if (loadingAlbums || !ytAlbumsContinuation) return;
loadingAlbums = true;
try {
const params = new URLSearchParams({
query: data.query,
continuation: ytAlbumsContinuation
});
const res = await fetch(`/api/youtube/albums?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as {
items: YouTubeAlbum[];
continuationToken: string;
};
youtubeAlbums = appendUnique(youtubeAlbums, next.items, (album) => album.albumId);
ytAlbumsContinuation = next.continuationToken;
} catch {
ytAlbumsContinuation = '';
} finally {
loadingAlbums = false;
}
}
const nothingFound = $derived(
!!data.query &&
!data.ytError &&
(showTracks ? items.length === 0 : true) &&
(showAlbums ? !hasAlbums : true) &&
filters.length > 0
); );
const hasAlbums = $derived(albumEntries.length > 0);
const fillerCount = $derived(Math.max(0, EXPLORE_ALBUM_SLOTS - albumEntries.length));
const fillerTracks = $derived(items.slice(0, fillerCount));
const remainingTracks = $derived(items.slice(fillerCount));
const nothingFound = $derived(!!data.query && !data.ytError && items.length === 0 && !hasAlbums);
function buildHref(query: string) { function buildHref(query: string) {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore'; return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
} }
@@ -177,6 +143,18 @@
contextMenu = null; contextMenu = null;
} }
function isForeignAlbum(album: LocalAlbum) {
return String(album.ownerUserId) !== data.user?.sub;
}
function openAlbumMenu(event: MouseEvent, kind: 'local' | 'youtube', albumId: string) {
albumMenu = albumContextMenuStateFor(event, kind, albumId);
}
function closeAlbumMenu() {
albumMenu = null;
}
function addToQueue() { function addToQueue() {
if (!contextMenu) return; if (!contextMenu) return;
player.addToQueue(queueItemForTarget(contextMenu)); player.addToQueue(queueItemForTarget(contextMenu));
@@ -235,26 +213,10 @@
icon={Search} icon={Search}
bind:value={searchValue} bind:value={searchValue}
oninput={onSearchInput} oninput={onSearchInput}
placeholder="Buscar en tu música y en YouTube Music..." placeholder="¿Qué quieres reproducir?"
/>
<ChipGroup
label="Qué quieres buscar"
options={filterOptions}
selected={filters}
onChange={(next) => (filters = next)}
class="mt-4"
/> />
</div> </div>
{#if filters.length === 0}
<EmptyState
icon={Search}
title="No has elegido qué buscar"
description="Marca al menos un tipo de resultado para verlos aquí."
/>
{/if}
{#if form?.message} {#if form?.message}
<Alert class="mt-4">{form.message}</Alert> <Alert class="mt-4">{form.message}</Alert>
{/if} {/if}
@@ -267,68 +229,91 @@
/> />
{/if} {/if}
{#if showAlbums && hasAlbums} {#if hasAlbums}
<div class="mt-8"> <div class="mt-8">
<SectionHeading title="Álbumes" /> <SectionHeading title="Álbumes" />
<MediaGrid class="mt-4"> <MediaGrid as="ul" class="mt-4">
{#each albums as album, i (album.id)} {#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
<li>
{#if entry.kind === 'local'}
<AlbumCard <AlbumCard
id={album.id} id={entry.album.id}
title={album.title} title={entry.album.title}
typeLabel="Álbum" typeLabel="Álbum"
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)} releaseYear={entry.album.releaseYear === null
trackCount={Number(album.trackCount)} ? undefined
trackIds={album.coverTrackIds} : Number(entry.album.releaseYear)}
trackCount={Number(entry.album.trackCount)}
trackIds={entry.album.coverTrackIds}
index={i} index={i}
onContextMenu={isForeignAlbum(entry.album)
? (e) => openAlbumMenu(e, 'local', entry.album.id)
: undefined}
/> />
{/each} {:else}
{#each youtubeAlbums as album, i (album.albumId)}
<AlbumCard <AlbumCard
id={album.albumId} id={entry.album.albumId}
href="/albums/youtube/{album.albumId}" href="/albums/youtube/{entry.album.albumId}"
title={album.title} title={entry.album.title}
typeLabel="Álbum" typeLabel="Álbum"
subtitle={album.artist} subtitle={entry.album.artist}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)} releaseYear={entry.album.releaseYear === null
coverSrc={album.thumbnailUrl} ? undefined
index={albums.length + i} : Number(entry.album.releaseYear)}
coverSrc={entry.album.thumbnailUrl}
index={i}
onContextMenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
/> />
{/if}
</li>
{/each} {/each}
</MediaGrid> {#each fillerTracks as item, i (targetId(item))}
{#if ytAlbumsContinuation}
<div class="mt-6 flex justify-center">
<Button variant="secondary" onclick={loadMoreAlbums} loading={loadingAlbums}>
Ver más álbumes
</Button>
</div>
{/if}
</div>
{/if}
{#if showTracks && items.length > 0}
{#if showAlbums && hasAlbums}
<SectionHeading title="Canciones" class="mt-10" />
{/if}
<MediaGrid as="ul" class="mt-6">
{#each items as item, i (targetId(item))}
<TrackTile <TrackTile
id={targetId(item)} id={targetId(item)}
title={targetTitle(item)} title={targetTitle(item)}
artist={targetArtist(item)} artist={targetArtist(item)}
typeLabel="Canción" typeLabel="Canción"
coverShape="round"
coverSrc={targetCoverSrc(item)}
hue={hueFor(targetId(item))}
explicit={targetExplicit(item)}
active={isTargetCurrent(item)}
playing={player.isPlaying}
index={albumEntries.length + i}
onClick={() => togglePlay(i)}
onContextMenu={(e) => openContextMenu(e, item)}
/>
{/each}
</MediaGrid>
</div>
{/if}
{#if remainingTracks.length > 0}
{#if hasAlbums}
<SectionHeading title="Canciones" class="mt-10" />
{/if}
<MediaGrid as="ul" class="mt-6">
{#each remainingTracks as item, i (targetId(item))}
<TrackTile
id={targetId(item)}
title={targetTitle(item)}
artist={targetArtist(item)}
typeLabel="Canción"
coverShape="round"
coverSrc={targetCoverSrc(item)} coverSrc={targetCoverSrc(item)}
hue={hueFor(targetId(item))} hue={hueFor(targetId(item))}
explicit={targetExplicit(item)} explicit={targetExplicit(item)}
active={isTargetCurrent(item)} active={isTargetCurrent(item)}
playing={player.isPlaying} playing={player.isPlaying}
index={i} index={i}
onClick={() => togglePlay(i)} onClick={() => togglePlay(fillerCount + i)}
onContextMenu={(e) => openContextMenu(e, item)} onContextMenu={(e) => openContextMenu(e, item)}
/> />
{/each} {/each}
</MediaGrid> </MediaGrid>
{/if}
{#if hasMore}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} /> <InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{/if} {/if}
@@ -348,3 +333,7 @@
onAddToQueue={addToQueue} onAddToQueue={addToQueue}
/> />
{/if} {/if}
{#if albumMenu}
<AlbumContextMenu menu={albumMenu} playlists={data.playlists} onClose={closeAlbumMenu} />
{/if}
+11 -2
View File
@@ -3,7 +3,7 @@ import { createApiClient, requireUser } from '$lib/server/api';
import { fetchYoutubeFiller } from '$lib/server/youtube'; import { fetchYoutubeFiller } from '$lib/server/youtube';
import { shuffle } from '$lib/collections'; import { shuffle } from '$lib/collections';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions'; import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import { HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config'; import { HOME_ALBUMS_PAGE_SIZE, HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
import { pickGreeting } from '$lib/server/greeting'; import { pickGreeting } from '$lib/server/greeting';
export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
@@ -16,14 +16,21 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const playlistsPromise = api.GET('/playlists/users/{userId}', { const playlistsPromise = api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 12 } } params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 12 } }
}); });
const albumsPromise = api.GET('/albums/users/{userId}', {
params: {
path: { userId: user.sub },
query: { pageNumber: 1, pageSize: HOME_ALBUMS_PAGE_SIZE }
}
});
const mixesPromise = api.GET('/mixes'); const mixesPromise = api.GET('/mixes');
const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', { const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
params: { path: { id: user.sub } } params: { path: { id: user.sub } }
}); });
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([ const [latest, playlists, albums, mixes, recentlyPlayed] = await Promise.all([
latestPromise, latestPromise,
playlistsPromise, playlistsPromise,
albumsPromise,
mixesPromise, mixesPromise,
recentlyPlayedPromise recentlyPlayedPromise
]); ]);
@@ -41,6 +48,8 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
user.sub user.sub
), ),
playlists: playlistItems, playlists: playlistItems,
albums: albums.data?.items ?? [],
albumsHasMore: Boolean(albums.data?.hasNextPage),
mixes: mixes.data ?? [], mixes: mixes.data ?? [],
recentlyPlayed: recentlyPlayed.data ?? [] recentlyPlayed: recentlyPlayed.data ?? []
}; };
+115 -1
View File
@@ -14,8 +14,10 @@
} from '$lib/tracks'; } from '$lib/tracks';
import { hueFor } from '$lib/theme/color'; import { hueFor } from '$lib/theme/color';
import Cover from '$lib/components/ui/Cover.svelte'; import Cover from '$lib/components/ui/Cover.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte'; import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import MixCard from '$lib/components/ui/MixCard.svelte'; import MixCard from '$lib/components/ui/MixCard.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte'; import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte'; import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
@@ -29,7 +31,7 @@
type ContextMenuState type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte'; } from '$lib/components/TrackContextMenu.svelte';
import type { YouTubeSong } from '$lib/types'; import type { YouTubeSong } from '$lib/types';
import { appendUnique } from '$lib/collections'; import { appendUnique, shuffle } from '$lib/collections';
let { data } = $props(); let { data } = $props();
@@ -107,12 +109,58 @@
} }
} }
const playlists = $derived(data.playlists); const playlists = $derived(data.playlists);
const albums = $derived(data.albums);
const mixes = $derived(data.mixes); const mixes = $derived(data.mixes);
function playLatest(index: number) { function playLatest(index: number) {
player.playOrToggle(latest.map(queueItemForTarget), index); player.playOrToggle(latest.map(queueItemForTarget), index);
} }
function playShuffledLocal() {
if (localLatest.length === 0) return;
player.playQueue(shuffle(localLatest).map(queueItemForTarget), 0);
}
function playShuffledYoutube() {
if (youtubeFillerItems.length === 0) return;
const targets = youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }));
player.playQueue(shuffle(targets).map(queueItemForTarget), 0);
}
interface QuickMix {
key: string;
title: string;
subtitle: string;
coverIds?: (string | number)[];
coverSongs?: YouTubeSong[];
onClick: () => void;
}
const quickMixes = $derived<QuickMix[]>(
(
[
localLatest.length > 0
? {
key: 'local',
title: 'Mezcla Musify',
subtitle: `${localLatest.length} ${localLatest.length === 1 ? 'canción' : 'canciones'}`,
coverIds: localLatest.map((item) => targetId(item)),
onClick: playShuffledLocal
}
: null,
youtubeFillerItems.length > 0
? {
key: 'youtube',
title: 'Mezcla YouTube Music',
subtitle: `${youtubeFillerItems.length} ${youtubeFillerItems.length === 1 ? 'canción' : 'canciones'}`,
coverSongs: youtubeFillerItems.slice(0, 4),
onClick: playShuffledYoutube
}
: null
] as (QuickMix | null)[]
).filter((mix): mix is QuickMix => mix !== null)
);
function openContextMenu(event: MouseEvent, target: TrackTarget) { function openContextMenu(event: MouseEvent, target: TrackTarget) {
contextMenu = contextMenuStateFor(event, target); contextMenu = contextMenuStateFor(event, target);
} }
@@ -225,6 +273,46 @@
</section> </section>
{/if} {/if}
{#if quickMixes.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6">
<SectionHeading title="Mezclas rápidas" />
<MediaGrid>
{#each quickMixes as mix, i (mix.key)}
<button
type="button"
onclick={mix.onClick}
class="group animate-enter min-w-0 text-left"
style="animation-delay:{i * 45}ms"
>
{#if mix.coverSongs}
<div
class="grid aspect-square w-full grid-cols-2 grid-rows-2 overflow-hidden rounded-art shadow-art transition group-hover:shadow-art-lg"
>
{#each mix.coverSongs as song (song.videoId)}
<Cover
trackId={song.videoId}
src={song.thumbnailUrl}
hue={hueFor(song.videoId)}
size="small"
class="h-full w-full"
/>
{/each}
</div>
{:else}
<PlaylistArt
trackIds={mix.coverIds ?? []}
hue={hueFor(mix.key)}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
/>
{/if}
<div class="mt-2.5 truncate text-fg">{mix.title}</div>
<div class="mt-0.5 truncate text-sm text-fg-3">{mix.subtitle}</div>
</button>
{/each}
</MediaGrid>
</section>
{/if}
{#if mixes.length > 0} {#if mixes.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6"> <section class="page-x pt-5 pb-3 sm:pt-6">
<SectionHeading title="Hechas para ti" /> <SectionHeading title="Hechas para ti" />
@@ -254,6 +342,32 @@
</section> </section>
{/if} {/if}
{#if albums.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6">
<SectionHeading title="Tus álbumes">
{#snippet actions()}
{#if data.albumsHasMore}
<a href="/albums" class="text-sm text-fg-3 transition hover:text-accent-soft">
Ver todos
</a>
{/if}
{/snippet}
</SectionHeading>
<MediaGrid>
{#each albums as album, i (album.id)}
<AlbumCard
id={album.id}
title={album.title}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackCount={Number(album.trackCount)}
trackIds={album.coverTrackIds}
index={i}
/>
{/each}
</MediaGrid>
</section>
{/if}
<section class="page-x pt-5 pb-10 sm:pt-6"> <section class="page-x pt-5 pb-10 sm:pt-6">
<SectionHeading title="Novedades" /> <SectionHeading title="Novedades" />
{#if latest.length > 0} {#if latest.length > 0}
@@ -1,21 +0,0 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
const accessToken = requireAccessToken(locals, 'Inicia sesión para buscar.');
const query = url.searchParams.get('query')?.trim() ?? '';
if (!query) {
error(400, 'Falta el término de búsqueda.');
}
const continuation = url.searchParams.get('continuation') ?? undefined;
const api = createApiClient({ fetch, accessToken });
const result = await api.GET('/youtube/albums', {
params: { query: { query, continuation } }
});
return json(unwrapOrError(result, 'No se pudieron buscar álbumes en YouTube Music.'));
};
+1
View File
@@ -28,6 +28,7 @@
--radius-panel: 1rem; --radius-panel: 1rem;
--radius-art: 0.75rem; --radius-art: 0.75rem;
--radius-art-lg: 1rem; --radius-art-lg: 1rem;
--radius-art-round: 1.75rem;
--shadow-art: 0 12px 28px -10px rgba(0, 0, 0, 0.6); --shadow-art: 0 12px 28px -10px rgba(0, 0, 0, 0.6);
--shadow-art-lg: 0 20px 45px -15px rgba(0, 0, 0, 0.7); --shadow-art-lg: 0 20px 45px -15px rgba(0, 0, 0, 0.7);