add playlist
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 39s

This commit is contained in:
ordinarthur 2026-04-13 16:23:23 +02:00
parent 2fbccfac10
commit 4c8dd1cc52
2 changed files with 380 additions and 6 deletions

View File

@ -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<string, number> = {}
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 })) app.get('/health', (c) => c.json({ ok: true }))
const port = parseInt(process.env.PORT || '3001') const port = parseInt(process.env.PORT || '3001')

View File

@ -1,6 +1,6 @@
import { useState, useRef, useCallback, useEffect } from 'react' import { useState, useRef, useCallback, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' 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 { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@ -20,8 +20,25 @@ interface OEmbedData {
authorAvatar: string 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' 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<OEmbedData | null> { async function fetchVideoMeta(url: string): Promise<OEmbedData | null> {
try { try {
const res = await fetch(`${API_URL}/api/metadata?url=${encodeURIComponent(url)}`) const res = await fetch(`${API_URL}/api/metadata?url=${encodeURIComponent(url)}`)
@ -32,6 +49,16 @@ async function fetchVideoMeta(url: string): Promise<OEmbedData | null> {
} }
} }
async function fetchPlaylistMeta(url: string): Promise<PlaylistData | null> {
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() { export function Upload() {
const { user } = useAuthStore() const { user } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
@ -75,6 +102,12 @@ export function Upload() {
const [author, setAuthor] = useState('') const [author, setAuthor] = useState('')
const [authorAvatar, setAuthorAvatar] = useState('') const [authorAvatar, setAuthorAvatar] = useState('')
// Playlist mode
const [playlist, setPlaylist] = useState<PlaylistData | null>(null)
const [selectedTracks, setSelectedTracks] = useState<Set<number>>(new Set())
const [importingPlaylist, setImportingPlaylist] = useState(false)
const [importProgress, setImportProgress] = useState(0)
// Fetch user's shows for the show selector // Fetch user's shows for the show selector
useEffect(() => { useEffect(() => {
if (!user || mode !== 'original') return if (!user || mode !== 'original') return
@ -209,6 +242,8 @@ export function Upload() {
setDuration(0) setDuration(0)
setAuthor('') setAuthor('')
setAuthorAvatar('') setAuthorAvatar('')
setPlaylist(null)
setSelectedTracks(new Set())
return return
} }
@ -218,7 +253,22 @@ export function Upload() {
const timeout = setTimeout(async () => { const timeout = setTimeout(async () => {
setFetching(true) setFetching(true)
setError('') 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) const meta = await fetchVideoMeta(url)
if (!meta) { if (!meta) {
setError('URL non reconnue. Formats supportes : YouTube, Spotify, Dailymotion, SoundCloud.') setError('URL non reconnue. Formats supportes : YouTube, Spotify, Dailymotion, SoundCloud.')
@ -239,6 +289,42 @@ export function Upload() {
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [externalUrl, mode]) }, [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) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!user) return if (!user) return
@ -379,7 +465,7 @@ export function Upload() {
</div> </div>
<h3 className="font-heading font-bold text-lg mb-1.5">Contenu existant</h3> <h3 className="font-heading font-bold text-lg mb-1.5">Contenu existant</h3>
<p className="text-sm text-text-secondary leading-relaxed"> <p className="text-sm text-text-secondary leading-relaxed">
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.
</p> </p>
<div className="flex gap-1.5 mt-4"> <div className="flex gap-1.5 mt-4">
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">YouTube</span> <span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">YouTube</span>
@ -412,6 +498,8 @@ export function Upload() {
setPlatform('') setPlatform('')
setAuthor('') setAuthor('')
setAuthorAvatar('') setAuthorAvatar('')
setPlaylist(null)
setSelectedTracks(new Set())
}} }}
className="text-sm text-text-secondary hover:text-text transition-colors cursor-pointer" className="text-sm text-text-secondary hover:text-text transition-colors cursor-pointer"
> >
@ -549,7 +637,107 @@ export function Upload() {
)} )}
</div> </div>
{platform && coverPreview && ( {/* Playlist preview */}
{playlist && (
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 bg-primary/5 rounded-xl border border-primary/20">
{playlist.thumbnail && (
<img src={playlist.thumbnail} alt="" className="w-16 h-16 rounded-lg object-cover shrink-0" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{playlist.name}</p>
<div className="flex items-center gap-2 mt-0.5 text-xs text-text-secondary">
{playlist.author && <span className="truncate">{playlist.author}</span>}
<span>·</span>
<span>{playlist.tracks.length} titres</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-primary/10 text-primary">
<ListMusic size={10} className="inline -mt-0.5 mr-0.5" />{playlist.platform} Playlist
</span>
</div>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-secondary font-medium">{selectedTracks.size}/{playlist.tracks.length} selectionnes</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => setSelectedTracks(new Set(playlist.tracks.map((_, i) => i)))}
className="text-xs text-primary hover:text-primary-hover font-medium cursor-pointer"
>
Tout selectionner
</button>
<button
type="button"
onClick={() => setSelectedTracks(new Set())}
className="text-xs text-text-secondary hover:text-text font-medium cursor-pointer"
>
Tout deselectionner
</button>
</div>
</div>
<div className="max-h-80 overflow-y-auto rounded-xl border border-border divide-y divide-border">
{playlist.tracks.map((track, i) => (
<label
key={i}
className={`flex items-center gap-3 p-2.5 cursor-pointer transition-colors ${selectedTracks.has(i) ? 'bg-primary/[0.03]' : 'bg-surface opacity-50'}`}
>
<input
type="checkbox"
checked={selectedTracks.has(i)}
onChange={() => {
const next = new Set(selectedTracks)
if (next.has(i)) next.delete(i)
else next.add(i)
setSelectedTracks(next)
}}
className="accent-primary shrink-0"
/>
{track.thumbnail && (
<img src={track.thumbnail} alt="" className="w-10 h-10 rounded object-cover shrink-0" />
)}
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium truncate">{track.title}</p>
<div className="flex items-center gap-2 text-[11px] text-text-secondary">
{track.author && <span className="truncate">{track.author}</span>}
{track.duration > 0 && (
<span className="shrink-0">{Math.floor(track.duration / 60)}:{(track.duration % 60).toString().padStart(2, '0')}</span>
)}
</div>
</div>
</label>
))}
</div>
{importingPlaylist && (
<div className="space-y-1.5">
<div className="h-2 rounded-full bg-border overflow-hidden">
<div className="h-full bg-primary rounded-full transition-all duration-300" style={{ width: `${importProgress}%` }} />
</div>
<p className="text-xs text-text-secondary text-center">Import en cours... {importProgress}%</p>
</div>
)}
<Button
type="button"
size="lg"
className="w-full"
disabled={selectedTracks.size === 0 || importingPlaylist}
onClick={handlePlaylistImport}
>
{importingPlaylist
? `Import en cours... ${importProgress}%`
: `Importer ${selectedTracks.size} podcast${selectedTracks.size > 1 ? 's' : ''}`
}
</Button>
</div>
)}
{/* Single track preview */}
{!playlist && platform && coverPreview && (
<div className="flex items-center gap-3 p-3 bg-mint/5 rounded-xl border border-mint/20"> <div className="flex items-center gap-3 p-3 bg-mint/5 rounded-xl border border-mint/20">
<img src={coverPreview} alt="" className="w-16 h-16 rounded-lg object-cover shrink-0" /> <img src={coverPreview} alt="" className="w-16 h-16 rounded-lg object-cover shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@ -573,8 +761,8 @@ export function Upload() {
</div> </div>
)} )}
{/* Shared fields — hidden in external mode until metadata is fetched */} {/* Shared fields — hidden in external mode until metadata is fetched, hidden for playlists */}
{(mode === 'original' || platform) && ( {!playlist && (mode === 'original' || platform) && (
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-6"> <div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-6">
<div className="space-y-4"> <div className="space-y-4">
<Input id="title" label="Titre" value={title} onChange={(e) => setTitle(e.target.value)} required placeholder="Le titre de votre podcast" /> <Input id="title" label="Titre" value={title} onChange={(e) => setTitle(e.target.value)} required placeholder="Le titre de votre podcast" />
@ -723,7 +911,7 @@ export function Upload() {
{error && <p className="text-sm text-accent">{error}</p>} {error && <p className="text-sm text-accent">{error}</p>}
{(mode === 'original' || platform) && ( {!playlist && (mode === 'original' || platform) && (
<Button <Button
type="submit" type="submit"
size="lg" size="lg"