diff --git a/api/src/index.ts b/api/src/index.ts index c623f3e..b8ab7f2 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -257,6 +257,192 @@ app.get('/api/metadata', async (c) => { } }) +// ── Playlist endpoint ── +function extractPlaylistInfo(url: string): { platform: string; id: string; type?: string } | null { + // YouTube playlist + const ytPlaylist = url.match(/[?&]list=([a-zA-Z0-9_-]+)/) + if (ytPlaylist && url.includes('youtube.com')) return { platform: 'youtube', id: ytPlaylist[1] } + const ytPlaylistDirect = url.match(/youtube\.com\/playlist\?list=([a-zA-Z0-9_-]+)/) + if (ytPlaylistDirect) return { platform: 'youtube', id: ytPlaylistDirect[1] } + + // Spotify playlist or album + const spotifyPlaylist = url.match(/open\.spotify\.com\/(playlist|album)\/([a-zA-Z0-9]+)/) + if (spotifyPlaylist) return { platform: 'spotify', id: spotifyPlaylist[2], type: spotifyPlaylist[1] } + + return null +} + +app.get('/api/playlist', async (c) => { + const url = c.req.query('url') + if (!url) return c.json({ error: 'Missing url parameter' }, 400) + + const info = extractPlaylistInfo(url) + if (!info) return c.json({ error: 'Not a playlist URL' }, 400) + + try { + // ── YouTube playlist ── + if (info.platform === 'youtube') { + if (!YOUTUBE_API_KEY) return c.json({ error: 'YouTube API key not configured' }, 500) + + // Fetch playlist metadata + const plRes = await fetch(`https://www.googleapis.com/youtube/v3/playlists?part=snippet&id=${info.id}&key=${YOUTUBE_API_KEY}`) + const plData = plRes.ok ? await plRes.json() : null + const plSnippet = plData?.items?.[0]?.snippet || {} + + // Fetch channel avatar + let channelAvatar = '' + if (plSnippet.channelId) { + const chRes = await fetch(`https://www.googleapis.com/youtube/v3/channels?part=snippet&id=${plSnippet.channelId}&key=${YOUTUBE_API_KEY}`) + if (chRes.ok) { + const chData = await chRes.json() + const chThumbs = chData.items?.[0]?.snippet?.thumbnails || {} + channelAvatar = chThumbs.medium?.url || chThumbs.default?.url || '' + } + } + + // Fetch all playlist items (paginated, max 200) + const items: any[] = [] + let pageToken = '' + for (let i = 0; i < 4; i++) { // max 4 pages = 200 items + const piUrl = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails&playlistId=${info.id}&maxResults=50&key=${YOUTUBE_API_KEY}${pageToken ? `&pageToken=${pageToken}` : ''}` + const piRes = await fetch(piUrl) + if (!piRes.ok) break + const piData = await piRes.json() + items.push(...(piData.items || [])) + if (!piData.nextPageToken) break + pageToken = piData.nextPageToken + } + + // Fetch video durations in batches of 50 + const videoIds = items.map((i: any) => i.contentDetails?.videoId).filter(Boolean) + const durations: Record = {} + for (let i = 0; i < videoIds.length; i += 50) { + const batch = videoIds.slice(i, i + 50).join(',') + const vRes = await fetch(`https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${batch}&key=${YOUTUBE_API_KEY}`) + if (vRes.ok) { + const vData = await vRes.json() + for (const v of vData.items || []) { + durations[v.id] = parseDuration(v.contentDetails?.duration || '') + } + } + } + + const tracks = items + .filter((i: any) => i.snippet?.title !== 'Private video' && i.snippet?.title !== 'Deleted video') + .map((i: any) => { + const s = i.snippet || {} + const videoId = i.contentDetails?.videoId || '' + const thumbs = s.thumbnails || {} + return { + title: s.title || '', + description: (s.description || '').slice(0, 300), + thumbnail: thumbs.maxres?.url || thumbs.standard?.url || thumbs.high?.url || thumbs.medium?.url || thumbs.default?.url || '', + duration: durations[videoId] || 0, + audioUrl: `https://www.youtube.com/watch?v=${videoId}`, + platform: 'YouTube', + author: plSnippet.channelTitle || s.channelTitle || '', + authorAvatar: channelAvatar, + } + }) + + return c.json({ + name: plSnippet.title || 'Playlist YouTube', + platform: 'YouTube', + thumbnail: (plSnippet.thumbnails?.maxres || plSnippet.thumbnails?.high || plSnippet.thumbnails?.medium || plSnippet.thumbnails?.default)?.url || '', + author: plSnippet.channelTitle || '', + authorAvatar: channelAvatar, + tracks, + }) + } + + // ── Spotify playlist/album ── + if (info.platform === 'spotify') { + const token = await getSpotifyToken() + if (!token) return c.json({ error: 'Spotify auth failed' }, 500) + + const isAlbum = info.type === 'album' + const endpoint = isAlbum + ? `https://api.spotify.com/v1/albums/${info.id}?market=FR` + : `https://api.spotify.com/v1/playlists/${info.id}?market=FR` + + const res = await fetch(endpoint, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) return c.json({ error: 'Could not fetch playlist' }, 404) + const data = await res.json() + + const playlistImages = data.images || [] + let tracks: any[] + + if (isAlbum) { + const artist = data.artists?.map((a: any) => a.name).join(', ') || '' + let artistAvatar = '' + if (data.artists?.[0]?.id) { + const artRes = await fetch(`https://api.spotify.com/v1/artists/${data.artists[0].id}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (artRes.ok) { + const artData = await artRes.json() + artistAvatar = artData.images?.[artData.images.length - 1]?.url || artData.images?.[0]?.url || '' + } + } + + tracks = (data.tracks?.items || []).map((t: any) => ({ + title: t.name || '', + description: `${artist} — ${data.name || ''}`, + thumbnail: playlistImages[0]?.url || '', + duration: Math.floor((t.duration_ms || 0) / 1000), + audioUrl: `https://open.spotify.com/track/${t.id}`, + platform: 'Spotify', + author: t.artists?.map((a: any) => a.name).join(', ') || artist, + authorAvatar: artistAvatar, + })) + + return c.json({ + name: data.name || 'Album Spotify', + platform: 'Spotify', + thumbnail: playlistImages[0]?.url || '', + author: artist, + authorAvatar: artistAvatar, + tracks, + }) + } else { + // Playlist + tracks = (data.tracks?.items || []) + .filter((i: any) => i.track) + .map((i: any) => { + const t = i.track + const artist = t.artists?.map((a: any) => a.name).join(', ') || '' + const images = t.album?.images || [] + return { + title: t.name || '', + description: artist ? `Par ${artist}` : '', + thumbnail: images[0]?.url || '', + duration: Math.floor((t.duration_ms || 0) / 1000), + audioUrl: `https://open.spotify.com/track/${t.id}`, + platform: 'Spotify', + author: artist, + authorAvatar: '', + } + }) + + return c.json({ + name: data.name || 'Playlist Spotify', + platform: 'Spotify', + thumbnail: playlistImages[0]?.url || '', + author: data.owner?.display_name || '', + authorAvatar: '', + tracks, + }) + } + } + + return c.json({ error: 'Unsupported playlist platform' }, 400) + } catch (e) { + return c.json({ error: 'Internal error' }, 500) + } +}) + app.get('/health', (c) => c.json({ ok: true })) const port = parseInt(process.env.PORT || '3001') diff --git a/src/pages/Upload.tsx b/src/pages/Upload.tsx index f99e56a..753dfde 100644 --- a/src/pages/Upload.tsx +++ b/src/pages/Upload.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { Upload as UploadIcon, Music, X, Image, Mic, Link2, Loader2, Circle, Square, MicOff, ShieldAlert, Plus, Trash2, Calendar } from 'lucide-react' +import { Upload as UploadIcon, Music, X, Image, Mic, Link2, Loader2, Circle, Square, MicOff, ShieldAlert, Plus, Trash2, Calendar, ListMusic, Check } from 'lucide-react' import { supabase } from '@/lib/supabase' import { useAuthStore } from '@/stores/auth' import { Button } from '@/components/ui/Button' @@ -20,8 +20,25 @@ interface OEmbedData { authorAvatar: string } +interface PlaylistData { + name: string + platform: string + thumbnail: string + author: string + authorAvatar: string + tracks: OEmbedData[] +} + const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001' +function isPlaylistUrl(url: string): boolean { + // YouTube playlist + if (url.includes('youtube.com') && url.includes('list=')) return true + // Spotify playlist or album + if (/open\.spotify\.com\/(playlist|album)\//.test(url)) return true + return false +} + async function fetchVideoMeta(url: string): Promise { try { const res = await fetch(`${API_URL}/api/metadata?url=${encodeURIComponent(url)}`) @@ -32,6 +49,16 @@ async function fetchVideoMeta(url: string): Promise { } } +async function fetchPlaylistMeta(url: string): Promise { + try { + const res = await fetch(`${API_URL}/api/playlist?url=${encodeURIComponent(url)}`) + if (!res.ok) return null + return await res.json() + } catch { + return null + } +} + export function Upload() { const { user } = useAuthStore() const navigate = useNavigate() @@ -75,6 +102,12 @@ export function Upload() { const [author, setAuthor] = useState('') const [authorAvatar, setAuthorAvatar] = useState('') + // Playlist mode + const [playlist, setPlaylist] = useState(null) + const [selectedTracks, setSelectedTracks] = useState>(new Set()) + const [importingPlaylist, setImportingPlaylist] = useState(false) + const [importProgress, setImportProgress] = useState(0) + // Fetch user's shows for the show selector useEffect(() => { if (!user || mode !== 'original') return @@ -209,6 +242,8 @@ export function Upload() { setDuration(0) setAuthor('') setAuthorAvatar('') + setPlaylist(null) + setSelectedTracks(new Set()) return } @@ -218,7 +253,22 @@ export function Upload() { const timeout = setTimeout(async () => { setFetching(true) setError('') + setPlaylist(null) + setSelectedTracks(new Set()) + // Check if it's a playlist URL + if (isPlaylistUrl(url)) { + const pl = await fetchPlaylistMeta(url) + if (pl && pl.tracks.length > 0) { + setPlaylist(pl) + setPlatform(pl.platform) + setSelectedTracks(new Set(pl.tracks.map((_, i) => i))) + setFetching(false) + return + } + } + + // Single video/track const meta = await fetchVideoMeta(url) if (!meta) { setError('URL non reconnue. Formats supportes : YouTube, Spotify, Dailymotion, SoundCloud.') @@ -239,6 +289,42 @@ export function Upload() { return () => clearTimeout(timeout) }, [externalUrl, mode]) + async function handlePlaylistImport() { + if (!user || !playlist) return + const WETALK_SYSTEM_ID = 'a1000000-0000-0000-0000-000000000005' + const tracks = playlist.tracks.filter((_, i) => selectedTracks.has(i)) + if (tracks.length === 0) return + + setImportingPlaylist(true) + setImportProgress(0) + setError('') + + let imported = 0 + for (const track of tracks) { + const { error: insertErr } = await supabase + .from('podcasts') + .insert({ + creator_id: WETALK_SYSTEM_ID, + title: track.title, + description: track.description || '', + audio_url: track.audioUrl, + duration_seconds: track.duration || 0, + cover_url: track.thumbnail || null, + external_author: track.author || playlist.author || null, + external_author_avatar: track.authorAvatar || playlist.authorAvatar || null, + }) + if (insertErr) { + console.error('[upload] playlist item failed:', track.title, insertErr) + } else { + imported++ + } + setImportProgress(Math.round(((imported) / tracks.length) * 100)) + } + + setImportingPlaylist(false) + navigate('/') + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!user) return @@ -379,7 +465,7 @@ export function Upload() {

