feat: remove publish to youtube
This commit is contained in:
@@ -57,6 +57,5 @@ exports.notifications = require('./src/notifications')
|
||||
exports.rankings = require('./src/rankings')
|
||||
exports.payouts = require('./src/payouts')
|
||||
exports.subscription = require('./src/subscription')
|
||||
exports.youtube = require('./src/youtube')
|
||||
exports.orders = require('./src/orders')
|
||||
exports.cron = require('./src/cron')
|
||||
|
||||
Generated
+3
-15
@@ -18,7 +18,6 @@
|
||||
"firebase-functions": "^6.0.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"genkit": "^1.18.0",
|
||||
"googleapis": "^131.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"resend": "^6.3.0",
|
||||
"sharp": "^0.33.4",
|
||||
@@ -4689,7 +4688,6 @@
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
@@ -8885,24 +8883,12 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis": {
|
||||
"version": "131.0.0",
|
||||
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-131.0.0.tgz",
|
||||
"integrity": "sha512-fa4kdkY0VwHDw/04ItpQv2tlvlPIwbh6NjHDoWAVrV52GuaZbYCMOC5Y+hRmprp5HHIMRODmyb2YujlbZSRUbQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"google-auth-library": "^9.0.0",
|
||||
"googleapis-common": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis-common": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz",
|
||||
"integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"gaxios": "^6.0.3",
|
||||
@@ -8924,6 +8910,7 @@
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
@@ -13236,6 +13223,7 @@
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
|
||||
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
|
||||
"devOptional": true,
|
||||
"license": "BSD"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"firebase-functions": "^6.0.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"genkit": "^1.18.0",
|
||||
"googleapis": "^131.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"resend": "^6.3.0",
|
||||
"sharp": "^0.33.4",
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user