This commit is contained in:
parent
2fbccfac10
commit
4c8dd1cc52
186
api/src/index.ts
186
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<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 }))
|
||||
|
||||
const port = parseInt(process.env.PORT || '3001')
|
||||
|
||||
@ -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<OEmbedData | null> {
|
||||
try {
|
||||
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() {
|
||||
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<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
|
||||
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() {
|
||||
</div>
|
||||
<h3 className="font-heading font-bold text-lg mb-1.5">Contenu existant</h3>
|
||||
<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>
|
||||
<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>
|
||||
@ -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() {
|
||||
)}
|
||||
</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">
|
||||
<img src={coverPreview} alt="" className="w-16 h-16 rounded-lg object-cover shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
@ -573,8 +761,8 @@ export function Upload() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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) && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-6">
|
||||
<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" />
|
||||
@ -723,7 +911,7 @@ export function Upload() {
|
||||
|
||||
{error && <p className="text-sm text-accent">{error}</p>}
|
||||
|
||||
{(mode === 'original' || platform) && (
|
||||
{!playlist && (mode === 'original' || platform) && (
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user