From 569b5a5846054b2ae025a7c57cb471318dbc4b91 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 13 Apr 2026 11:20:47 +0200 Subject: [PATCH] add a little api --- .env.example | 1 + api/.env.example | 4 + api/bun.lock | 15 ++ api/package.json | 12 ++ api/src/index.ts | 234 ++++++++++++++++++++++++++++ docker-compose.dev.yml | 12 ++ src/components/layout/PlayerBar.tsx | 17 +- src/lib/embed.ts | 16 +- src/pages/Upload.tsx | 143 +---------------- src/stores/player.ts | 6 + 10 files changed, 321 insertions(+), 139 deletions(-) create mode 100644 api/.env.example create mode 100644 api/bun.lock create mode 100644 api/package.json create mode 100644 api/src/index.ts diff --git a/.env.example b/.env.example index eec9922..b3e6633 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ VITE_SUPABASE_URL=https://your-project.supabase.co VITE_SUPABASE_ANON_KEY=your-anon-key +VITE_API_URL=http://localhost:3001 diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..c685e93 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,4 @@ +SPOTIFY_CLIENT_ID= +SPOTIFY_CLIENT_SECRET= +YOUTUBE_API_KEY= +PORT=3001 diff --git a/api/bun.lock b/api/bun.lock new file mode 100644 index 0000000..848a322 --- /dev/null +++ b/api/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "wetalk-api", + "dependencies": { + "hono": "^4.7.0", + }, + }, + }, + "packages": { + "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..e49b07a --- /dev/null +++ b/api/package.json @@ -0,0 +1,12 @@ +{ + "name": "wetalk-api", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "bun run --hot src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "hono": "^4.7.0" + } +} diff --git a/api/src/index.ts b/api/src/index.ts new file mode 100644 index 0000000..c6066c2 --- /dev/null +++ b/api/src/index.ts @@ -0,0 +1,234 @@ +import { Hono } from 'hono' +import { cors } from 'hono/cors' + +const app = new Hono() + +app.use('*', cors({ + origin: '*', + allowMethods: ['GET', 'POST'], +})) + +// ── Spotify token cache ── +let spotifyToken: string | null = null +let spotifyTokenExpiry = 0 + +const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID || '' +const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET || '' +const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY || '' + +async function getSpotifyToken(): Promise { + if (spotifyToken && Date.now() < spotifyTokenExpiry) return spotifyToken + if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) return null + + const res = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${btoa(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`)}`, + }, + body: 'grant_type=client_credentials', + }) + if (!res.ok) return null + const data = await res.json() + spotifyToken = data.access_token + spotifyTokenExpiry = Date.now() + (data.expires_in - 60) * 1000 + return spotifyToken +} + +// ── URL parsing helpers ── +function parseDuration(iso: string): number { + const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/) + if (!match) return 0 + return parseInt(match[1] || '0') * 3600 + parseInt(match[2] || '0') * 60 + parseInt(match[3] || '0') +} + +function extractInfo(url: string): { platform: string; id: string; type?: string } | null { + // YouTube + const ytMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/) + if (ytMatch) return { platform: 'youtube', id: ytMatch[1] } + + // Dailymotion + const dmMatch = url.match(/(?:dailymotion\.com\/video\/|dai\.ly\/)([a-zA-Z0-9]+)/) + if (dmMatch) return { platform: 'dailymotion', id: dmMatch[1] } + + // SoundCloud + if (url.includes('soundcloud.com/')) return { platform: 'soundcloud', id: url } + + // Spotify + const spotifyMatch = url.match(/open\.spotify\.com\/(episode|show|track)\/([a-zA-Z0-9]+)/) + if (spotifyMatch) return { platform: 'spotify', id: spotifyMatch[2], type: spotifyMatch[1] } + + return null +} + +// ── Main endpoint ── +app.get('/api/metadata', async (c) => { + const url = c.req.query('url') + if (!url) return c.json({ error: 'Missing url parameter' }, 400) + + const info = extractInfo(url) + if (!info) return c.json({ error: 'Unsupported URL' }, 400) + + try { + // ── YouTube ── + if (info.platform === 'youtube') { + if (YOUTUBE_API_KEY) { + const apiUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=${info.id}&key=${YOUTUBE_API_KEY}` + const res = await fetch(apiUrl) + if (res.ok) { + const data = await res.json() + const video = data.items?.[0] + if (video) { + const s = video.snippet || {} + const thumbs = s.thumbnails || {} + const thumb = thumbs.maxres?.url || thumbs.standard?.url || thumbs.high?.url || thumbs.medium?.url || thumbs.default?.url || '' + return c.json({ + title: s.title || '', + description: (s.description || '').slice(0, 500), + thumbnail: thumb, + duration: parseDuration(video.contentDetails?.duration || ''), + audioUrl: url, + platform: 'YouTube', + author: s.channelTitle || '', + }) + } + } + } + // Fallback oEmbed + const res = await fetch(`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${info.id}&format=json`) + if (res.ok) { + const data = await res.json() + return c.json({ + title: data.title || '', + description: data.author_name ? `Par ${data.author_name}` : '', + thumbnail: `https://img.youtube.com/vi/${info.id}/maxresdefault.jpg`, + duration: 0, + audioUrl: url, + platform: 'YouTube', + author: data.author_name || '', + }) + } + } + + // ── Spotify ── + if (info.platform === 'spotify') { + const token = await getSpotifyToken() + if (token) { + const contentType = info.type || 'episode' + const endpoint = contentType === 'show' + ? `https://api.spotify.com/v1/shows/${info.id}?market=FR` + : contentType === 'track' + ? `https://api.spotify.com/v1/tracks/${info.id}?market=FR` + : `https://api.spotify.com/v1/episodes/${info.id}?market=FR` + + const res = await fetch(endpoint, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + const data = await res.json() + + if (contentType === 'episode') { + const images = data.images || data.show?.images || [] + return c.json({ + title: data.name || '', + description: data.description || '', + thumbnail: images[0]?.url || '', + duration: Math.floor((data.duration_ms || 0) / 1000), + audioUrl: url, + platform: 'Spotify', + author: data.show?.name || data.show?.publisher || '', + }) + } + if (contentType === 'show') { + return c.json({ + title: data.name || '', + description: data.description || '', + thumbnail: (data.images || [])[0]?.url || '', + duration: 0, + audioUrl: url, + platform: 'Spotify', + author: data.publisher || '', + }) + } + if (contentType === 'track') { + const artist = data.artists?.map((a: any) => a.name).join(', ') || '' + return c.json({ + title: data.name || '', + description: artist ? `Par ${artist}` : '', + thumbnail: (data.album?.images || [])[0]?.url || '', + duration: Math.floor((data.duration_ms || 0) / 1000), + audioUrl: url, + platform: 'Spotify', + author: artist, + }) + } + } + } + // Fallback oEmbed + const res = await fetch(`https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`) + if (res.ok) { + const data = await res.json() + const parts = (data.title || '').split(' - ') + return c.json({ + title: parts[0]?.trim() || data.title || '', + description: '', + thumbnail: data.thumbnail_url || '', + duration: 0, + audioUrl: url, + platform: 'Spotify', + author: parts.length > 1 ? parts[parts.length - 1]?.trim() : '', + }) + } + } + + // ── Dailymotion ── + if (info.platform === 'dailymotion') { + const apiUrl = `https://api.dailymotion.com/video/${info.id}?fields=title,description,duration,thumbnail_720_url,owner.screenname` + const res = await fetch(apiUrl) + if (res.ok) { + const data = await res.json() + const owner = data.owner?.screenname || '' + return c.json({ + title: data.title || '', + description: data.description || '', + thumbnail: data.thumbnail_720_url || '', + duration: data.duration || 0, + audioUrl: url, + platform: 'Dailymotion', + author: owner, + }) + } + } + + // ── SoundCloud ── + if (info.platform === 'soundcloud') { + const res = await fetch(`https://soundcloud.com/oembed?url=${encodeURIComponent(url)}&format=json`) + if (res.ok) { + const data = await res.json() + return c.json({ + title: data.title || '', + description: data.description || '', + thumbnail: data.thumbnail_url || '', + duration: 0, + audioUrl: url, + platform: 'SoundCloud', + author: data.author_name || '', + }) + } + } + + return c.json({ error: 'Could not fetch metadata' }, 404) + } catch (e) { + return c.json({ error: 'Internal error' }, 500) + } +}) + +app.get('/health', (c) => c.json({ ok: true })) + +const port = parseInt(process.env.PORT || '3001') +console.log(`[wetalk-api] listening on :${port}`) + +export default { + port, + fetch: app.fetch, +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 377276f..bad1e16 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -12,3 +12,15 @@ services: - .env environment: - CHOKIDAR_USEPOLLING=true + + api: + image: oven/bun:1 + working_dir: /app + ports: + - "3001:3001" + volumes: + - ./api:/app + - /app/node_modules + env_file: + - ./api/.env + command: bun run src/index.ts diff --git a/src/components/layout/PlayerBar.tsx b/src/components/layout/PlayerBar.tsx index d32a10b..628712f 100644 --- a/src/components/layout/PlayerBar.tsx +++ b/src/components/layout/PlayerBar.tsx @@ -21,7 +21,9 @@ export function PlayerBar() { if (!current) return null - const isYouTube = isExternal && current && isExternalUrl(current.audio_url) && getEmbedInfo(current.audio_url)?.platform === 'youtube' + const embedInfo = isExternal && current ? getEmbedInfo(current.audio_url) : null + const isYouTube = embedInfo?.platform === 'youtube' + const isSpotify = embedInfo?.platform === 'spotify' const pct = duration > 0 ? (progress / duration) * 100 : 0 const displayPct = isDragging ? dragPct : pct // Allow seeking for native audio and YouTube (via IFrame API) @@ -97,6 +99,19 @@ export function PlayerBar() { )} + {/* Spotify player - mini floating */} + {isSpotify && embedInfo && ( +
+