feat: test publish to youtube
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user