feat: test publish to youtube

This commit is contained in:
2025-11-04 10:20:09 +01:00
parent 1adf5ba5e4
commit e0babe90c0
5 changed files with 322 additions and 34 deletions
+276
View File
@@ -0,0 +1,276 @@
const { onCall, HttpsError } = require("firebase-functions/v2/https");
const functions = require("firebase-functions");
const logger = require("firebase-functions/logger");
const admin = require("firebase-admin");
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 firestore = admin.firestore();
const projectsRef = firestore.collection("projects");
const YOUTUBE_IN_PROGRESS_STATUSES = [
"PUBLISHING",
"UPLOADING",
"PROCESSING",
"QUEUED",
];
const getEnvYoutubeConfig = () =>
Object.fromEntries(
Object.entries({
client_id: process.env.YOUTUBE_CLIENT_ID,
client_secret: process.env.YOUTUBE_CLIENT_SECRET,
refresh_token: process.env.YOUTUBE_REFRESH_TOKEN,
redirect_uri: process.env.YOUTUBE_REDIRECT_URI,
privacy_status: process.env.YOUTUBE_PRIVACY_STATUS,
category_id: process.env.YOUTUBE_CATEGORY_ID,
}).filter(([, value]) => value !== undefined && value !== "")
);
const ensureYoutubeConfig = () => {
const firebaseConfig = functions.config()?.youtube || {};
const envConfig = getEnvYoutubeConfig();
const cfg = { ...firebaseConfig, ...envConfig };
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 || "https://developers.google.com/oauthplayground",
defaultPrivacyStatus: cfg.privacy_status || "unlisted",
defaultCategoryId: cfg.category_id || "10",
};
};
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",
],
},
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: admin.firestore.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: admin.firestore.FieldValue.serverTimestamp(),
youtubeError: null,
updatedAt: admin.firestore.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: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
);
if (error instanceof HttpsError) {
throw error;
}
throw new HttpsError("internal", errorMessage);
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true });
}
}
);