295 lines
8.7 KiB
JavaScript
295 lines
8.7 KiB
JavaScript
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) l’ancienne 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 l’exé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 })
|
||
}
|
||
}
|
||
)
|