Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
29 changed files with 1742 additions and 406 deletions
Showing only changes of commit 2f98982d83 - Show all commits
@@ -50,15 +50,6 @@ public static class AlbumEndpoints
.Produces(StatusCodes.Status401Unauthorized)
.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)
.WithName("UpdateAlbum")
.WithSummary("Update An Album.")
@@ -152,19 +143,6 @@ public static class AlbumEndpoints
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(
IMediator mediator,
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]
public required long OwnerUserId { get; set; }
[MaxLength(64)]
public string? ExternalId { get; set; }
[ForeignKey(nameof(OwnerUserId))]
public User Owner { get; set; }
}
@@ -111,10 +111,6 @@ namespace Musify.Infrastructure.Persistence
modelBuilder.Entity<UserAlbum>()
.ToTable("UserAlbums");
modelBuilder.Entity<UserAlbum>()
.HasIndex(album => new { album.OwnerUserId, album.ExternalId })
.IsUnique();
modelBuilder.Entity<UserAlbum>()
.HasOne(album => album.Owner)
.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.Property<string>("ExternalId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long>("OwnerUserId")
.HasColumnType("bigint");
b.HasIndex("OwnerUserId", "ExternalId")
.IsUnique();
b.HasIndex("OwnerUserId");
b.ToTable("UserAlbums", (string)null);
});
@@ -162,7 +162,7 @@ namespace Musify.Infrastructure.Services
return new YouTubeAlbumDetail(
album,
info.Description ?? string.Empty,
StripWikipediaAttribution(info.Description),
(int)info.Duration.TotalSeconds,
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) =>
new(
album.Id,
@@ -16,6 +16,9 @@
"OpenTelemetry": {
"OtlpEndpoint": "http://localhost:4317"
},
"YouTube": {
"GeographicalLocation": "ES"
},
"InfrastructureStorage": {
"Address": "http://localhost:8333",
"AccessKey": "admin_access_key",
-60
View File
@@ -73,23 +73,6 @@ export interface paths {
patch?: 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}": {
parameters: {
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: {
parameters: {
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"
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
@@ -132,7 +132,7 @@
{user.name.charAt(0)}
</span>
{/if}
<span class="truncate leading-[1.35]">{user.name}</span>
<span class="truncate leading-[1.35] text-fg-3">{user.name}</span>
</button>
</div>
</aside>
@@ -11,6 +11,7 @@
typeLabel?: string;
coverSrc?: string | null;
coverSize?: 'small' | 'medium' | 'large';
coverShape?: 'square' | 'round';
hue: number;
explicit?: boolean;
active: boolean;
@@ -28,6 +29,7 @@
typeLabel,
coverSrc,
coverSize = 'large',
coverShape = 'square',
hue,
explicit = false,
active,
@@ -58,7 +60,9 @@
{hue}
size={coverSize}
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} />
</Cover>
@@ -3,8 +3,19 @@
import TransportControls from './TransportControls.svelte';
import SeekBar from './SeekBar.svelte';
import VolumeControl from './VolumeControl.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import { player } from '$lib/player/player.svelte';
</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
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))"
@@ -14,6 +14,7 @@
trackIds?: (string | number)[];
coverSrc?: string | null;
index?: number;
onContextMenu?: (event: MouseEvent) => void;
}
let {
@@ -26,7 +27,8 @@
trackCount,
trackIds = [],
coverSrc,
index = 0
index = 0,
onContextMenu
}: Props = $props();
const meta = $derived(
@@ -47,6 +49,7 @@
href={href ?? `/albums/${id}`}
class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms"
oncontextmenu={onContextMenu}
>
{#if coverSrc}
<Cover
@@ -11,6 +11,7 @@
alt?: string;
class?: string;
children?: Snippet;
oncontextmenu?: (event: MouseEvent) => void;
}
let {
@@ -20,7 +21,8 @@
src,
alt = '',
class: klass = '',
children
children,
oncontextmenu
}: Props = $props();
let failed = $state(false);
@@ -40,6 +42,8 @@
<div
class="relative overflow-hidden {klass}"
style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`}
role="presentation"
{oncontextmenu}
>
{#if !failed}
<img
@@ -37,7 +37,7 @@
<div class="relative w-full">
{#if Icon}
<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>
{/if}
<input
@@ -52,7 +52,7 @@
{autocomplete}
{oninput}
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'
: 'px-4'} {klass}"
/>
@@ -1,10 +1,20 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
class?: string;
actions?: Snippet;
}
let { title, class: klass = '' }: Props = $props();
let { title, class: klass = '', actions }: Props = $props();
</script>
{#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_EARLIEST_YEAR = 1877;
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_ALBUMS_PAGE_SIZE = 12;
export const YOUTUBE_FILLER_LIMIT = 27;
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 EMPTY: PlayerTrack = {
@@ -286,13 +295,16 @@ class PlayerState {
if (token !== this.#loadToken) return;
this.loading = 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> {
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 {
manifestUrl: string;
ticket: string;
@@ -302,7 +314,7 @@ class PlayerState {
async #resolveYouTubeSrc(videoId: string): Promise<string> {
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 {
mode: string;
streamUrl: string;
@@ -1,5 +1,6 @@
import { fail, type RequestEvent } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
@@ -59,3 +60,79 @@ export async function addYouTubeToPlaylistAction({ request, params, locals, fetc
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 { fail, redirect } from '@sveltejs/kit';
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
import {
createApiClient,
requireAccessTokenAction,
requireUser,
unwrapOrError
} from '$lib/server/api';
import { addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
addYouTubeToPlaylistAction,
addYoutubeAlbumToPlaylistAction
} from '$lib/server/playlistActions';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const user = requireUser(locals, url);
@@ -26,27 +23,5 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
export const actions: Actions = {
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
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}`);
}
addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction
};
@@ -1,9 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import Plus from '@lucide/svelte/icons/plus';
import { player, type QueueItem } from '$lib/player/player.svelte';
import { hueFor } from '$lib/theme/color';
import { fmtTime } from '$lib/format';
@@ -20,6 +18,10 @@
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import AlbumContextMenu, {
albumContextMenuStateFor,
type AlbumMenuState
} from '$lib/components/AlbumContextMenu.svelte';
import type { TrackTarget } from '$lib/tracks';
let { data, form } = $props();
@@ -28,7 +30,7 @@
const tracks = $derived(data.album.tracks);
let contextMenu = $state<ContextMenuState | null>(null);
let saving = $state(false);
let albumMenu = $state<AlbumMenuState | null>(null);
const queue = $derived<QueueItem[]>(
tracks.map((track) => ({
@@ -94,6 +96,7 @@
size="large"
alt={album.title}
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">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">
@@ -127,22 +130,6 @@
Reproducir
{/if}
</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>
{#if form?.message}
@@ -150,7 +137,7 @@
{/if}
{#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}
<TrackTable columns="32px 1fr 64px" columnsMobile="28px 1fr 52px" class="mt-6 sm:mt-8">
@@ -197,3 +184,11 @@
}}
/>
{/if}
{#if albumMenu}
<AlbumContextMenu
menu={albumMenu}
playlists={data.playlists}
onClose={() => (albumMenu = null)}
/>
{/if}
@@ -1,7 +1,12 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
import { fetchYoutubeFiller } from '$lib/server/youtube';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import {
addAlbumToPlaylistAction,
addTrackAction,
addYouTubeToPlaylistAction,
addYoutubeAlbumToPlaylistAction
} from '$lib/server/playlistActions';
import {
EXPLORE_ALBUMS_PAGE_SIZE,
EXPLORE_PAGE_SIZE as PAGE_SIZE,
@@ -55,7 +60,6 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
tracks,
albums: albumsRes?.data?.items ?? [],
youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [],
youtubeAlbumsContinuation: youtubeAlbumsRes?.data?.continuationToken ?? '',
ytResults: searchRes?.data ?? null,
ytError: Boolean(searchRes?.error),
youtubeFiller: query
@@ -67,5 +71,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
export const actions: Actions = {
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
addAlbumToPlaylist: addAlbumToPlaylistAction,
addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction
};
+99 -110
View File
@@ -1,8 +1,6 @@
<script lang="ts">
import Search from '@lucide/svelte/icons/search';
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 { goto } from '$app/navigation';
import { player } from '$lib/player/player.svelte';
@@ -12,12 +10,10 @@
import Alert from '$lib/components/ui/Alert.svelte';
import Input from '$lib/components/ui/Input.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 ChipGroup, { type ChipOption } from '$lib/components/ui/ChipGroup.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.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 type { YouTubeSong } from '$lib/types';
import TrackTile from '$lib/components/TrackTile.svelte';
@@ -25,6 +21,10 @@
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import AlbumContextMenu, {
albumContextMenuStateFor,
type AlbumMenuState
} from '$lib/components/AlbumContextMenu.svelte';
import { appendUnique } from '$lib/collections';
import {
isTargetCurrent,
@@ -42,21 +42,11 @@
let searchValue = $state(untrack(() => data.query));
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 ytContinuation = $state('');
let ytSearchTerm = $state('');
let contextMenu = $state<ContextMenuState | null>(null);
let albumMenu = $state<AlbumMenuState | null>(null);
type LocalTrack = (typeof data.tracks.items)[number];
@@ -67,6 +57,7 @@
$effect(() => {
contextMenu = null;
albumMenu = null;
localItems = data.tracks.items;
localPage = Number(data.tracks.pageNumber);
localHasNext = Boolean(data.tracks.hasNextPage);
@@ -99,54 +90,29 @@
...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 youtubeAlbums = $derived(data.youtubeAlbums);
type LocalAlbum = (typeof data.albums)[number];
type YouTubeAlbum = (typeof data.youtubeAlbums)[number];
type AlbumEntry = { kind: 'local'; album: LocalAlbum } | { kind: 'youtube'; album: YouTubeAlbum };
let youtubeAlbums = $state<YouTubeAlbum[]>([]);
let ytAlbumsContinuation = $state('');
let loadingAlbums = $state(false);
$effect(() => {
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 albumEntries = $derived<AlbumEntry[]>(
[
...albums.map((album) => ({ kind: 'local' as const, album })),
...youtubeAlbums.map((album) => ({ kind: 'youtube' as const, album }))
].slice(0, EXPLORE_ALBUM_SLOTS)
);
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) {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
}
@@ -177,6 +143,18 @@
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() {
if (!contextMenu) return;
player.addToQueue(queueItemForTarget(contextMenu));
@@ -235,26 +213,10 @@
icon={Search}
bind:value={searchValue}
oninput={onSearchInput}
placeholder="Buscar en tu música y en YouTube Music..."
/>
<ChipGroup
label="Qué quieres buscar"
options={filterOptions}
selected={filters}
onChange={(next) => (filters = next)}
class="mt-4"
placeholder="¿Qué quieres reproducir?"
/>
</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}
<Alert class="mt-4">{form.message}</Alert>
{/if}
@@ -267,68 +229,91 @@
/>
{/if}
{#if showAlbums && hasAlbums}
{#if hasAlbums}
<div class="mt-8">
<SectionHeading title="Álbumes" />
<MediaGrid class="mt-4">
{#each albums as album, i (album.id)}
<MediaGrid as="ul" class="mt-4">
{#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
<li>
{#if entry.kind === 'local'}
<AlbumCard
id={album.id}
title={album.title}
id={entry.album.id}
title={entry.album.title}
typeLabel="Álbum"
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackCount={Number(album.trackCount)}
trackIds={album.coverTrackIds}
releaseYear={entry.album.releaseYear === null
? undefined
: Number(entry.album.releaseYear)}
trackCount={Number(entry.album.trackCount)}
trackIds={entry.album.coverTrackIds}
index={i}
onContextMenu={isForeignAlbum(entry.album)
? (e) => openAlbumMenu(e, 'local', entry.album.id)
: undefined}
/>
{/each}
{#each youtubeAlbums as album, i (album.albumId)}
{:else}
<AlbumCard
id={album.albumId}
href="/albums/youtube/{album.albumId}"
title={album.title}
id={entry.album.albumId}
href="/albums/youtube/{entry.album.albumId}"
title={entry.album.title}
typeLabel="Álbum"
subtitle={album.artist}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
coverSrc={album.thumbnailUrl}
index={albums.length + i}
subtitle={entry.album.artist}
releaseYear={entry.album.releaseYear === null
? undefined
: Number(entry.album.releaseYear)}
coverSrc={entry.album.thumbnailUrl}
index={i}
onContextMenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
/>
{/if}
</li>
{/each}
</MediaGrid>
{#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))}
{#each fillerTracks as item, i (targetId(item))}
<TrackTile
id={targetId(item)}
title={targetTitle(item)}
artist={targetArtist(item)}
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)}
hue={hueFor(targetId(item))}
explicit={targetExplicit(item)}
active={isTargetCurrent(item)}
playing={player.isPlaying}
index={i}
onClick={() => togglePlay(i)}
onClick={() => togglePlay(fillerCount + i)}
onContextMenu={(e) => openContextMenu(e, item)}
/>
{/each}
</MediaGrid>
{/if}
{#if hasMore}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{/if}
@@ -348,3 +333,7 @@
onAddToQueue={addToQueue}
/>
{/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 { shuffle } from '$lib/collections';
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';
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}', {
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 recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
params: { path: { id: user.sub } }
});
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([
const [latest, playlists, albums, mixes, recentlyPlayed] = await Promise.all([
latestPromise,
playlistsPromise,
albumsPromise,
mixesPromise,
recentlyPlayedPromise
]);
@@ -41,6 +48,8 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
user.sub
),
playlists: playlistItems,
albums: albums.data?.items ?? [],
albumsHasMore: Boolean(albums.data?.hasNextPage),
mixes: mixes.data ?? [],
recentlyPlayed: recentlyPlayed.data ?? []
};
+115 -1
View File
@@ -14,8 +14,10 @@
} from '$lib/tracks';
import { hueFor } from '$lib/theme/color';
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 MixCard from '$lib/components/ui/MixCard.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.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';
@@ -29,7 +31,7 @@
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import type { YouTubeSong } from '$lib/types';
import { appendUnique } from '$lib/collections';
import { appendUnique, shuffle } from '$lib/collections';
let { data } = $props();
@@ -107,12 +109,58 @@
}
}
const playlists = $derived(data.playlists);
const albums = $derived(data.albums);
const mixes = $derived(data.mixes);
function playLatest(index: number) {
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) {
contextMenu = contextMenuStateFor(event, target);
}
@@ -225,6 +273,46 @@
</section>
{/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}
<section class="page-x pt-5 pb-3 sm:pt-6">
<SectionHeading title="Hechas para ti" />
@@ -254,6 +342,32 @@
</section>
{/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">
<SectionHeading title="Novedades" />
{#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-art: 0.75rem;
--radius-art-lg: 1rem;
--radius-art-round: 1.75rem;
--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);