feat(frontend): YouTube Music search, playback and playlist saving

This commit is contained in:
2026-07-16 01:06:20 +02:00
parent 24595a17d6
commit 560a943bab
8 changed files with 856 additions and 133 deletions
+268
View File
@@ -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<string, never>;
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;
};
};
};
}
+87 -13
View File
@@ -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<string, Accent>();
#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<string> {
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<string> {
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))