This commit is contained in:
parent
1ae040cdd3
commit
0f9679ca2f
@ -82,6 +82,18 @@ app.get('/api/metadata', async (c) => {
|
|||||||
const s = video.snippet || {}
|
const s = video.snippet || {}
|
||||||
const thumbs = s.thumbnails || {}
|
const thumbs = s.thumbnails || {}
|
||||||
const thumb = thumbs.maxres?.url || thumbs.standard?.url || thumbs.high?.url || thumbs.medium?.url || thumbs.default?.url || ''
|
const thumb = thumbs.maxres?.url || thumbs.standard?.url || thumbs.high?.url || thumbs.medium?.url || thumbs.default?.url || ''
|
||||||
|
|
||||||
|
// Fetch channel avatar
|
||||||
|
let authorAvatar = ''
|
||||||
|
if (s.channelId) {
|
||||||
|
const chRes = await fetch(`https://www.googleapis.com/youtube/v3/channels?part=snippet&id=${s.channelId}&key=${YOUTUBE_API_KEY}`)
|
||||||
|
if (chRes.ok) {
|
||||||
|
const chData = await chRes.json()
|
||||||
|
const chThumbs = chData.items?.[0]?.snippet?.thumbnails || {}
|
||||||
|
authorAvatar = chThumbs.medium?.url || chThumbs.default?.url || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
title: s.title || '',
|
title: s.title || '',
|
||||||
description: (s.description || '').slice(0, 500),
|
description: (s.description || '').slice(0, 500),
|
||||||
@ -90,6 +102,7 @@ app.get('/api/metadata', async (c) => {
|
|||||||
audioUrl: url,
|
audioUrl: url,
|
||||||
platform: 'YouTube',
|
platform: 'YouTube',
|
||||||
author: s.channelTitle || '',
|
author: s.channelTitle || '',
|
||||||
|
authorAvatar,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -106,6 +119,7 @@ app.get('/api/metadata', async (c) => {
|
|||||||
audioUrl: url,
|
audioUrl: url,
|
||||||
platform: 'YouTube',
|
platform: 'YouTube',
|
||||||
author: data.author_name || '',
|
author: data.author_name || '',
|
||||||
|
authorAvatar: '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -129,6 +143,7 @@ app.get('/api/metadata', async (c) => {
|
|||||||
|
|
||||||
if (contentType === 'episode') {
|
if (contentType === 'episode') {
|
||||||
const images = data.images || data.show?.images || []
|
const images = data.images || data.show?.images || []
|
||||||
|
const showImages = data.show?.images || []
|
||||||
return c.json({
|
return c.json({
|
||||||
title: data.name || '',
|
title: data.name || '',
|
||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
@ -137,21 +152,36 @@ app.get('/api/metadata', async (c) => {
|
|||||||
audioUrl: url,
|
audioUrl: url,
|
||||||
platform: 'Spotify',
|
platform: 'Spotify',
|
||||||
author: data.show?.name || data.show?.publisher || '',
|
author: data.show?.name || data.show?.publisher || '',
|
||||||
|
authorAvatar: showImages[showImages.length - 1]?.url || showImages[0]?.url || '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (contentType === 'show') {
|
if (contentType === 'show') {
|
||||||
|
const showImages = data.images || []
|
||||||
return c.json({
|
return c.json({
|
||||||
title: data.name || '',
|
title: data.name || '',
|
||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
thumbnail: (data.images || [])[0]?.url || '',
|
thumbnail: showImages[0]?.url || '',
|
||||||
duration: 0,
|
duration: 0,
|
||||||
audioUrl: url,
|
audioUrl: url,
|
||||||
platform: 'Spotify',
|
platform: 'Spotify',
|
||||||
author: data.publisher || '',
|
author: data.publisher || '',
|
||||||
|
authorAvatar: showImages[showImages.length - 1]?.url || showImages[0]?.url || '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (contentType === 'track') {
|
if (contentType === 'track') {
|
||||||
const artist = data.artists?.map((a: any) => a.name).join(', ') || ''
|
const artist = data.artists?.map((a: any) => a.name).join(', ') || ''
|
||||||
|
// Fetch first artist's image
|
||||||
|
let authorAvatar = ''
|
||||||
|
if (data.artists?.[0]?.id && token) {
|
||||||
|
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()
|
||||||
|
const artImages = artData.images || []
|
||||||
|
authorAvatar = artImages[artImages.length - 1]?.url || artImages[0]?.url || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
return c.json({
|
return c.json({
|
||||||
title: data.name || '',
|
title: data.name || '',
|
||||||
description: artist ? `Par ${artist}` : '',
|
description: artist ? `Par ${artist}` : '',
|
||||||
@ -160,6 +190,7 @@ app.get('/api/metadata', async (c) => {
|
|||||||
audioUrl: url,
|
audioUrl: url,
|
||||||
platform: 'Spotify',
|
platform: 'Spotify',
|
||||||
author: artist,
|
author: artist,
|
||||||
|
authorAvatar,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -177,13 +208,14 @@ app.get('/api/metadata', async (c) => {
|
|||||||
audioUrl: url,
|
audioUrl: url,
|
||||||
platform: 'Spotify',
|
platform: 'Spotify',
|
||||||
author: parts.length > 1 ? parts[parts.length - 1]?.trim() : '',
|
author: parts.length > 1 ? parts[parts.length - 1]?.trim() : '',
|
||||||
|
authorAvatar: '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dailymotion ──
|
// ── Dailymotion ──
|
||||||
if (info.platform === 'dailymotion') {
|
if (info.platform === 'dailymotion') {
|
||||||
const apiUrl = `https://api.dailymotion.com/video/${info.id}?fields=title,description,duration,thumbnail_720_url,owner.screenname`
|
const apiUrl = `https://api.dailymotion.com/video/${info.id}?fields=title,description,duration,thumbnail_720_url,owner.screenname,owner.avatar_720_url`
|
||||||
const res = await fetch(apiUrl)
|
const res = await fetch(apiUrl)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@ -196,6 +228,7 @@ app.get('/api/metadata', async (c) => {
|
|||||||
audioUrl: url,
|
audioUrl: url,
|
||||||
platform: 'Dailymotion',
|
platform: 'Dailymotion',
|
||||||
author: owner,
|
author: owner,
|
||||||
|
authorAvatar: data.owner?.avatar_720_url || '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -213,6 +246,7 @@ app.get('/api/metadata', async (c) => {
|
|||||||
audioUrl: url,
|
audioUrl: url,
|
||||||
platform: 'SoundCloud',
|
platform: 'SoundCloud',
|
||||||
author: data.author_name || '',
|
author: data.author_name || '',
|
||||||
|
authorAvatar: '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
97
scripts/backfill-authors-sql.ts
Normal file
97
scripts/backfill-authors-sql.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Generate SQL UPDATE statements to backfill external_author and external_author_avatar.
|
||||||
|
* Outputs SQL that you can paste into the Supabase SQL Editor.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* 1. Start the API: cd api && bun run dev
|
||||||
|
* 2. Run: bun run scripts/backfill-authors-sql.ts
|
||||||
|
* 3. Copy the output SQL and paste into Supabase SQL Editor
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SUPABASE_URL = process.env.VITE_SUPABASE_URL || ''
|
||||||
|
const SUPABASE_KEY = process.env.VITE_SUPABASE_ANON_KEY || ''
|
||||||
|
const API_URL = process.env.VITE_API_URL || 'http://localhost:3001'
|
||||||
|
|
||||||
|
if (!SUPABASE_URL || !SUPABASE_KEY) {
|
||||||
|
console.error('Missing VITE_SUPABASE_URL or VITE_SUPABASE_ANON_KEY in .env')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExternalUrl(url: string): boolean {
|
||||||
|
return (
|
||||||
|
url.includes('youtube.com') ||
|
||||||
|
url.includes('youtu.be') ||
|
||||||
|
url.includes('spotify.com') ||
|
||||||
|
url.includes('dailymotion.com') ||
|
||||||
|
url.includes('dai.ly') ||
|
||||||
|
url.includes('soundcloud.com')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeSql(s: string): string {
|
||||||
|
return s.replace(/'/g, "''")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Fetch all podcasts (read-only, anon key is fine)
|
||||||
|
const res = await fetch(`${SUPABASE_URL}/rest/v1/podcasts?select=id,audio_url,external_author,external_author_avatar&order=created_at.desc`, {
|
||||||
|
headers: {
|
||||||
|
apikey: SUPABASE_KEY,
|
||||||
|
Authorization: `Bearer ${SUPABASE_KEY}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('Failed to fetch podcasts:', await res.text())
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const podcasts: { id: string; audio_url: string; external_author: string | null; external_author_avatar: string | null }[] = await res.json()
|
||||||
|
const toUpdate = podcasts.filter(p => isExternalUrl(p.audio_url) && (!p.external_author || !p.external_author_avatar))
|
||||||
|
|
||||||
|
console.error(`Found ${podcasts.length} total podcasts, ${toUpdate.length} external to backfill\n`)
|
||||||
|
|
||||||
|
if (toUpdate.length === 0) {
|
||||||
|
console.error('Nothing to do!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const statements: string[] = []
|
||||||
|
|
||||||
|
for (const podcast of toUpdate) {
|
||||||
|
try {
|
||||||
|
const metaRes = await fetch(`${API_URL}/api/metadata?url=${encodeURIComponent(podcast.audio_url)}`)
|
||||||
|
if (!metaRes.ok) {
|
||||||
|
console.error(` SKIP ${podcast.id} — API returned ${metaRes.status}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await metaRes.json()
|
||||||
|
if (!meta.author && !meta.authorAvatar) {
|
||||||
|
console.error(` SKIP ${podcast.id} — no author data`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const sets: string[] = []
|
||||||
|
if (meta.author) sets.push(`external_author = '${escapeSql(meta.author)}'`)
|
||||||
|
if (meta.authorAvatar) sets.push(`external_author_avatar = '${escapeSql(meta.authorAvatar)}'`)
|
||||||
|
|
||||||
|
if (sets.length > 0) {
|
||||||
|
statements.push(`UPDATE public.podcasts SET ${sets.join(', ')} WHERE id = '${podcast.id}';`)
|
||||||
|
console.error(` OK ${podcast.id} — ${meta.author} ${meta.authorAvatar ? '(+avatar)' : '(no avatar)'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 200))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` ERR ${podcast.id} — ${err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output the SQL to stdout
|
||||||
|
console.log('-- Backfill external authors and avatars')
|
||||||
|
console.log('-- Generated by scripts/backfill-authors-sql.ts\n')
|
||||||
|
console.log(statements.join('\n'))
|
||||||
|
console.error(`\nGenerated ${statements.length} UPDATE statements. Paste the output into Supabase SQL Editor.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
122
scripts/backfill-authors.ts
Normal file
122
scripts/backfill-authors.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Backfill external_author and external_author_avatar for existing external podcasts.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* cd podcast-us
|
||||||
|
* bun run scripts/backfill-authors.ts
|
||||||
|
*
|
||||||
|
* Requires:
|
||||||
|
* - API server running on localhost:3001 (cd api && bun run dev)
|
||||||
|
* - .env with VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SUPABASE_URL = process.env.VITE_SUPABASE_URL || ''
|
||||||
|
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.VITE_SUPABASE_ANON_KEY || ''
|
||||||
|
const API_URL = process.env.VITE_API_URL || 'http://localhost:3001'
|
||||||
|
|
||||||
|
if (!SUPABASE_URL || !SUPABASE_KEY) {
|
||||||
|
console.error('Missing VITE_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.SUPABASE_SERVICE_ROLE_KEY) {
|
||||||
|
console.warn('⚠ No SUPABASE_SERVICE_ROLE_KEY found — using anon key. Updates may fail due to RLS.')
|
||||||
|
console.warn(' Set SUPABASE_SERVICE_ROLE_KEY=... in .env or pass it as env var.\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patterns that indicate an external URL (not a Supabase storage URL)
|
||||||
|
function isExternalUrl(url: string): boolean {
|
||||||
|
return (
|
||||||
|
url.includes('youtube.com') ||
|
||||||
|
url.includes('youtu.be') ||
|
||||||
|
url.includes('spotify.com') ||
|
||||||
|
url.includes('dailymotion.com') ||
|
||||||
|
url.includes('dai.ly') ||
|
||||||
|
url.includes('soundcloud.com')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function supabaseFetch(path: string, options?: RequestInit) {
|
||||||
|
const res = await fetch(`${SUPABASE_URL}/rest/v1${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
apikey: SUPABASE_KEY,
|
||||||
|
Authorization: `Bearer ${SUPABASE_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: options?.method === 'PATCH' ? 'return=minimal' : 'return=representation',
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// 1. Fetch all podcasts
|
||||||
|
const res = await supabaseFetch('/podcasts?select=id,audio_url,external_author,external_author_avatar&order=created_at.desc')
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('Failed to fetch podcasts:', await res.text())
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const podcasts: { id: string; audio_url: string; external_author: string | null; external_author_avatar: string | null }[] = await res.json()
|
||||||
|
|
||||||
|
// 2. Filter external podcasts that need backfill
|
||||||
|
const toUpdate = podcasts.filter(p => isExternalUrl(p.audio_url) && (!p.external_author || !p.external_author_avatar))
|
||||||
|
|
||||||
|
console.log(`Found ${podcasts.length} total podcasts, ${toUpdate.length} external podcasts to backfill\n`)
|
||||||
|
|
||||||
|
if (toUpdate.length === 0) {
|
||||||
|
console.log('Nothing to do!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let success = 0
|
||||||
|
let failed = 0
|
||||||
|
|
||||||
|
for (const podcast of toUpdate) {
|
||||||
|
try {
|
||||||
|
// 3. Fetch metadata from API
|
||||||
|
const metaRes = await fetch(`${API_URL}/api/metadata?url=${encodeURIComponent(podcast.audio_url)}`)
|
||||||
|
if (!metaRes.ok) {
|
||||||
|
console.log(` SKIP ${podcast.id} — API returned ${metaRes.status} for ${podcast.audio_url}`)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await metaRes.json()
|
||||||
|
|
||||||
|
if (!meta.author && !meta.authorAvatar) {
|
||||||
|
console.log(` SKIP ${podcast.id} — no author data from API`)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Update podcast
|
||||||
|
const updateRes = await supabaseFetch(`/podcasts?id=eq.${podcast.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({
|
||||||
|
external_author: meta.author || null,
|
||||||
|
external_author_avatar: meta.authorAvatar || null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (updateRes.ok) {
|
||||||
|
console.log(` OK ${podcast.id} — ${meta.author} ${meta.authorAvatar ? '(+avatar)' : '(no avatar)'}`)
|
||||||
|
success++
|
||||||
|
} else {
|
||||||
|
console.log(` FAIL ${podcast.id} — DB update failed: ${await updateRes.text()}`)
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit: small delay between requests
|
||||||
|
await new Promise(r => setTimeout(r, 200))
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ERR ${podcast.id} — ${err}`)
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone! ${success} updated, ${failed} failed/skipped`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@ -97,7 +97,14 @@ export function PodcastCard({ podcast, progressPercent: propProgress }: PodcastC
|
|||||||
</h3>
|
</h3>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{podcast.creator && (
|
{podcast.external_author ? (
|
||||||
|
<div className="flex items-center gap-1.5 mt-2">
|
||||||
|
{podcast.external_author_avatar && (
|
||||||
|
<img src={podcast.external_author_avatar} alt="" referrerPolicy="no-referrer" className="w-5 h-5 rounded-full object-cover ring-1 ring-border" />
|
||||||
|
)}
|
||||||
|
<span className="text-[11px] text-text-secondary font-medium">{podcast.external_author}</span>
|
||||||
|
</div>
|
||||||
|
) : podcast.creator && (
|
||||||
<Link to={`/profile/${podcast.creator.username}`} className="flex items-center gap-1.5 mt-2">
|
<Link to={`/profile/${podcast.creator.username}`} className="flex items-center gap-1.5 mt-2">
|
||||||
<Avatar src={podcast.creator.avatar_url} name={podcast.creator.username} size="sm" className="!w-5 !h-5 !text-[8px] !ring-1" />
|
<Avatar src={podcast.creator.avatar_url} name={podcast.creator.username} size="sm" className="!w-5 !h-5 !text-[8px] !ring-1" />
|
||||||
<span className="text-[11px] text-text-secondary font-medium hover:text-primary transition-colors">{podcast.creator.username}</span>
|
<span className="text-[11px] text-text-secondary font-medium hover:text-primary transition-colors">{podcast.creator.username}</span>
|
||||||
|
|||||||
@ -123,7 +123,14 @@ export function PodcastDetail() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h1 className="text-2xl font-heading font-bold">{podcast.title}</h1>
|
<h1 className="text-2xl font-heading font-bold">{podcast.title}</h1>
|
||||||
|
|
||||||
{podcast.creator && (
|
{podcast.external_author ? (
|
||||||
|
<div className="flex items-center gap-2 mt-3">
|
||||||
|
{podcast.external_author_avatar && (
|
||||||
|
<img src={podcast.external_author_avatar} alt="" referrerPolicy="no-referrer" className="w-8 h-8 rounded-full object-cover ring-2 ring-border" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium text-text-secondary">{podcast.external_author}</span>
|
||||||
|
</div>
|
||||||
|
) : podcast.creator && (
|
||||||
<Link to={`/profile/${podcast.creator.username}`} className="flex items-center gap-2 mt-3">
|
<Link to={`/profile/${podcast.creator.username}`} className="flex items-center gap-2 mt-3">
|
||||||
<Avatar src={podcast.creator.avatar_url} name={podcast.creator.username} size="sm" />
|
<Avatar src={podcast.creator.avatar_url} name={podcast.creator.username} size="sm" />
|
||||||
<span className="text-sm font-medium hover:text-primary transition-colors">{podcast.creator.username}</span>
|
<span className="text-sm font-medium hover:text-primary transition-colors">{podcast.creator.username}</span>
|
||||||
|
|||||||
@ -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, Sparkles, Circle, Square, MicOff, ShieldAlert } from 'lucide-react'
|
import { Upload as UploadIcon, Music, X, Image, Mic, Link2, Loader2, Circle, Square, MicOff, ShieldAlert } 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'
|
||||||
@ -16,6 +16,7 @@ interface OEmbedData {
|
|||||||
audioUrl: string
|
audioUrl: string
|
||||||
platform: string
|
platform: string
|
||||||
author: string
|
author: string
|
||||||
|
authorAvatar: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||||
@ -62,6 +63,7 @@ export function Upload() {
|
|||||||
const [fetching, setFetching] = useState(false)
|
const [fetching, setFetching] = useState(false)
|
||||||
const [platform, setPlatform] = useState('')
|
const [platform, setPlatform] = useState('')
|
||||||
const [author, setAuthor] = useState('')
|
const [author, setAuthor] = useState('')
|
||||||
|
const [authorAvatar, setAuthorAvatar] = useState('')
|
||||||
|
|
||||||
// Check microphone permission when entering original mode
|
// Check microphone permission when entering original mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -170,26 +172,47 @@ export function Upload() {
|
|||||||
setCoverPreview(URL.createObjectURL(file))
|
setCoverPreview(URL.createObjectURL(file))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFetchUrl() {
|
// Auto-fetch metadata when URL changes (debounced)
|
||||||
if (!externalUrl.trim()) return
|
useEffect(() => {
|
||||||
setFetching(true)
|
if (mode !== 'external') return
|
||||||
setError('')
|
const url = externalUrl.trim()
|
||||||
|
if (!url) {
|
||||||
const meta = await fetchVideoMeta(externalUrl.trim())
|
setPlatform('')
|
||||||
if (!meta) {
|
setCoverPreview(null)
|
||||||
setError('URL non reconnue. Formats supportes : YouTube, Dailymotion, SoundCloud.')
|
setTitle('')
|
||||||
setFetching(false)
|
setDescription('')
|
||||||
|
setDuration(0)
|
||||||
|
setAuthor('')
|
||||||
|
setAuthorAvatar('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meta.title && !title) setTitle(meta.title)
|
// Basic URL validation before fetching
|
||||||
if (meta.description && !description) setDescription(meta.description)
|
if (!url.startsWith('http://') && !url.startsWith('https://')) return
|
||||||
if (meta.thumbnail) setCoverPreview(meta.thumbnail)
|
|
||||||
if (meta.duration) setDuration(meta.duration)
|
const timeout = setTimeout(async () => {
|
||||||
if (meta.author) setAuthor(meta.author)
|
setFetching(true)
|
||||||
setPlatform(meta.platform)
|
setError('')
|
||||||
setFetching(false)
|
|
||||||
}
|
const meta = await fetchVideoMeta(url)
|
||||||
|
if (!meta) {
|
||||||
|
setError('URL non reconnue. Formats supportes : YouTube, Spotify, Dailymotion, SoundCloud.')
|
||||||
|
setFetching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle(meta.title || '')
|
||||||
|
setDescription(meta.description || '')
|
||||||
|
if (meta.thumbnail) setCoverPreview(meta.thumbnail)
|
||||||
|
if (meta.duration) setDuration(meta.duration)
|
||||||
|
if (meta.author) setAuthor(meta.author)
|
||||||
|
if (meta.authorAvatar) setAuthorAvatar(meta.authorAvatar)
|
||||||
|
setPlatform(meta.platform)
|
||||||
|
setFetching(false)
|
||||||
|
}, 600)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [externalUrl, mode])
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -241,6 +264,7 @@ export function Upload() {
|
|||||||
audio_url,
|
audio_url,
|
||||||
duration_seconds: duration,
|
duration_seconds: duration,
|
||||||
cover_url,
|
cover_url,
|
||||||
|
...(mode === 'external' && author ? { external_author: author, external_author_avatar: authorAvatar || null } : {}),
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single()
|
.single()
|
||||||
@ -342,6 +366,7 @@ export function Upload() {
|
|||||||
setError('')
|
setError('')
|
||||||
setPlatform('')
|
setPlatform('')
|
||||||
setAuthor('')
|
setAuthor('')
|
||||||
|
setAuthorAvatar('')
|
||||||
}}
|
}}
|
||||||
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"
|
||||||
>
|
>
|
||||||
@ -464,27 +489,19 @@ export function Upload() {
|
|||||||
) : (
|
) : (
|
||||||
/* ---- EXTERNAL: URL input ---- */
|
/* ---- EXTERNAL: URL input ---- */
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex gap-2">
|
<div className="relative">
|
||||||
<div className="flex-1">
|
<Input
|
||||||
<Input
|
id="url"
|
||||||
id="url"
|
label="URL de la video ou du podcast"
|
||||||
label="URL de la video ou du podcast"
|
value={externalUrl}
|
||||||
value={externalUrl}
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
placeholder="https://youtube.com/watch?v=... ou https://open.spotify.com/episode/..."
|
||||||
placeholder="https://youtube.com/watch?v=... ou https://open.spotify.com/episode/..."
|
/>
|
||||||
/>
|
{fetching && (
|
||||||
</div>
|
<div className="absolute right-3 bottom-3">
|
||||||
<div className="flex items-end">
|
<Loader2 size={18} className="animate-spin text-text-secondary" />
|
||||||
<Button
|
</div>
|
||||||
type="button"
|
)}
|
||||||
variant="secondary"
|
|
||||||
onClick={handleFetchUrl}
|
|
||||||
disabled={!externalUrl.trim() || fetching}
|
|
||||||
className="h-[46px]"
|
|
||||||
>
|
|
||||||
{fetching ? <Loader2 size={18} className="animate-spin" /> : <Sparkles size={18} />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{platform && coverPreview && (
|
{platform && coverPreview && (
|
||||||
@ -511,8 +528,9 @@ export function Upload() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Shared fields */}
|
{/* Shared fields — hidden in external mode until metadata is fetched */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-6">
|
{(mode === 'original' || platform) && (
|
||||||
|
<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" />
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@ -551,19 +569,22 @@ export function Upload() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-accent">{error}</p>}
|
{error && <p className="text-sm text-accent">{error}</p>}
|
||||||
|
|
||||||
<Button
|
{(mode === 'original' || platform) && (
|
||||||
type="submit"
|
<Button
|
||||||
size="lg"
|
type="submit"
|
||||||
className="w-full"
|
size="lg"
|
||||||
disabled={
|
className="w-full"
|
||||||
(mode === 'original' ? !audioFile : !externalUrl.trim()) || !title || uploading
|
disabled={
|
||||||
}
|
(mode === 'original' ? !audioFile : !externalUrl.trim()) || !title || uploading
|
||||||
>
|
}
|
||||||
{uploading ? 'Publication en cours...' : 'Publier le podcast'}
|
>
|
||||||
</Button>
|
{uploading ? 'Publication en cours...' : 'Publier le podcast'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -17,6 +17,8 @@ export interface Podcast {
|
|||||||
audio_url: string
|
audio_url: string
|
||||||
duration_seconds: number
|
duration_seconds: number
|
||||||
cover_url: string | null
|
cover_url: string | null
|
||||||
|
external_author: string | null
|
||||||
|
external_author_avatar: string | null
|
||||||
plays_count: number
|
plays_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
creator?: Profile
|
creator?: Profile
|
||||||
|
|||||||
4
supabase/migrations/004_external_author.sql
Normal file
4
supabase/migrations/004_external_author.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-- Store original content creator info for external podcasts (YouTube channel, Spotify artist, etc.)
|
||||||
|
ALTER TABLE public.podcasts
|
||||||
|
ADD COLUMN IF NOT EXISTS external_author text,
|
||||||
|
ADD COLUMN IF NOT EXISTS external_author_avatar text;
|
||||||
Loading…
x
Reference in New Issue
Block a user