functions

This commit is contained in:
Thomas Demirdjian
2025-11-03 13:47:07 +01:00
parent 460c91f1e6
commit 7b3a47090d
16 changed files with 23868 additions and 2399 deletions
+86 -28
View File
@@ -1,21 +1,17 @@
const { onDocumentCreated } = require("firebase-functions/v2/firestore");
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const {
generateImageV2,
} = require("../helpers/gemini");
const { generateImageV2 } = require("../helpers/gemini");
const { generatePicturePrompt } = require("../helpers/prompts");
const path = require("path");
const fs = require("fs");
const axios = require("axios");
const sharp = require("sharp");
const { ALERT_TYPE, refList } = require("../index");
const { sendNotification } = require("./notifications");
const db = admin.firestore();
const bucket = admin.storage().bucket();
const LOGO_PATH = path.resolve(
__dirname,
"../assets/musicLandProduction.png",
);
const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandProduction.png");
async function buildCoverWithLogo(backgroundUrl, targetPath) {
logger.info("🖼️ [Cover] Adding logo to generated background");
@@ -124,22 +120,19 @@ async function performCoverGeneration(project) {
const [firstOption] = options;
await db
.collection("projects")
.doc(project.id)
.set(
{
cover: {
generatedBackground: firstOption?.generatedUrl || null,
result: firstOption?.finalUrl || null,
selectedOptionId: firstOption?.id || null,
options,
},
coverStatus: "GENERATED",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
await refList.projects.doc(project.id).set(
{
cover: {
generatedBackground: firstOption?.generatedUrl || null,
result: firstOption?.finalUrl || null,
selectedOptionId: firstOption?.id || null,
options,
},
{ merge: true },
);
coverStatus: "GENERATED",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
logger.info("✅ [Cover] Saved", {
projectId: project.id,
@@ -162,6 +155,7 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
const type = data?.type || "";
const projectId = data?.projectId || null;
let coverUrl = null;
let project = null;
try {
if (!projectId) {
throw new Error("projectId manquant dans la task");
@@ -189,13 +183,12 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
return;
}
await db.collection("projects").doc(projectId).update({
await refList.projects.doc(projectId).update({
coverStatus: "GENERATING",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
const project =
(await db.collection("projects").doc(projectId).get())?.data() || null;
project = (await refList.projects.doc(projectId).get())?.data() || null;
if (!project) {
throw new Error("Projet non trouvé");
}
@@ -217,7 +210,7 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
projectId,
existingOptionsCount,
});
await db.collection("projects").doc(projectId).update({
await refList.projects.doc(projectId).update({
coverStatus: "GENERATED",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
@@ -244,6 +237,39 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
taskId: event?.params?.taskId,
projectId,
});
if (
project?.userId &&
typeof project.userId === "string" &&
project.userId.trim()
) {
const receiverId = project.userId.trim();
const projectTitle =
typeof project?.title === "string" && project.title.trim()
? project.title.trim()
: "ton projet";
const message = `Ta nouvelle pochette pour "${projectTitle}" est prête.`;
try {
await sendNotification({
sender: "SYSTEM",
receiver: receiverId,
receiverCollection: "users",
title: "Pochette générée",
message,
data: {
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
projectId,
projectTitle,
coverUrl,
},
});
} catch (notifError) {
logger.error("❌ [Cover] Failed to send success notification", {
projectId,
error: notifError?.message || String(notifError),
});
}
}
return;
} catch (error) {
logger.error("❌ [Task] Cover generation error", {
@@ -256,9 +282,41 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
{ status: "ERROR", error: error?.message || "Erreur" },
{ merge: true },
);
await db.collection("projects").doc(projectId).update({
await refList.projects.doc(projectId).update({
coverStatus: "ERROR",
});
if (
project?.userId &&
typeof project.userId === "string" &&
project.userId.trim()
) {
const receiverId = project.userId.trim();
const projectTitle =
typeof project?.title === "string" && project.title.trim()
? project.title.trim()
: "ton projet";
const message = `La génération de la pochette pour "${projectTitle}" a échoué.`;
try {
await sendNotification({
sender: "SYSTEM",
receiver: receiverId,
receiverCollection: "users",
title: "Pochette indisponible",
message,
data: {
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
projectId,
projectTitle,
error: error?.message || String(error),
},
});
} catch (notifError) {
logger.error("❌ [Cover] Failed to send error notification", {
projectId,
error: notifError?.message || String(notifError),
});
}
}
}
},
);
+95 -22
View File
@@ -1,7 +1,13 @@
const { onCall, onRequest, HttpsError } = require("firebase-functions/v2/https");
const {
onCall,
onRequest,
HttpsError,
} = require("firebase-functions/v2/https");
const axios = require("axios");
const admin = require("firebase-admin");
const { logger } = require("firebase-functions/logger");
const { ALERT_TYPE, refList } = require("../index");
const { sendNotification } = require("./notifications");
const { SUNO_API_KEY } = require("../config/keys");
const {
SUNO_MODEL,
@@ -11,9 +17,6 @@ const {
SUNO_STATUS_PATH,
} = require("../config/suno");
// Initialiser Firestorey
const db = admin.firestore();
/**
* Marque un projet comme échoué suite à une erreur Suno
* @param {string} projectId - Identifiant du projet
@@ -22,7 +25,9 @@ const db = admin.firestore();
async function markProjectMusicFailure(projectId, error) {
if (!projectId) return;
try {
const docRef = db.collection("projects").doc(projectId);
const docRef = refList.projects.doc(projectId);
const projectSnap = await docRef.get();
const projectData = projectSnap?.data() || {};
const status = error?.response?.status || error?.status || null;
const sunoMessage =
error?.response?.data?.msg ||
@@ -44,12 +49,44 @@ async function markProjectMusicFailure(projectId, error) {
musicError: errorPayload,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
{ merge: true },
);
const receiverId =
typeof projectData?.userId === "string" && projectData.userId.trim()
? projectData.userId.trim()
: null;
if (receiverId) {
const projectTitle =
typeof projectData?.title === "string" && projectData.title.trim()
? projectData.title.trim()
: "ton projet";
const message = `La génération de musique pour "${projectTitle}" a échoué.`;
try {
await sendNotification({
sender: "SYSTEM",
receiver: receiverId,
receiverCollection: "users",
title: "Génération de musique échouée",
message,
data: {
type: ALERT_TYPE?.MUSIC_GENERATION_FAILED,
projectId,
projectTitle,
error: errorPayload,
},
});
} catch (notifyError) {
console.error(
"[markProjectMusicFailure] Failed to send notification:",
notifyError,
);
}
}
} catch (err) {
console.error(
"❌ [generateMusic] Impossible de marquer le projet en erreur:",
err
err,
);
}
}
@@ -80,7 +117,7 @@ function extractVoiceStrings(voice) {
}
if (typeof voice === "object") {
return Object.values(voice).filter(
(v) => typeof v === "string" && v.trim()
(v) => typeof v === "string" && v.trim(),
);
}
return [];
@@ -134,7 +171,7 @@ function detectVocalGender(voiceInput = "") {
if (Array.isArray(voiceInput)) {
const baseItem = voiceInput.find(
(v) =>
v && (v.category === "base" || v?.category?.toLowerCase() === "base")
v && (v.category === "base" || v?.category?.toLowerCase() === "base"),
);
const text = baseItem ? baseItem.value || baseItem.text || baseItem : null;
if (text) return detectVocalGender(text);
@@ -346,7 +383,7 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
"Content-Type": "application/json",
Authorization: `Bearer ${SUNO_API_KEY}`,
},
}
},
);
const parsed = response.data;
@@ -370,7 +407,7 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
} catch (markErr) {
console.error(
"❌ Erreur lors de la mise à jour du statut de projet:",
markErr
markErr,
);
}
@@ -412,7 +449,7 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => {
Authorization: `Bearer ${SUNO_API_KEY}`,
},
timeout: 30000, // 30 secondes de timeout
}
},
);
parsed = response.data;
@@ -547,16 +584,24 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
// 1) Récupérer le projectId associé au taskId
let projectId = null;
const projSnap = await db
.collection("projects")
const projSnap = await refList.projects
.where("sunoTaskId", "==", taskId)
.limit(1)
.get();
if (projSnap.empty) {
throw new Error("Aucun projet trouvé pour ce taskId");
}
projectId = projSnap.docs[0].id;
const { userId } = projSnap.docs[0].data() || {};
const projectDoc = projSnap.docs[0];
const projectData = projectDoc?.data() || {};
projectId = projectDoc.id;
const userId =
typeof projectData.userId === "string" && projectData.userId.trim()
? projectData.userId.trim()
: null;
const projectTitle =
typeof projectData.title === "string" && projectData.title.trim()
? projectData.title.trim()
: "ton projet";
if (!projectId) {
console.warn("⚠️ [SunoCallback] Aucun projet trouvé pour", { taskId });
@@ -567,7 +612,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
const audioUrls = tracks
.map(
(t) =>
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl,
)
.filter(Boolean)
.slice(0, 2);
@@ -582,7 +627,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
has_audio_url: !!t.audio_url,
has_stream_audio_url: !!t.stream_audio_url,
})),
}
},
);
}
@@ -607,14 +652,14 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
},
});
const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
path
path,
)}?alt=media&token=${token}`;
console.log("✅ [SunoCallback] Sauvegardé:", path, "URL:", downloadUrl);
return { path, url: downloadUrl };
} catch (e) {
console.error(
`❌ [SunoCallback] Échec save piste ${index + 1}:`,
e.message
e.message,
);
return null;
}
@@ -628,19 +673,47 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
// 4) Mettre à jour le statut du projet
try {
const musicUrls = [p1?.url, p2?.url].filter(Boolean);
await db.collection("projects").doc(projectId).set(
await refList.projects.doc(projectId).set(
{
musicStatus: "GENERATED",
musicUrls,
musicError: admin.firestore.FieldValue.delete(),
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
{ merge: true },
);
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
projectId,
musicUrlsCount: musicUrls.length,
});
if (userId) {
const successMessage =
musicUrls.length > 0
? `Ta musique pour "${projectTitle}" est prête.`
: `La génération de musique pour "${projectTitle}" est terminée.`;
try {
await sendNotification({
sender: "SYSTEM",
receiver: userId,
receiverCollection: "users",
title: "Musique prête",
message: successMessage,
data: {
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
projectId,
projectTitle,
musicUrls,
taskId,
},
});
} catch (notifError) {
console.error(
"[sunoCallback] Failed to send success notification:",
notifError,
);
}
}
} catch (e) {
console.error("❌ [SunoCallback] Erreur maj projet:", e.message);
}
+15 -4
View File
@@ -13,10 +13,16 @@ const resendInstance = new Resend(RESEND_API_KEY);
// Initialisation de Expo SDK
let expo = new Expo();
const db = admin.firestore();
const EMAIL_FROM = "MusicLand <musicland@minuit.app>";
function getCollectionRef(collectionName = "") {
const ref = refList?.[collectionName];
if (!ref) {
throw new Error(`Unknown collection "${collectionName}"`);
}
return ref;
}
exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
{ region: "europe-west1", document: "notifications/{notificationId}" },
async (event) => {
@@ -34,12 +40,17 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
throw new Error("Receiver and message are required");
}
const receiverSnap = await getCollectionRef(receiverCollection)
.doc(receiver)
.get();
const receiverData = receiverSnap.exists ? receiverSnap.data() : {};
const {
pushToken = null,
pushTokens = [],
email = "",
emailNotifications = false,
} = (await db.collection(receiverCollection).doc(receiver).get()).data();
} = receiverData;
if (!mailOnly) {
const tokensSet = new Set(
@@ -193,7 +204,7 @@ async function removeInvalidTokens({
return;
}
const docRef = db.collection(receiverCollection).doc(receiverId);
const docRef = getCollectionRef(receiverCollection).doc(receiverId);
const userSnap = await docRef.get();
const userData = userSnap?.data() || {};
+142 -64
View File
@@ -1,36 +1,17 @@
const admin = require("firebase-admin");
const { onSchedule } = require("firebase-functions/v2/scheduler");
const { refList } = require("../index");
const firestore = refList.projects.firestore;
const _ = require("lodash");
const { refList, db, ALERT_TYPE } = require("../index");
const { batchFirestore } = require("../helpers/firebase");
const { BATCH_TYPE } = require("../config/types");
const {
buildMonthKey,
buildPreviousMonthContext,
} = require("../helpers/stats");
const { sendNotification } = require("./notifications");
const DISTRIBUTION_REVENUE_BASELINE = 1000;
const DISTRIBUTION_RATIO = 0.3;
const MAX_RECIPIENTS = 10;
const WEIGHTS = Array.from({ length: MAX_RECIPIENTS }, (_, idx) => MAX_RECIPIENTS - idx);
const buildPreviousMonthContext = (referenceDate) => {
const current = referenceDate ? new Date(referenceDate) : new Date();
current.setHours(1, 0, 0, 0);
current.setDate(1);
const target = new Date(current);
target.setMonth(target.getMonth() - 1);
const month = target.getMonth();
const year = target.getFullYear();
const monthKey = `${year}-${String(month + 1).padStart(2, "0")}`;
const rangeStart = new Date(year, month, 1, 0, 0, 0, 0);
const rangeEnd = new Date(year, month + 1, 0, 23, 59, 59, 999);
return {
year,
month: month + 1,
monthKey,
rangeStart,
rangeEnd,
};
};
exports.distributeMonthlyPayouts = onSchedule(
{
@@ -40,64 +21,164 @@ exports.distributeMonthlyPayouts = onSchedule(
async (event) => {
const { scheduleTime } = event;
const context = buildPreviousMonthContext(
scheduleTime ? new Date(scheduleTime) : new Date()
scheduleTime ? new Date(scheduleTime) : new Date(),
);
const monthKey = context.monthKey;
const monthKey = buildMonthKey(context.rangeStart);
const now = admin.firestore.Timestamp.now();
const statsSnapshot = await firestore
.collectionGroup("monthly")
const statsSnapshot = await db
.collectionGroup("monthlyListens")
.where("monthKey", "==", monthKey)
.orderBy("streams", "desc")
.limit(MAX_RECIPIENTS)
.get();
const topEntries = statsSnapshot.docs
.map((doc, index) => {
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey);
const totalsSnapshot = await totalsDocRef.get();
const entries = _.chain(statsSnapshot.docs)
.map((doc) => {
const data = doc.data() || {};
return {
rank: index + 1,
projectId: data.projectId || doc.ref.parent.parent?.id || null,
userId: data.userId || null,
streams: typeof data.streams === "number" ? data.streams : 0,
projectId:
_.get(data, "projectId") || doc.ref.parent.parent?.id || null,
userId: _.get(data, "userId", null),
streams: _.toFinite(_.get(data, "streams", 0)),
statsDocPath: doc.ref.path,
};
})
.filter((entry) => entry.projectId && entry.streams > 0);
.filter((entry) => entry.projectId && entry.streams > 0)
.orderBy(["streams"], ["desc"])
.value();
const payoutPool = Number(
(DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO).toFixed(2)
const payoutsTotalStreamsFromDocs = _.sumBy(entries, "streams");
const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null;
let totalStreams = _.toFinite(_.get(totalsData, "totalStreams", 0));
if (!totalStreams || totalStreams < payoutsTotalStreamsFromDocs) {
totalStreams = payoutsTotalStreamsFromDocs;
}
const payoutPool = _.round(
DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO,
2,
);
const weights = WEIGHTS.slice(0, topEntries.length);
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
let allocations = topEntries.map((entry, idx) => {
if (!weightSum) return 0;
const rawAmount = (payoutPool * weights[idx]) / weightSum;
return Number(rawAmount.toFixed(2));
let allocations = _.map(entries, (entry) => {
if (!totalStreams) return 0;
const rawAmount = (payoutPool * entry.streams) / totalStreams;
return _.round(rawAmount, 2);
});
if (allocations.length > 0) {
const allocatedTotal = Number(
allocations.reduce((sum, amount) => sum + amount, 0).toFixed(2)
);
const remainder = Number((payoutPool - allocatedTotal).toFixed(2));
const allocatedTotal = _.round(_.sum(allocations), 2);
const remainder = _.round(payoutPool - allocatedTotal, 2);
if (remainder !== 0) {
allocations[0] = Number((allocations[0] + remainder).toFixed(2));
allocations[0] = _.round(allocations[0] + remainder, 2);
}
}
const payouts = topEntries.map((entry, idx) => ({
rank: entry.rank,
const payouts = entries.map((entry, idx) => ({
rank: idx + 1,
projectId: entry.projectId,
userId: entry.userId,
streams: entry.streams,
weight: weights[idx],
share: totalStreams ? _.round(entry.streams / totalStreams, 6) : 0,
amount: allocations[idx],
statsDocPath: entry.statsDocPath,
}));
const userDocs = _(payouts)
.filter((payout) => !!payout.userId)
.groupBy("userId")
.map((projects, userId) => {
const sortedProjects = _.orderBy(projects, ["amount"], ["desc"]).map(
(project) => ({
projectId: project.projectId,
rank: project.rank,
amount: project.amount,
streams: project.streams,
share: project.share,
statsDocPath: project.statsDocPath,
}),
);
return {
userId,
totalAmount: _.round(_.sumBy(projects, "amount"), 2),
totalStreams: _.sumBy(projects, "streams"),
projects: sortedProjects,
};
})
.value();
if (userDocs.length) {
const docs = userDocs.map((userData) => {
const docId = `${monthKey}_${userData.userId}`;
return {
ref: refList.monthlyPayoutEntries.doc(docId),
data: {
monthKey,
userId: userData.userId,
payoutMonth: context.month,
totalAmount: userData.totalAmount,
totalStreams: userData.totalStreams,
projects: userData.projects,
computedAt: now,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
};
});
await batchFirestore({
docs,
type: BATCH_TYPE.UPDATE,
});
const payoutLabel = `${String(context.month).padStart(2, "0")}/${
context.year
}`;
await Promise.all(
userDocs.map(async (userData) => {
const receiverId =
typeof userData.userId === "string" && userData.userId.trim()
? userData.userId.trim()
: null;
const amount = Number(userData.totalAmount) || 0;
if (!receiverId || amount <= 0) {
return null;
}
const amountLabel = amount.toFixed(2);
const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.`;
try {
await sendNotification({
sender: "SYSTEM",
receiver: receiverId,
receiverCollection: "users",
title: "Revenus disponibles",
message,
data: {
type: ALERT_TYPE?.PAYOUT_AVAILABLE,
monthKey,
month: context.month,
year: context.year,
totalAmount: amount,
totalStreams: userData.totalStreams,
projects: userData.projects,
},
});
} catch (notifError) {
console.log(
"[distributeMonthlyPayouts] Failed to send payout notification:",
{
userId: receiverId,
error: notifError?.message || String(notifError),
},
);
}
return null;
}),
);
}
const summary = {
monthKey,
month: context.month,
@@ -109,19 +190,16 @@ exports.distributeMonthlyPayouts = onSchedule(
revenueBaseline: DISTRIBUTION_REVENUE_BASELINE,
payoutRatio: DISTRIBUTION_RATIO,
payoutPool,
totalStreams: topEntries.reduce((sum, entry) => sum + entry.streams, 0),
totalStreams,
totalRecipients: payouts.length,
totalAllocated: payouts.reduce(
(sum, payout) => Number((sum + payout.amount).toFixed(2)),
0
),
totalAllocated: _.round(_.sumBy(payouts, "amount"), 2),
payouts,
status: payouts.length ? "computed" : "no-data",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
computedAt: now,
};
const docRef = firestore.collection("monthlyPayouts").doc(monthKey);
const docRef = refList.monthlyPayouts.doc(monthKey);
await docRef.set(summary, { merge: true });
}
},
);
+90 -106
View File
@@ -1,69 +1,12 @@
const admin = require("firebase-admin");
const { onDocumentWritten } = require("firebase-functions/firestore");
const { refList } = require("../index");
const _ = require("lodash");
const { refList, db } = require("../index");
const { getSunoTimestamps } = require("./lyrics");
const { deleteFolder } = require("../helpers/firebase");
const { buildMonthKey } = require("../helpers/stats");
const firestore = admin.firestore();
const buildMonthKey = (timestamp) => {
const date = timestamp.toDate();
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
};
exports.onProjectUpdate = onDocumentWritten(
"projects/{projectId}",
async (projectSnap) => {
try {
const { projectId } = projectSnap.params;
const currentData = projectSnap?.data?.after?.data() || null;
const previousData = projectSnap?.data?.before?.data() || null;
if (!previousData?.songUrl && currentData?.songUrl) {
await getSunoTimestamps(projectId);
}
if (!currentData) {
const { userId } = previousData || {};
//delete folder
await deleteFolder(`users/${userId}/projects/${projectId}/`);
//delete tasks
const snapshot = await refList.tasks
.where("projectId", "==", projectId)
.get();
if (snapshot.empty) return null;
const batch = admin.firestore().batch();
snapshot.forEach((doc) => {
batch.delete(doc.ref);
});
await batch.commit();
} else {
if (previousData) {
// edit
if (!previousData?.songUrl && currentData?.songUrl) {
await refList.projects.doc(projectId).update({
hasSong: true,
});
}
if (!previousData?.playbackUrl && currentData?.playbackUrl) {
await refList.projects.doc(projectId).update({
hasPlayback: true,
});
}
}
}
} catch (e) {
console.log(e);
}
}
);
exports.onProjectViewsIncrement = onDocumentWritten(
exports.onProjectWritten = onDocumentWritten(
"projects/{projectId}",
async (projectSnap) => {
try {
@@ -71,53 +14,94 @@ exports.onProjectViewsIncrement = onDocumentWritten(
const beforeData = projectSnap?.data?.before?.data() || null;
const afterData = projectSnap?.data?.after?.data() || null;
if (!afterData) return null;
if (!afterData) {
const { userId } = beforeData || {};
const beforeViews =
typeof beforeData?.views === "number" && Number.isFinite(beforeData.views)
? beforeData.views
: 0;
const afterViews =
typeof afterData?.views === "number" && Number.isFinite(afterData.views)
? afterData.views
: 0;
const delta = afterViews - beforeViews;
if (delta <= 0) return null;
const userId =
typeof afterData?.userId === "string" && afterData.userId.trim().length
? afterData.userId.trim()
: null;
const now = admin.firestore.Timestamp.now();
const monthKey = buildMonthKey(now);
const statsDocRef = firestore
.collection("projectStreamStats")
.doc(projectId)
.collection("monthly")
.doc(monthKey);
await firestore.runTransaction(async (transaction) => {
const statsSnapshot = await transaction.get(statsDocRef);
const updatePayload = {
projectId,
userId,
monthKey,
updatedAt: now,
lastStreamAt: now,
lastDelta: delta,
streams: admin.firestore.FieldValue.increment(delta),
};
const hasFirstStream =
statsSnapshot.exists && statsSnapshot.data()?.firstStreamAt;
if (!hasFirstStream) {
updatePayload.firstStreamAt = now;
if (userId) {
await deleteFolder(`users/${userId}/projects/${projectId}/`);
}
transaction.set(statsDocRef, updatePayload, { merge: true });
});
const snapshot = await refList.tasks
.where("projectId", "==", projectId)
.get();
if (!snapshot.empty) {
const batch = db.batch();
snapshot.forEach((doc) => {
batch.delete(doc.ref);
});
await batch.commit();
}
return null;
} else if (!beforeData) {
//song create
} else {
if (!beforeData?.songUrl && afterData?.songUrl) {
await getSunoTimestamps(projectId);
await refList.projects.doc(projectId).update({ hasSong: true });
}
if (!beforeData?.playbackUrl && afterData?.playbackUrl) {
await refList.projects.doc(projectId).update({ hasPlayback: true });
}
}
const beforeViews = _.toFinite(_.get(beforeData, "views", 0));
const afterViews = _.toFinite(_.get(afterData, "views", 0));
const delta = afterViews - beforeViews;
if (delta > 0) {
const userIdRaw = _.get(afterData, "userId", null);
const userId =
_.isString(userIdRaw) && _.trim(userIdRaw).length
? _.trim(userIdRaw)
: null;
const now = admin.firestore.Timestamp.now();
const monthKey = buildMonthKey(now);
const statsDocRef = refList.projectStreamStats
.doc(projectId)
.collection("monthlyListens")
.doc(monthKey);
const totalsDocRef =
refList.projectStreamStatsMonthlyTotals.doc(monthKey);
await db.runTransaction(async (transaction) => {
const statsSnapshot = await transaction.get(statsDocRef);
const totalsSnapshot = await transaction.get(totalsDocRef);
const updatePayload = {
projectId,
userId,
monthKey,
updatedAt: now,
lastStreamAt: now,
lastDelta: delta,
streams: admin.firestore.FieldValue.increment(delta),
};
const hasFirstStream =
statsSnapshot.exists && statsSnapshot.data()?.firstStreamAt;
if (!hasFirstStream) {
updatePayload.firstStreamAt = now;
}
transaction.set(statsDocRef, updatePayload, { merge: true });
const totalsUpdatePayload = {
monthKey,
updatedAt: now,
lastStreamAt: now,
lastDelta: delta,
totalStreams: admin.firestore.FieldValue.increment(delta),
};
const totalsHasFirstStream =
totalsSnapshot.exists && totalsSnapshot.data()?.firstStreamAt;
if (!totalsHasFirstStream) {
totalsUpdatePayload.firstStreamAt = now;
}
transaction.set(totalsDocRef, totalsUpdatePayload, { merge: true });
});
}
return null;
} catch (error) {
@@ -126,5 +110,5 @@ exports.onProjectViewsIncrement = onDocumentWritten(
});
return null;
}
}
},
);
+2 -1
View File
@@ -7,6 +7,7 @@ const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const { refList } = require("../index");
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
@@ -146,7 +147,7 @@ exports.generateVideoThumbnail = onObjectFinalized(
const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`;
if (projectId) {
await admin.firestore().collection("projects").doc(projectId).set(
await refList.projects.doc(projectId).set(
{
thumbnailUrl,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),