Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/danielroe/roe.dev. This is the code and content for my personal website, built in Nuxt. roe.dev
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172import { useRuntimeConfig } from 'nuxt/server'
import { getValidYouTubeAccessToken } from '../youtube/auth'
export interface YouTubeMetadata { title: string description: string hashtags: string[]}
// Title is the (truncated) question; hashtags blend a baseline set with// topic-specific tags matched against a small keyword list.export function generateVideoMetadata (question: string, answer: string): YouTubeMetadata { const title = question.length > 47 ? question.slice(0, 47) + '…' : question
const contentText = `${question} ${answer}`.toLowerCase() const baseHashtags = ['AMA', 'QandA', 'Tech', 'Programming', 'Developer'] const contextHashtags: string[] = [] const techKeywords: Record<string, string[]> = { nuxt: ['Nuxt', 'NuxtJS'], vue: ['Vue', 'VueJS'], javascript: ['JavaScript', 'JS'], typescript: ['TypeScript', 'TS'], node: ['NodeJS', 'Node'], react: ['React', 'ReactJS'], css: ['CSS', 'Styling'], performance: ['Performance', 'Optimization'], security: ['Security', 'Auth'], database: ['Database', 'DB'], api: ['API', 'Backend'], frontend: ['Frontend', 'UI'], fullstack: ['FullStack', 'WebDev'], opensource: ['OpenSource', 'OSS'], git: ['Git', 'GitHub'], deployment: ['Deployment', 'DevOps'], testing: ['Testing', 'QA'], ai: ['AI', 'MachineLearning'], mobile: ['Mobile', 'App'], web: ['WebDevelopment', 'Web'], } for (const [keyword, tags] of Object.entries(techKeywords)) { if (contentText.includes(keyword)) contextHashtags.push(...tags.slice(0, 1)) } const allHashtags = [...baseHashtags, ...contextHashtags].slice(0, 8)
const shortAnswer = answer.length > 100 ? answer.slice(0, 97) + '…' : answer const hashtagString = allHashtags.map(t => `#${t}`).join(' ') const description = `Q: ${question}\n\nA: ${shortAnswer}\n\nroe.dev/ama\n\n${hashtagString}`
return { title, description, hashtags: allHashtags }}
interface UploadOptions { accessToken: string videoBuffer: Uint8Array videoMimeType: string title: string description: string categoryId: string tags: string[] privacyStatus: 'public' | 'private' | 'unlisted' playlistId?: string}
async function uploadToYouTube (options: UploadOptions): Promise<{ videoId: string }> { const { accessToken, videoBuffer, videoMimeType, title, description, categoryId, tags, privacyStatus, playlistId } = options
const metadata = { snippet: { title, description, tags, categoryId }, status: { privacyStatus }, }
const boundary = `----formdata-${Date.now()}` const delimiter = '\r\n--' + boundary + '\r\n' const closeDelimiter = '\r\n--' + boundary + '--'
let body = delimiter body += 'Content-Type: application/json; charset=UTF-8\r\n\r\n' body += JSON.stringify(metadata) body += delimiter body += `Content-Type: ${videoMimeType}\r\n\r\n`
const bodyStart = new TextEncoder().encode(body) const bodyEnd = new TextEncoder().encode(closeDelimiter) const fullBody = new Uint8Array(bodyStart.length + videoBuffer.length + bodyEnd.length) fullBody.set(bodyStart, 0) fullBody.set(videoBuffer, bodyStart.length) fullBody.set(bodyEnd, bodyStart.length + videoBuffer.length)
const response = await fetch('https://www.googleapis.com/upload/youtube/v3/videos?uploadType=multipart&part=snippet,status', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': `multipart/related; boundary=${boundary}`, }, body: fullBody, })
if (!response.ok) { const errorText = await response.text() throw new Error(`YouTube API error: ${response.status} ${response.statusText} - ${errorText}`) }
const result = await response.json() as { id?: string } if (!result.id) throw new Error('YouTube upload failed: No video ID returned')
if (playlistId) { try { await addVideoToPlaylist(accessToken, result.id, playlistId) } catch (error) { console.warn('[youtube] playlist add failed:', error) } }
return { videoId: result.id }}
async function addVideoToPlaylist (accessToken: string, videoId: string, playlistId: string): Promise<void> { const playlistItem = { snippet: { playlistId, resourceId: { kind: 'youtube#video', videoId }, }, }
const response = await fetch('https://www.googleapis.com/youtube/v3/playlistItems?part=snippet', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(playlistItem), })
if (!response.ok) { const errorText = await response.text() throw new Error(`YouTube playlist API error: ${response.status} ${response.statusText} - ${errorText}`) }}
export interface PublishYouTubeOptions { question: string answer: string videoBuffer: Uint8Array videoMimeType: string}
export async function publishYouTubeShorts (options: PublishYouTubeOptions): Promise<{ url: string }> { const config = useRuntimeConfig() const accessToken = await getValidYouTubeAccessToken()
const meta = generateVideoMetadata(options.question, options.answer) const title = meta.title.length > 100 ? meta.title.slice(0, 97) + '…' : meta.title const shortAnswer = options.answer.length > 200 ? options.answer.slice(0, 200) + '…' : options.answer const description = `Q: ${options.question}\n\nA: ${shortAnswer}\n\n🔗 roe.dev/ama\n\n${meta.hashtags.map(t => `#${t}`).join(' ')}`
const { videoId } = await uploadToYouTube({ accessToken, videoBuffer: options.videoBuffer, videoMimeType: options.videoMimeType, title, description, categoryId: '28', // Science & Technology tags: meta.hashtags, privacyStatus: 'public', playlistId: config.youtube.amaPlaylistId, })
return { url: `https://www.youtube.com/watch?v=${videoId}` }}