Contenu existant

- Partagez une video ou un podcast deja en ligne. On recupere tout automatiquement. + Partagez une video, un podcast ou une playlist entiere. On recupere tout automatiquement.

YouTube @@ -412,6 +498,8 @@ export function Upload() { setPlatform('') setAuthor('') setAuthorAvatar('') + setPlaylist(null) + setSelectedTracks(new Set()) }} className="text-sm text-text-secondary hover:text-text transition-colors cursor-pointer" > @@ -549,7 +637,107 @@ export function Upload() { )}
- {platform && coverPreview && ( + {/* Playlist preview */} + {playlist && ( +
+
+ {playlist.thumbnail && ( + + )} +
+

{playlist.name}

+
+ {playlist.author && {playlist.author}} + · + {playlist.tracks.length} titres +
+
+ + {playlist.platform} Playlist + +
+
+
+ +
+ {selectedTracks.size}/{playlist.tracks.length} selectionnes +
+ + +
+
+ +
+ {playlist.tracks.map((track, i) => ( + + ))} +
+ + {importingPlaylist && ( +
+
+
+
+

Import en cours... {importProgress}%

+
+ )} + + +
+ )} + + {/* Single track preview */} + {!playlist && platform && coverPreview && (
@@ -573,8 +761,8 @@ export function Upload() {
)} - {/* Shared fields — hidden in external mode until metadata is fetched */} - {(mode === 'original' || platform) && ( + {/* Shared fields — hidden in external mode until metadata is fetched, hidden for playlists */} + {!playlist && (mode === 'original' || platform) && (
setTitle(e.target.value)} required placeholder="Le titre de votre podcast" /> @@ -723,7 +911,7 @@ export function Upload() { {error &&

{error}

} - {(mode === 'original' || platform) && ( + {!playlist && (mode === 'original' || platform) && (