Files
musicland/functions/src/youtube.js
T
2026-01-12 16:01:32 +01:00

295 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { onCall, HttpsError } = require('firebase-functions/v2/https')
const { defineSecret } = require('firebase-functions/params')
const logger = require('firebase-functions/logger')
const functions = require('firebase-functions')
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const axios = require('axios')
const fs = require('node:fs')
const fsp = require('node:fs/promises')
const os = require('node:os')
const path = require('node:path')
const { google } = require('googleapis')
// ---- Secrets déclarés (gen2 + Secret Manager)
const S_YT_CLIENT_ID = defineSecret('YOUTUBE_CLIENT_ID')
const S_YT_CLIENT_SECRET = defineSecret('YOUTUBE_CLIENT_SECRET')
const S_YT_REFRESH_TOKEN = defineSecret('YOUTUBE_REFRESH_TOKEN')
const S_YT_REDIRECT_URI = defineSecret('YOUTUBE_REDIRECT_URI')
const S_YT_PRIVACY_STATUS = defineSecret('YOUTUBE_PRIVACY_STATUS')
const S_YT_CATEGORY_ID = defineSecret('YOUTUBE_CATEGORY_ID')
// Firestore
const firestore = admin.firestore()
const projectsRef = firestore.collection('projects')
const YOUTUBE_IN_PROGRESS_STATUSES = ['PUBLISHING', 'UPLOADING', 'PROCESSING', 'QUEUED']
// Lecture des secrets (recommandé en v2)
const getSecretsYoutubeConfig = () =>
Object.fromEntries(
Object.entries({
client_id: S_YT_CLIENT_ID.value(),
client_secret: S_YT_CLIENT_SECRET.value(),
refresh_token: S_YT_REFRESH_TOKEN.value(),
redirect_uri: S_YT_REDIRECT_URI.value(),
privacy_status: S_YT_PRIVACY_STATUS.value(),
category_id: S_YT_CATEGORY_ID.value(),
}).filter(([, value]) => value !== undefined && value !== '')
)
// Compat facultative v1 -> renverra {} en v2 (et on log un warn propre)
const getLegacyYoutubeConfig = () => {
if (typeof functions.config !== 'function') {
return {}
}
try {
return functions.config()?.youtube || {}
} catch (error) {
if (
typeof error?.message === 'string' &&
error.message.includes('functions.config() is no longer available')
) {
logger.warn(
'[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)'
)
return {}
}
throw error
}
}
const ensureYoutubeConfig = () => {
// Fusionne (par prudence) lancienne config et les secrets actuels
const firebaseConfig = getLegacyYoutubeConfig()
const secretConfig = getSecretsYoutubeConfig()
const cfg = { ...firebaseConfig, ...secretConfig }
const requiredKeys = ['client_id', 'client_secret', 'refresh_token']
const missing = requiredKeys.filter((key) => !cfg[key])
if (missing.length) {
throw new HttpsError(
'failed-precondition',
`Configuration YouTube manquante: ${missing.join(', ')}`
)
}
return {
clientId: cfg.client_id,
clientSecret: cfg.client_secret,
refreshToken: cfg.refresh_token,
redirectUri: cfg.redirect_uri,
defaultPrivacyStatus: cfg.privacy_status,
defaultCategoryId: cfg.category_id,
}
}
const createYoutubeClient = ({ clientId, clientSecret, refreshToken, redirectUri }) => {
const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri)
oauth2Client.setCredentials({ refresh_token: refreshToken })
const youtube = google.youtube({
version: 'v3',
auth: oauth2Client,
})
return { youtube, oauth2Client }
}
const downloadFile = async (url, destinationPath) => {
if (!/^https?:\/\//i.test(url || '')) {
throw new HttpsError('invalid-argument', `URL non valide: ${url}`)
}
await fsp.mkdir(path.dirname(destinationPath), { recursive: true })
const response = await axios.get(url, { responseType: 'stream' })
await new Promise((resolve, reject) => {
const writer = fs.createWriteStream(destinationPath)
response.data.pipe(writer)
writer.on('finish', resolve)
writer.on('error', reject)
})
return destinationPath
}
const buildVideoMetadata = (project, defaults) => {
const baseTitle = project?.title || 'Création MusicLand'
const youtubeTitle = `${baseTitle} | MusicLand`
const description = `Vidéo générée avec MusicLand pour ${baseTitle}. Rejoins l'aventure sur l'app MusicLand !`
const tags = Array.isArray(project?.youtubeTags)
? project.youtubeTags.filter(Boolean).slice(0, 500)
: undefined
const snippet = {
title: youtubeTitle,
description,
categoryId: defaults.defaultCategoryId,
}
if (tags && tags.length) {
snippet.tags = tags
}
return {
snippet,
status: {
privacyStatus: defaults.defaultPrivacyStatus,
embeddable: true,
selfDeclaredMadeForKids: false,
},
}
}
exports.publishPlaybackToYoutube = onCall(
{
timeoutSeconds: 540,
memory: '1GiB',
cors: [
'http://localhost:8081',
'https://musicland-one.vercel.app/',
'https://musicland-d33f9.firebaseapp.com',
],
// Secrets requis pour lexécution (v2)
secrets: [
S_YT_CLIENT_ID,
S_YT_CLIENT_SECRET,
S_YT_REFRESH_TOKEN,
S_YT_REDIRECT_URI,
S_YT_PRIVACY_STATUS,
S_YT_CATEGORY_ID,
],
},
async ({ data = {}, auth }) => {
const uid = auth?.uid
if (!uid) {
throw new HttpsError('unauthenticated', 'Authentification requise')
}
const projectId = data?.projectId
if (!projectId || typeof projectId !== 'string') {
throw new HttpsError('invalid-argument', 'Paramètre projectId requis')
}
const projectSnap = await projectsRef.doc(projectId).get()
if (!projectSnap.exists) {
throw new HttpsError('not-found', 'Projet introuvable')
}
const project = projectSnap.data()
if (!project || project.userId !== uid) {
throw new HttpsError('permission-denied', "Vous n'avez pas les droits sur ce projet")
}
if (!project.playbackUrl) {
throw new HttpsError('failed-precondition', 'Aucun playback disponible pour la publication')
}
if (project.youtubeStatus && YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus)) {
throw new HttpsError(
'failed-precondition',
'Une publication est déjà en cours pour ce projet'
)
}
let youtubeDefaults
let youtubeClient
try {
youtubeDefaults = ensureYoutubeConfig()
youtubeClient = createYoutubeClient(youtubeDefaults)
await youtubeClient.oauth2Client.getAccessToken()
} catch (error) {
logger.error('[publishPlaybackToYoutube] configuration invalide', {
error: error?.message,
})
throw new HttpsError('failed-precondition', 'Configuration YouTube invalide ou incomplète')
}
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'yt-upload-'))
const videoPath = path.join(tmpDir, 'playback.mp4')
try {
await projectsRef.doc(projectId).set(
{
youtubeStatus: 'PUBLISHING',
youtubePublished: false,
youtubeError: null,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
await downloadFile(project.playbackUrl, videoPath)
const metadata = buildVideoMetadata(project, youtubeDefaults)
const uploadResponse = await youtubeClient.youtube.videos.insert({
part: ['snippet', 'status'].join(','),
requestBody: metadata,
media: {
body: fs.createReadStream(videoPath),
},
})
const videoId = uploadResponse?.data?.id
if (!videoId) {
throw new Error('ID de vidéo introuvable dans la réponse YouTube')
}
const youtubeLink = `https://www.youtube.com/watch?v=${videoId}`
await projectsRef.doc(projectId).set(
{
youtubeStatus: 'PUBLISHED',
youtubePublished: true,
youtubeUrl: youtubeLink,
youtubeVideoId: videoId,
youtubePublishedAt: FieldValue.serverTimestamp(),
youtubeError: null,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
logger.info('[publishPlaybackToYoutube] publication réussie', {
projectId,
videoId,
})
return {
videoId,
youtubeUrl: youtubeLink,
}
} catch (error) {
logger.error('[publishPlaybackToYoutube] échec de publication', {
projectId,
error: error?.message,
})
const errorMessage =
error instanceof HttpsError
? error.message
: error?.message || 'Publication YouTube échouée'
await projectsRef.doc(projectId).set(
{
youtubeStatus: 'FAILED',
youtubePublished: false,
youtubeError: errorMessage,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
if (error instanceof HttpsError) {
throw error
}
throw new HttpsError('internal', errorMessage)
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true })
}
}
)