feat: fixes and formatter

This commit is contained in:
2026-01-12 16:01:32 +01:00
parent 85c6084351
commit 11e632acff
353 changed files with 23315 additions and 27361 deletions
+120 -146
View File
@@ -1,34 +1,29 @@
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");
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");
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 firestore = admin.firestore()
const projectsRef = firestore.collection('projects')
const YOUTUBE_IN_PROGRESS_STATUSES = [
"PUBLISHING",
"UPLOADING",
"PROCESSING",
"QUEUED",
];
const YOUTUBE_IN_PROGRESS_STATUSES = ['PUBLISHING', 'UPLOADING', 'PROCESSING', 'QUEUED']
// Lecture des secrets (recommandé en v2)
const getSecretsYoutubeConfig = () =>
@@ -40,44 +35,44 @@ const getSecretsYoutubeConfig = () =>
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 !== "")
);
}).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 {};
if (typeof functions.config !== 'function') {
return {}
}
try {
return functions.config()?.youtube || {};
return functions.config()?.youtube || {}
} catch (error) {
if (
typeof error?.message === "string" &&
error.message.includes("functions.config() is no longer available")
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 {};
'[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)'
)
return {}
}
throw error;
throw error
}
};
}
const ensureYoutubeConfig = () => {
// Fusionne (par prudence) lancienne config et les secrets actuels
const firebaseConfig = getLegacyYoutubeConfig();
const secretConfig = getSecretsYoutubeConfig();
const cfg = { ...firebaseConfig, ...secretConfig };
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]);
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(", ")}`
);
'failed-precondition',
`Configuration YouTube manquante: ${missing.join(', ')}`
)
}
return {
@@ -87,63 +82,54 @@ const ensureYoutubeConfig = () => {
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 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",
version: 'v3',
auth: oauth2Client,
});
return { youtube, oauth2Client };
};
})
return { youtube, oauth2Client }
}
const downloadFile = async (url, destinationPath) => {
if (!/^https?:\/\//i.test(url || "")) {
throw new HttpsError("invalid-argument", `URL non valide: ${url}`);
if (!/^https?:\/\//i.test(url || '')) {
throw new HttpsError('invalid-argument', `URL non valide: ${url}`)
}
await fsp.mkdir(path.dirname(destinationPath), { recursive: true });
await fsp.mkdir(path.dirname(destinationPath), { recursive: true })
const response = await axios.get(url, { responseType: "stream" });
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);
});
const writer = fs.createWriteStream(destinationPath)
response.data.pipe(writer)
writer.on('finish', resolve)
writer.on('error', reject)
})
return destinationPath;
};
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 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;
: undefined
const snippet = {
title: youtubeTitle,
description,
categoryId: defaults.defaultCategoryId,
};
}
if (tags && tags.length) {
snippet.tags = tags;
snippet.tags = tags
}
return {
@@ -153,17 +139,17 @@ const buildVideoMetadata = (project, defaults) => {
embeddable: true,
selfDeclaredMadeForKids: false,
},
};
};
}
}
exports.publishPlaybackToYoutube = onCall(
{
timeoutSeconds: 540,
memory: "1GiB",
memory: '1GiB',
cors: [
"http://localhost:8081",
"https://musicland-one.vercel.app/",
"https://musicland-d33f9.firebaseapp.com",
'http://localhost:8081',
'https://musicland-one.vercel.app/',
'https://musicland-d33f9.firebaseapp.com',
],
// Secrets requis pour lexécution (v2)
secrets: [
@@ -176,98 +162,86 @@ exports.publishPlaybackToYoutube = onCall(
],
},
async ({ data = {}, auth }) => {
const uid = auth?.uid;
const uid = auth?.uid
if (!uid) {
throw new HttpsError("unauthenticated", "Authentification requise");
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 projectId = data?.projectId
if (!projectId || typeof projectId !== 'string') {
throw new HttpsError('invalid-argument', 'Paramètre projectId requis')
}
const projectSnap = await projectsRef.doc(projectId).get();
const projectSnap = await projectsRef.doc(projectId).get()
if (!projectSnap.exists) {
throw new HttpsError("not-found", "Projet introuvable");
throw new HttpsError('not-found', 'Projet introuvable')
}
const project = projectSnap.data();
const project = projectSnap.data()
if (!project || project.userId !== uid) {
throw new HttpsError(
"permission-denied",
"Vous n'avez pas les droits sur ce projet"
);
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"
);
throw new HttpsError('failed-precondition', 'Aucun playback disponible pour la publication')
}
if (
project.youtubeStatus &&
YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus)
) {
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"
);
'failed-precondition',
'Une publication est déjà en cours pour ce projet'
)
}
let youtubeDefaults;
let youtubeClient;
let youtubeDefaults
let youtubeClient
try {
youtubeDefaults = ensureYoutubeConfig();
youtubeClient = createYoutubeClient(youtubeDefaults);
await youtubeClient.oauth2Client.getAccessToken();
youtubeDefaults = ensureYoutubeConfig()
youtubeClient = createYoutubeClient(youtubeDefaults)
await youtubeClient.oauth2Client.getAccessToken()
} catch (error) {
logger.error("[publishPlaybackToYoutube] configuration invalide", {
logger.error('[publishPlaybackToYoutube] configuration invalide', {
error: error?.message,
});
throw new HttpsError(
"failed-precondition",
"Configuration YouTube invalide ou incomplète"
);
})
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");
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",
youtubeStatus: 'PUBLISHING',
youtubePublished: false,
youtubeError: null,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
)
await downloadFile(project.playbackUrl, videoPath);
await downloadFile(project.playbackUrl, videoPath)
const metadata = buildVideoMetadata(project, youtubeDefaults);
const metadata = buildVideoMetadata(project, youtubeDefaults)
const uploadResponse = await youtubeClient.youtube.videos.insert({
part: ["snippet", "status"].join(","),
part: ['snippet', 'status'].join(','),
requestBody: metadata,
media: {
body: fs.createReadStream(videoPath),
},
});
})
const videoId = uploadResponse?.data?.id;
const videoId = uploadResponse?.data?.id
if (!videoId) {
throw new Error("ID de vidéo introuvable dans la réponse YouTube");
throw new Error('ID de vidéo introuvable dans la réponse YouTube')
}
const youtubeLink = `https://www.youtube.com/watch?v=${videoId}`;
const youtubeLink = `https://www.youtube.com/watch?v=${videoId}`
await projectsRef.doc(projectId).set(
{
youtubeStatus: "PUBLISHED",
youtubeStatus: 'PUBLISHED',
youtubePublished: true,
youtubeUrl: youtubeLink,
youtubeVideoId: videoId,
@@ -276,45 +250,45 @@ exports.publishPlaybackToYoutube = onCall(
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
)
logger.info("[publishPlaybackToYoutube] publication réussie", {
logger.info('[publishPlaybackToYoutube] publication réussie', {
projectId,
videoId,
});
})
return {
videoId,
youtubeUrl: youtubeLink,
};
}
} catch (error) {
logger.error("[publishPlaybackToYoutube] échec de publication", {
logger.error('[publishPlaybackToYoutube] échec de publication', {
projectId,
error: error?.message,
});
})
const errorMessage =
error instanceof HttpsError
? error.message
: error?.message || "Publication YouTube échouée";
: error?.message || 'Publication YouTube échouée'
await projectsRef.doc(projectId).set(
{
youtubeStatus: "FAILED",
youtubeStatus: 'FAILED',
youtubePublished: false,
youtubeError: errorMessage,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
)
if (error instanceof HttpsError) {
throw error;
throw error
}
throw new HttpsError("internal", errorMessage);
throw new HttpsError('internal', errorMessage)
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true });
await fsp.rm(tmpDir, { recursive: true, force: true })
}
}
);
)