From 0f9679ca2f441426ed8ab5e7cdae45e00776bad3 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 13 Apr 2026 11:45:06 +0200 Subject: [PATCH] add good author --- api/src/index.ts | 38 +++++- scripts/backfill-authors-sql.ts | 97 +++++++++++++++ scripts/backfill-authors.ts | 122 +++++++++++++++++++ src/components/podcast/PodcastCard.tsx | 9 +- src/pages/PodcastDetail.tsx | 9 +- src/pages/Upload.tsx | 123 ++++++++++++-------- src/types/index.ts | 2 + supabase/migrations/004_external_author.sql | 4 + 8 files changed, 349 insertions(+), 55 deletions(-) create mode 100644 scripts/backfill-authors-sql.ts create mode 100644 scripts/backfill-authors.ts create mode 100644 supabase/migrations/004_external_author.sql diff --git a/api/src/index.ts b/api/src/index.ts index c6066c2..fdd5286 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -82,6 +82,18 @@ app.get('/api/metadata', async (c) => { 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 || '' + + // 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({ title: s.title || '', description: (s.description || '').slice(0, 500), @@ -90,6 +102,7 @@ app.get('/api/metadata', async (c) => { audioUrl: url, platform: 'YouTube', author: s.channelTitle || '', + authorAvatar, }) } } @@ -106,6 +119,7 @@ app.get('/api/metadata', async (c) => { audioUrl: url, platform: 'YouTube', author: data.author_name || '', + authorAvatar: '', }) } } @@ -129,6 +143,7 @@ app.get('/api/metadata', async (c) => { if (contentType === 'episode') { const images = data.images || data.show?.images || [] + const showImages = data.show?.images || [] return c.json({ title: data.name || '', description: data.description || '', @@ -137,21 +152,36 @@ app.get('/api/metadata', async (c) => { audioUrl: url, platform: 'Spotify', author: data.show?.name || data.show?.publisher || '', + authorAvatar: showImages[showImages.length - 1]?.url || showImages[0]?.url || '', }) } if (contentType === 'show') { + const showImages = data.images || [] return c.json({ title: data.name || '', description: data.description || '', - thumbnail: (data.images || [])[0]?.url || '', + thumbnail: showImages[0]?.url || '', duration: 0, audioUrl: url, platform: 'Spotify', author: data.publisher || '', + authorAvatar: showImages[showImages.length - 1]?.url || showImages[0]?.url || '', }) } if (contentType === 'track') { 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({ title: data.name || '', description: artist ? `Par ${artist}` : '', @@ -160,6 +190,7 @@ app.get('/api/metadata', async (c) => { audioUrl: url, platform: 'Spotify', author: artist, + authorAvatar, }) } } @@ -177,13 +208,14 @@ app.get('/api/metadata', async (c) => { audioUrl: url, platform: 'Spotify', author: parts.length > 1 ? parts[parts.length - 1]?.trim() : '', + authorAvatar: '', }) } } // ── 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) if (res.ok) { const data = await res.json() @@ -196,6 +228,7 @@ app.get('/api/metadata', async (c) => { audioUrl: url, platform: 'Dailymotion', author: owner, + authorAvatar: data.owner?.avatar_720_url || '', }) } } @@ -213,6 +246,7 @@ app.get('/api/metadata', async (c) => { audioUrl: url, platform: 'SoundCloud', author: data.author_name || '', + authorAvatar: '', }) } } diff --git a/scripts/backfill-authors-sql.ts b/scripts/backfill-authors-sql.ts new file mode 100644 index 0000000..d5b3913 --- /dev/null +++ b/scripts/backfill-authors-sql.ts @@ -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() diff --git a/scripts/backfill-authors.ts b/scripts/backfill-authors.ts new file mode 100644 index 0000000..5ce421d --- /dev/null +++ b/scripts/backfill-authors.ts @@ -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() diff --git a/src/components/podcast/PodcastCard.tsx b/src/components/podcast/PodcastCard.tsx index 46ff985..fa7be48 100644 --- a/src/components/podcast/PodcastCard.tsx +++ b/src/components/podcast/PodcastCard.tsx @@ -97,7 +97,14 @@ export function PodcastCard({ podcast, progressPercent: propProgress }: PodcastC - {podcast.creator && ( + {podcast.external_author ? ( +
+ {podcast.external_author_avatar && ( + + )} + {podcast.external_author} +
+ ) : podcast.creator && ( {podcast.creator.username} diff --git a/src/pages/PodcastDetail.tsx b/src/pages/PodcastDetail.tsx index 63d4e0c..3fc5544 100644 --- a/src/pages/PodcastDetail.tsx +++ b/src/pages/PodcastDetail.tsx @@ -123,7 +123,14 @@ export function PodcastDetail() {

{podcast.title}

- {podcast.creator && ( + {podcast.external_author ? ( +
+ {podcast.external_author_avatar && ( + + )} + {podcast.external_author} +
+ ) : podcast.creator && ( {podcast.creator.username} diff --git a/src/pages/Upload.tsx b/src/pages/Upload.tsx index 91745e2..268eb07 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, 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 { useAuthStore } from '@/stores/auth' import { Button } from '@/components/ui/Button' @@ -16,6 +16,7 @@ interface OEmbedData { audioUrl: string platform: string author: string + authorAvatar: string } 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 [platform, setPlatform] = useState('') const [author, setAuthor] = useState('') + const [authorAvatar, setAuthorAvatar] = useState('') // Check microphone permission when entering original mode useEffect(() => { @@ -170,26 +172,47 @@ export function Upload() { setCoverPreview(URL.createObjectURL(file)) } - async function handleFetchUrl() { - if (!externalUrl.trim()) return - setFetching(true) - setError('') - - const meta = await fetchVideoMeta(externalUrl.trim()) - if (!meta) { - setError('URL non reconnue. Formats supportes : YouTube, Dailymotion, SoundCloud.') - setFetching(false) + // Auto-fetch metadata when URL changes (debounced) + useEffect(() => { + if (mode !== 'external') return + const url = externalUrl.trim() + if (!url) { + setPlatform('') + setCoverPreview(null) + setTitle('') + setDescription('') + setDuration(0) + setAuthor('') + setAuthorAvatar('') return } - if (meta.title && !title) setTitle(meta.title) - if (meta.description && !description) setDescription(meta.description) - if (meta.thumbnail) setCoverPreview(meta.thumbnail) - if (meta.duration) setDuration(meta.duration) - if (meta.author) setAuthor(meta.author) - setPlatform(meta.platform) - setFetching(false) - } + // Basic URL validation before fetching + if (!url.startsWith('http://') && !url.startsWith('https://')) return + + const timeout = setTimeout(async () => { + setFetching(true) + setError('') + + 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) { e.preventDefault() @@ -241,6 +264,7 @@ export function Upload() { audio_url, duration_seconds: duration, cover_url, + ...(mode === 'external' && author ? { external_author: author, external_author_avatar: authorAvatar || null } : {}), }) .select() .single() @@ -342,6 +366,7 @@ export function Upload() { setError('') setPlatform('') setAuthor('') + setAuthorAvatar('') }} className="text-sm text-text-secondary hover:text-text transition-colors cursor-pointer" > @@ -464,27 +489,19 @@ export function Upload() { ) : ( /* ---- EXTERNAL: URL input ---- */
-
-
- setExternalUrl(e.target.value)} - placeholder="https://youtube.com/watch?v=... ou https://open.spotify.com/episode/..." - /> -
-
- -
+
+ setExternalUrl(e.target.value)} + placeholder="https://youtube.com/watch?v=... ou https://open.spotify.com/episode/..." + /> + {fetching && ( +
+ +
+ )}
{platform && coverPreview && ( @@ -511,8 +528,9 @@ export function Upload() {
)} - {/* Shared fields */} -
+ {/* Shared fields — hidden in external mode until metadata is fetched */} + {(mode === 'original' || platform) && ( +
setTitle(e.target.value)} required placeholder="Le titre de votre podcast" />
@@ -551,19 +569,22 @@ export function Upload() { )}
+ )} {error &&

{error}

} - + {(mode === 'original' || platform) && ( + + )}
) diff --git a/src/types/index.ts b/src/types/index.ts index ea48ee8..eacbd9f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -17,6 +17,8 @@ export interface Podcast { audio_url: string duration_seconds: number cover_url: string | null + external_author: string | null + external_author_avatar: string | null plays_count: number created_at: string creator?: Profile diff --git a/supabase/migrations/004_external_author.sql b/supabase/migrations/004_external_author.sql new file mode 100644 index 0000000..f26b22f --- /dev/null +++ b/supabase/migrations/004_external_author.sql @@ -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;