refactor: rename frontend/ to web-player/ and format with prettier
Updates the compose build context, the Dockerfile header, the package name and the docs. The compose service and container keep the name frontend/musify-frontend, since nginx routes to http://frontend:3000. The source files also carry the first prettier pass, so the rename and the reformat land together for them.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import createClient from 'openapi-fetch';
|
||||
import { error, fail, redirect, type ActionFailure } from '@sveltejs/kit';
|
||||
import type { paths } from '$lib/api/schema';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import { apiConfig } from '$lib/server/config';
|
||||
|
||||
interface ApiClientOptions {
|
||||
fetch: typeof fetch;
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
export function createApiClient({ fetch, accessToken }: ApiClientOptions) {
|
||||
return createClient<paths>({
|
||||
baseUrl: apiConfig.baseUrl,
|
||||
fetch,
|
||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
export function requireUser(locals: App.Locals, url: URL): SessionUser {
|
||||
if (!locals.user)
|
||||
redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
||||
return locals.user;
|
||||
}
|
||||
|
||||
export function requireAccessToken(locals: App.Locals, message = 'Inicia sesión.'): string {
|
||||
if (!locals.accessToken) error(401, message);
|
||||
return locals.accessToken;
|
||||
}
|
||||
|
||||
export function requireAccessTokenAction(
|
||||
locals: App.Locals,
|
||||
message = 'Inicia sesión.'
|
||||
): string | ActionFailure<{ message: string }> {
|
||||
return locals.accessToken ?? fail(401, { message });
|
||||
}
|
||||
|
||||
export function unwrapOrError<T>(
|
||||
result: { data?: T; error?: unknown; response?: Response },
|
||||
message: string,
|
||||
status?: number
|
||||
): T {
|
||||
if (result.error || result.data === undefined) {
|
||||
error(status ?? result.response?.status ?? 502, message);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export function unwrapOrFail(
|
||||
result: { error?: unknown },
|
||||
message: string
|
||||
): ActionFailure<{ message: string }> | undefined {
|
||||
return result.error ? fail(502, { message }) : undefined;
|
||||
}
|
||||
|
||||
export async function playlistCoverTrackIds(
|
||||
api: ReturnType<typeof createApiClient>,
|
||||
playlistIds: string[]
|
||||
): Promise<Record<string, string[]>> {
|
||||
const covers = await Promise.all(
|
||||
playlistIds.map(async (playlistId) => {
|
||||
const { data: tracks } = await api.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId }, query: { pageNumber: 1, pageSize: 4 } }
|
||||
});
|
||||
return [playlistId, tracks?.items.map((t) => t.id) ?? []] as const;
|
||||
})
|
||||
);
|
||||
return Object.fromEntries(covers);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as client from 'openid-client';
|
||||
import { EncryptJWT, jwtDecrypt } from 'jose';
|
||||
import { dev } from '$app/environment';
|
||||
import { authConfig } from '$lib/server/config';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
|
||||
export const SESSION_COOKIE = 'mf_session';
|
||||
export const STATE_COOKIE = 'mf_oidc_state';
|
||||
export const VERIFIER_COOKIE = 'mf_oidc_verifier';
|
||||
export const NONCE_COOKIE = 'mf_oidc_nonce';
|
||||
export const RETURN_COOKIE = 'mf_oidc_return';
|
||||
|
||||
export interface Session extends SessionUser {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
idToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let configPromise: Promise<client.Configuration>;
|
||||
export function getOidcConfig(): Promise<client.Configuration> {
|
||||
configPromise ??= (async () => {
|
||||
const issuer = new URL(authConfig.issuer);
|
||||
const insecureAllowed = (dev || authConfig.allowInsecureHttp) && issuer.protocol === 'http:';
|
||||
const options = insecureAllowed ? { execute: [client.allowInsecureRequests] } : undefined;
|
||||
|
||||
const secret = authConfig.clientSecret;
|
||||
return secret
|
||||
? client.discovery(issuer, authConfig.clientId, secret, undefined, options)
|
||||
: client.discovery(issuer, authConfig.clientId, undefined, client.None(), options);
|
||||
})();
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
let keyPromise: Promise<Uint8Array>;
|
||||
function sessionKey(): Promise<Uint8Array> {
|
||||
keyPromise ??= crypto.subtle
|
||||
.digest('SHA-256', new TextEncoder().encode(authConfig.sessionSecret))
|
||||
.then((digest) => new Uint8Array(digest));
|
||||
return keyPromise;
|
||||
}
|
||||
|
||||
export function sessionCookieOptions(url: URL) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure: url.protocol === 'https:',
|
||||
path: '/',
|
||||
maxAge: authConfig.sessionTtlSeconds
|
||||
};
|
||||
}
|
||||
|
||||
export async function encodeSession(session: Session): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return new EncryptJWT({ ...session })
|
||||
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + authConfig.sessionTtlSeconds)
|
||||
.encrypt(await sessionKey());
|
||||
}
|
||||
|
||||
export async function decodeSession(token: string | undefined): Promise<Session | null> {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const { payload } = await jwtDecrypt(token, await sessionKey());
|
||||
return payload as unknown as Session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function claimString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function toSessionUser(
|
||||
claims: Record<string, unknown>,
|
||||
fallback?: SessionUser
|
||||
): SessionUser {
|
||||
const sub = claimString(claims.sub) ?? fallback?.sub ?? '';
|
||||
return {
|
||||
sub,
|
||||
name:
|
||||
claimString(claims.name) ??
|
||||
claimString(claims.preferred_username) ??
|
||||
fallback?.name ??
|
||||
claimString(claims.email) ??
|
||||
sub,
|
||||
email: claimString(claims.email) ?? fallback?.email ?? '',
|
||||
picture: claimString(claims.picture) ?? fallback?.picture ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshSession(session: Session): Promise<Session | null> {
|
||||
try {
|
||||
const config = await getOidcConfig();
|
||||
const tokens = await client.refreshTokenGrant(config, session.refreshToken);
|
||||
return {
|
||||
...toSessionUser(tokens.claims() ?? {}, session),
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token ?? session.refreshToken,
|
||||
idToken: tokens.id_token ?? session.idToken,
|
||||
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
|
||||
};
|
||||
} catch (exception) {
|
||||
console.error('Token refresh failed', exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const DAY_SECONDS = 60 * 60 * 24;
|
||||
const DEFAULT_OIDC_SCOPE = 'openid profile email offline_access';
|
||||
|
||||
function positiveInt(value: string | undefined, fallback: number): number {
|
||||
if (value === undefined || value === '') return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export const authConfig = {
|
||||
get issuer() {
|
||||
return env.ZITADEL_ISSUER;
|
||||
},
|
||||
get clientId() {
|
||||
return env.ZITADEL_CLIENT_ID;
|
||||
},
|
||||
get clientSecret(): string | undefined {
|
||||
const secret = env.ZITADEL_CLIENT_SECRET;
|
||||
return secret && !secret.startsWith('REPLACE_') ? secret : undefined;
|
||||
},
|
||||
get scope() {
|
||||
return env.OIDC_SCOPE || DEFAULT_OIDC_SCOPE;
|
||||
},
|
||||
get redirectUri() {
|
||||
return env.AUTH_REDIRECT_URI;
|
||||
},
|
||||
get postLogoutUri() {
|
||||
return env.AUTH_POST_LOGOUT_URI;
|
||||
},
|
||||
get sessionSecret() {
|
||||
return env.SESSION_SECRET;
|
||||
},
|
||||
get sessionTtlSeconds() {
|
||||
return positiveInt(env.SESSION_TTL_SECONDS, 7 * DAY_SECONDS);
|
||||
},
|
||||
get refreshThresholdSeconds() {
|
||||
return positiveInt(env.SESSION_REFRESH_THRESHOLD_SECONDS, 30);
|
||||
},
|
||||
get flowTtlSeconds() {
|
||||
return positiveInt(env.AUTH_FLOW_TTL_SECONDS, 600);
|
||||
},
|
||||
get allowInsecureHttp() {
|
||||
return env.AUTH_ALLOW_INSECURE_HTTP === 'true';
|
||||
}
|
||||
};
|
||||
|
||||
export const apiConfig = {
|
||||
get baseUrl() {
|
||||
return (env.API_BASE_URL ?? '').replace(/\/+$/, '');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
const GREETINGS_BY_HOUR: { until: number; pool: string[] }[] = [
|
||||
{
|
||||
until: 6,
|
||||
pool: ['¿Aún despierto?', 'La noche también tiene banda sonora', 'Un ratito más, con música']
|
||||
},
|
||||
{
|
||||
until: 12,
|
||||
pool: ['Buenos días', 'Que empiece bien la mañana', 'Un nuevo día, una nueva lista']
|
||||
},
|
||||
{ until: 14, pool: ['Buenos días', 'El mediodía sabe mejor con música'] },
|
||||
{
|
||||
until: 19,
|
||||
pool: ['Buenas tardes', 'La tarde pide su banda sonora', 'Un respiro, con algo de música']
|
||||
},
|
||||
{ until: 23, pool: ['Buenas noches', 'Hora de bajar el ritmo', 'Que la noche te acompañe'] },
|
||||
{ until: 24, pool: ['¿Aún despierto?', 'La noche también tiene banda sonora'] }
|
||||
];
|
||||
|
||||
export function pickGreeting(hour: number): string {
|
||||
const pool =
|
||||
GREETINGS_BY_HOUR.find((slot) => hour < slot.until)?.pool ?? GREETINGS_BY_HOUR[0].pool;
|
||||
const greeting = pool[Math.floor(Math.random() * pool.length)];
|
||||
return /[.!?]$/.test(greeting) ? greeting : `${greeting}.`;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { apiConfig } from '$lib/server/config';
|
||||
|
||||
const ALLOWED_SIZES = new Set(['small', 'medium', 'large']);
|
||||
|
||||
export async function proxyCoverImage(
|
||||
fetch: typeof globalThis.fetch,
|
||||
kind: 'playlists' | 'tracks',
|
||||
id: string,
|
||||
size: string | null
|
||||
): Promise<Response> {
|
||||
const safeSize = size && ALLOWED_SIZES.has(size) ? size : 'medium';
|
||||
|
||||
const upstream = await fetch(`${apiConfig.baseUrl}/${kind}/${id}/cover?size=${safeSize}`);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return new Response(null, { status: upstream.status });
|
||||
}
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': upstream.headers.get('content-type') ?? 'image/webp',
|
||||
'Cache-Control': 'public, max-age=86400'
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { fail, type RequestEvent } from '@sveltejs/kit';
|
||||
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
|
||||
|
||||
export async function addTrackAction({ 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 trackId = String(form.get('trackId') ?? '');
|
||||
|
||||
if (!playlistId || !trackId) {
|
||||
return fail(400, { message: 'Faltan datos de la canción.' });
|
||||
}
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', {
|
||||
params: { path: { playlistId, trackId } }
|
||||
});
|
||||
|
||||
if (err) {
|
||||
if (response?.status === 409) {
|
||||
return fail(409, { message: 'La canción ya está en esa playlist.', trackId });
|
||||
}
|
||||
return fail(502, { message: 'No se pudo añadir la canción.', trackId });
|
||||
}
|
||||
|
||||
return { added: true, trackId, playlistId };
|
||||
}
|
||||
|
||||
export async function addYouTubeToPlaylistAction({ 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 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 });
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const CONNECT_HOST = env.S3_UPLOAD_CONNECT_HOST || '127.0.0.1';
|
||||
|
||||
export const IMAGE_TYPES: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp'
|
||||
};
|
||||
|
||||
export const AUDIO_TYPES: Record<string, string> = {
|
||||
mp3: 'audio/mpeg',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
flac: 'audio/flac',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
opus: 'audio/opus'
|
||||
};
|
||||
|
||||
export function extOf(name: string): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function contentTypeOf(file: File, table: Record<string, string> = IMAGE_TYPES): string {
|
||||
if (file.type) return file.type;
|
||||
return table[extOf(file.name)] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
export async function putPresigned(
|
||||
presignedUrl: string,
|
||||
body: Buffer,
|
||||
contentType: string
|
||||
): Promise<void> {
|
||||
const url = new URL(presignedUrl);
|
||||
const signed = new Set(
|
||||
(url.searchParams.get('X-Amz-SignedHeaders') ?? 'host').toLowerCase().split(';')
|
||||
);
|
||||
|
||||
const headers: Record<string, string | number> = {
|
||||
Host: url.host,
|
||||
'Content-Length': body.byteLength
|
||||
};
|
||||
if (signed.has('content-type')) headers['Content-Type'] = contentType;
|
||||
if (signed.has('if-none-match')) headers['If-None-Match'] = '*';
|
||||
|
||||
const secure = url.protocol === 'https:';
|
||||
const transport = secure ? https : http;
|
||||
const port = url.port || (secure ? 443 : 80);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = transport.request(
|
||||
{
|
||||
host: CONNECT_HOST,
|
||||
port,
|
||||
method: 'PUT',
|
||||
path: url.pathname + url.search,
|
||||
headers
|
||||
},
|
||||
(response) => {
|
||||
let raw = '';
|
||||
response.on('data', (chunk) => (raw += chunk));
|
||||
response.on('end', () => {
|
||||
const status = response.statusCode ?? 0;
|
||||
if (status >= 200 && status < 300) resolve();
|
||||
else reject(new Error(`S3 PUT ${status}: ${raw.slice(0, 200)}`));
|
||||
});
|
||||
}
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.write(body);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string };
|
||||
|
||||
export async function uploadPresignedImage(
|
||||
reserve: (args: { fileType: string; contentType: string; expectedSizeBytes: number }) => Promise<{
|
||||
data?: { uploadUrl: string; contentType: string; intentId: string } | null;
|
||||
error?: unknown;
|
||||
}>,
|
||||
file: File
|
||||
): Promise<UploadImageResult> {
|
||||
const { data: reserved, error: reserveError } = await reserve({
|
||||
fileType: extOf(file.name) || 'jpg',
|
||||
contentType: contentTypeOf(file),
|
||||
expectedSizeBytes: file.size
|
||||
});
|
||||
|
||||
if (reserveError || !reserved) {
|
||||
return { failMessage: 'No se pudo reservar la subida de la portada.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
await putPresigned(reserved.uploadUrl, Buffer.from(buffer), reserved.contentType);
|
||||
} catch (err) {
|
||||
return {
|
||||
failMessage: 'Falló la subida de la portada al almacenamiento.',
|
||||
detail: err instanceof Error ? err.message : undefined
|
||||
};
|
||||
}
|
||||
|
||||
return { intentId: reserved.intentId };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { createApiClient } from '$lib/server/api';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
|
||||
export const YOUTUBE_FILLER_QUERIES = [
|
||||
'pop',
|
||||
'rock',
|
||||
'reggaeton',
|
||||
'lo-fi',
|
||||
'indie',
|
||||
'salsa',
|
||||
'k-pop',
|
||||
'electrónica',
|
||||
'jazz',
|
||||
'baladas',
|
||||
'trap',
|
||||
'cumbia',
|
||||
'hip hop',
|
||||
'música clásica'
|
||||
];
|
||||
|
||||
export interface YoutubeFiller {
|
||||
query: string;
|
||||
items: YouTubeSong[];
|
||||
continuationToken: string;
|
||||
}
|
||||
|
||||
export function pickRandomFillerQuery(): string {
|
||||
return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)];
|
||||
}
|
||||
|
||||
export function shuffle<T>(items: T[]): T[] {
|
||||
const copy = [...items];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
export async function fetchYoutubeFiller(
|
||||
api: ReturnType<typeof createApiClient>,
|
||||
accessToken: string | null | undefined,
|
||||
limit: number,
|
||||
seed?: string
|
||||
): Promise<YoutubeFiller> {
|
||||
if (!accessToken) return { query: '', items: [], continuationToken: '' };
|
||||
const query = seed?.trim() || pickRandomFillerQuery();
|
||||
const { data } = await api.GET('/youtube/search', { params: { query: { query } } });
|
||||
return {
|
||||
query,
|
||||
items: ((data?.items ?? []) as YouTubeSong[]).slice(0, limit),
|
||||
continuationToken: data?.continuationToken ?? ''
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user