diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index be9348a..118d780 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -73,6 +73,23 @@ export interface paths { patch?: never; trace?: never; }; + "/playlists/{id}/cover": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get A PlayList Cover Image. */ + get: operations["GetPlayListCover"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/playlists/{playlistId}": { parameters: { query?: never; @@ -126,6 +143,23 @@ export interface paths { patch?: never; trace?: never; }; + "/playlists/{playlistId}/youtube-tracks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Add A YouTube Track To A PlayList, Downloading It In The Background If Needed. */ + post: operations["AddYouTubeTrackToPlayList"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tracks": { parameters: { query?: never; @@ -298,10 +332,52 @@ export interface paths { patch?: never; trace?: never; }; + "/youtube/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Search Songs On YouTube Music. */ + get: operations["SearchYouTubeTracks"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/youtube/tracks/{videoId}/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise. */ + get: operations["GetYouTubeTrackStream"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { schemas: { + AddYouTubeTrackRequest: { + videoId: string; + title: string; + artist: string; + /** Format: int32 */ + durationSeconds: number | string; + thumbnailUrl: string; + }; CreatePlayListRequest: { name: string; description: string; @@ -396,6 +472,8 @@ export interface components { expiresInSeconds: number | string; uploadUrl: string; }; + /** @enum {unknown} */ + ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed"; RequestPlayListPictureUploadRequest: { fileType: string; contentType: string; @@ -416,6 +494,10 @@ export interface components { /** Format: uuid */ id: string; title: string; + artist: string; + source: components["schemas"]["TrackSource"]; + externalId: string; + audioStatus: components["schemas"]["ProcessingStatus"]; /** Format: int32 */ duration: number | string; /** Format: int32 */ @@ -425,6 +507,8 @@ export interface components { /** Format: date-time */ updatedAt: string; }; + /** @enum {unknown} */ + TrackSource: "Local" | "YouTube"; TrackStreamResponse: { manifestUrl: string; ticket: string; @@ -466,6 +550,30 @@ export interface components { /** Format: date-time */ updatedAt: string; }; + YouTubeSearchResult: { + items: components["schemas"]["YouTubeSongResult"][]; + continuationToken: string; + }; + YouTubeSongResult: { + videoId: string; + title: string; + artist: string; + album: string; + /** Format: int32 */ + durationSeconds: number | string; + thumbnailUrl: string; + }; + /** @enum {unknown} */ + YouTubeStreamMode: "Server" | "YouTube"; + YouTubeStreamResponse: { + mode: components["schemas"]["YouTubeStreamMode"]; + streamUrl: string; + ticket: string; + /** Format: int32 */ + expiresInSeconds: number | string; + /** Format: uuid */ + trackId: string; + }; }; responses: never; parameters: never; @@ -632,6 +740,35 @@ export interface operations { }; }; }; + GetPlayListCover: { + parameters: { + query?: { + size?: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; UpdatePlayList: { parameters: { query?: never; @@ -839,6 +976,62 @@ export interface operations { }; }; }; + AddYouTubeTrackToPlayList: { + parameters: { + query?: never; + header?: never; + path: { + playlistId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddYouTubeTrackRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TrackApplicationResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @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; + }; + }; + }; GetTracks: { parameters: { query?: { @@ -1254,4 +1447,79 @@ export interface operations { }; }; }; + SearchYouTubeTracks: { + parameters: { + query: { + query: string; + continuation?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["YouTubeSearchResult"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + GetYouTubeTrackStream: { + parameters: { + query?: never; + header?: never; + path: { + videoId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["YouTubeStreamResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; } diff --git a/frontend/src/lib/player/player.svelte.ts b/frontend/src/lib/player/player.svelte.ts index b847499..69322a4 100644 --- a/frontend/src/lib/player/player.svelte.ts +++ b/frontend/src/lib/player/player.svelte.ts @@ -2,12 +2,16 @@ import { browser } from '$app/environment'; import { HUES, accentForHue, gradientForHue } from '$lib/theme/color'; import { extractAccent, type Accent } from '$lib/theme/palette'; +export type TrackSourceKind = 'local' | 'youtube'; + export interface PlayerTrack { id: string | number; title: string; artist: string; duration: number; hue: number; + source: TrackSourceKind; + coverUrl?: string; } export interface Playlist { @@ -20,11 +24,43 @@ export interface QueueItem { id: string | number; title: string; artist?: string; + source?: TrackSourceKind; + coverUrl?: string; +} + +export interface ApiTrackLike { + id: string | number; + title: string; + artist?: string; + source?: 'Local' | 'YouTube'; + externalId?: string; + audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed'; +} + +export function isPendingYouTubeTrack(track: ApiTrackLike): boolean { + return track.source === 'YouTube' && track.audioStatus !== 'Completed'; +} + +export function queueIdForTrack(track: ApiTrackLike): string | number { + return isPendingYouTubeTrack(track) && track.externalId ? track.externalId : track.id; +} + +export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] { + return tracks.map((track) => { + const pendingYouTube = isPendingYouTubeTrack(track); + return { + id: queueIdForTrack(track), + title: track.title, + artist: track.artist, + source: pendingYouTube ? 'youtube' : 'local', + coverUrl: pendingYouTube ? `/api/tracks/${track.id}/cover?size=small` : undefined + }; + }); } const RECENT_LIMIT = 15; -const EMPTY: PlayerTrack = { id: '', title: '', artist: '', duration: 0, hue: HUES[0] }; +const EMPTY: PlayerTrack = { id: '', title: '', artist: '', duration: 0, hue: HUES[0], source: 'local' }; function hueFor(id: string | number): number { const text = String(id); @@ -39,7 +75,9 @@ function toTrack(item: QueueItem): PlayerTrack { title: item.title, artist: item.artist ?? '', duration: 0, - hue: hueFor(item.id) + hue: hueFor(item.id), + source: item.source ?? 'local', + coverUrl: item.coverUrl }; } @@ -61,6 +99,7 @@ class PlayerState { #audio: HTMLAudioElement | null = null; #loadToken = 0; + #retriedId: string | number | null = null; #accentCache = new Map(); #rafId: number | null = null; @@ -97,6 +136,15 @@ class PlayerState { }); audio.addEventListener('error', () => { this.#stopProgressLoop(); + if ( + this.currentId !== null && + this.current.source === 'youtube' && + this.#retriedId !== this.currentId + ) { + this.#retriedId = this.currentId; + this.#loadCurrent(); + return; + } this.loading = false; this.playing = false; this.error = 'No se pudo reproducir la pista.'; @@ -143,7 +191,7 @@ class PlayerState { ); } - async #applyAccent(id: string | number) { + async #applyAccent(id: string | number, coverUrl?: string) { const key = String(id); const cached = this.#accentCache.get(key); if (cached) { @@ -151,7 +199,7 @@ class PlayerState { this.gradientColor = cached.gradient; return; } - const result = await extractAccent(`/api/tracks/${id}/cover?size=small`); + const result = await extractAccent(coverUrl ?? `/api/tracks/${id}/cover?size=small`); if (this.currentId !== id) return; if (result) { this.#accentCache.set(key, result); @@ -167,24 +215,23 @@ class PlayerState { const audio = this.#audioEl(); if (!audio || this.currentId === null) return; const id = this.currentId; - this.#applyAccent(id); + const track = this.tracks.find((t) => t.id === id); + this.#applyAccent(id, track?.coverUrl); const token = ++this.#loadToken; this.loading = true; this.error = ''; this.progress = 0; try { - const res = await fetch(`/api/tracks/${id}/stream`); - if (!res.ok) throw new Error(String(res.status)); - const { manifestUrl, ticket } = (await res.json()) as { - manifestUrl: string; - ticket: string; - }; + const src = + track?.source === 'youtube' + ? await this.#resolveYouTubeSrc(String(id)) + : await this.#resolveLocalSrc(String(id)); if (token !== this.#loadToken) return; - const sep = manifestUrl.includes('?') ? '&' : '?'; - audio.src = ticket ? `${manifestUrl}${sep}t=${encodeURIComponent(ticket)}` : manifestUrl; + audio.src = src; audio.volume = this.volume / 100; await audio.play(); this.loading = false; + this.#retriedId = null; this.#pushRecent(this.current); } catch { if (token !== this.#loadToken) return; @@ -194,6 +241,33 @@ class PlayerState { } } + async #resolveLocalSrc(id: string): Promise { + const res = await fetch(`/api/tracks/${id}/stream`); + if (!res.ok) throw new Error(String(res.status)); + const { manifestUrl, ticket } = (await res.json()) as { + manifestUrl: string; + ticket: string; + }; + return this.#withTicket(manifestUrl, ticket); + } + + async #resolveYouTubeSrc(videoId: string): Promise { + const res = await fetch(`/api/youtube/tracks/${videoId}/stream`); + if (!res.ok) throw new Error(String(res.status)); + const { mode, streamUrl, ticket } = (await res.json()) as { + mode: string; + streamUrl: string; + ticket: string; + }; + return mode === 'Server' ? this.#withTicket(streamUrl, ticket) : streamUrl; + } + + #withTicket(url: string, ticket: string): string { + if (!ticket) return url; + const sep = url.includes('?') ? '&' : '?'; + return `${url}${sep}t=${encodeURIComponent(ticket)}`; + } + playlistTracks(ids: (string | number)[]): PlayerTrack[] { return ids .map((id) => this.tracks.find((t) => t.id === id)) diff --git a/frontend/src/routes/(app)/explore/+page.server.ts b/frontend/src/routes/(app)/explore/+page.server.ts index d6754e6..3b8459d 100644 --- a/frontend/src/routes/(app)/explore/+page.server.ts +++ b/frontend/src/routes/(app)/explore/+page.server.ts @@ -1,14 +1,42 @@ -import type { PageServerLoad } from './$types'; -import { error } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { error, fail } from '@sveltejs/kit'; import { createApiClient } from '$lib/server/api'; const PAGE_SIZE = 24; export const load: PageServerLoad = async ({ url, locals, fetch }) => { const query = url.searchParams.get('q')?.trim() ?? ''; + const src = url.searchParams.get('src') === 'yt' ? 'yt' : ('local' as const); const page = Math.max(1, Number(url.searchParams.get('page')) || 1); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + + if (src === 'yt') { + const emptyResults = { items: [], continuationToken: '' }; + + const playlistsPromise = locals.user + ? api.GET('/playlists/users/{userId}', { + params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } } + }) + : Promise.resolve(null); + + const searchPromise = + query && locals.accessToken + ? api.GET('/youtube/search', { params: { query: { query } } }) + : Promise.resolve(null); + + const [playlistsRes, searchRes] = await Promise.all([playlistsPromise, searchPromise]); + + return { + src: 'yt' as const, + query, + ytResults: searchRes?.data ?? emptyResults, + ytError: Boolean(searchRes?.error), + playlists: playlistsRes?.data?.items ?? [], + needsAuth: !locals.user + }; + } + const { data, error: err } = await api.GET('/tracks', { params: { query: { name: query || undefined, pageNumber: page, pageSize: PAGE_SIZE } } }); @@ -17,5 +45,38 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { error(502, 'No se pudieron cargar las canciones.'); } - return { tracks: data, query }; + return { src: 'local' as const, query, tracks: data }; +}; + +export const actions: Actions = { + addYouTubeToPlaylist: async ({ request, locals, fetch }) => { + if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); + + const form = await request.formData(); + const playlistId = String(form.get('playlistId') ?? ''); + const videoId = String(form.get('videoId') ?? ''); + const title = String(form.get('title') ?? ''); + const artist = String(form.get('artist') ?? ''); + const durationSeconds = Number(form.get('durationSeconds') ?? 0); + const thumbnailUrl = String(form.get('thumbnailUrl') ?? ''); + + if (!playlistId || !videoId || !title) { + return fail(400, { message: 'Faltan datos de la canción.' }); + } + + const api = createApiClient({ fetch, accessToken: locals.accessToken }); + const { error: err, response } = await api.POST('/playlists/{playlistId}/youtube-tracks', { + params: { path: { playlistId } }, + body: { videoId, title, artist, durationSeconds, thumbnailUrl } + }); + + if (err) { + if (response?.status === 409) { + return fail(409, { message: 'La canción ya está en esa playlist.', videoId }); + } + return fail(502, { message: 'No se pudo añadir la canción.', videoId }); + } + + return { added: true, videoId, playlistId }; + } }; diff --git a/frontend/src/routes/(app)/explore/+page.svelte b/frontend/src/routes/(app)/explore/+page.svelte index f43da48..647ce58 100644 --- a/frontend/src/routes/(app)/explore/+page.svelte +++ b/frontend/src/routes/(app)/explore/+page.svelte @@ -1,42 +1,115 @@ @@ -107,62 +114,62 @@ -
+
{#if tracks.length > 0}
- # - Título + # + Título Añadida - + Duración
{#each tracks as track, i (track.id)}
{dateFormatter.format(new Date(track.createdAt))}
- + {fmtTime(Number(track.duration))}
async ({ update }) => update()}> @@ -190,7 +215,7 @@ @@ -204,10 +229,18 @@ {#if library.length > 0}

Añadir de tu biblioteca

-
+
+ Título + Escuchas + Duración + +
+
{#each library as track, i (track.id)}
-
+
{Number(track.listensCount)}
+ + {fmtTime(Number(track.duration))} +
@@ -272,7 +308,7 @@ type="button" onclick={() => (editing = false)} aria-label="Cerrar" - class="grid h-8 w-8 place-items-center rounded-full text-neutral-400 transition hover:bg-white/5 hover:text-white" + class="grid h-8 w-8 place-items-center rounded-lg text-neutral-400 transition hover:bg-white/5 hover:text-white" > diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index dfbbbe2..53bf48e 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,6 +1,6 @@