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 }) } } )