feat: download options
This commit is contained in:
+146
-144
@@ -284,6 +284,8 @@ exports.validatePlaybackDraft = onCall({ region: REGION, timeoutSeconds: 540 },
|
||||
playbackJobId: null,
|
||||
playbackCallbackId: null,
|
||||
playbackError: null,
|
||||
playbackPublishedOnMusicLand: false,
|
||||
playbackPublishedOnMusicLandAt: null,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
@@ -302,180 +304,180 @@ exports.playbackWebhook = onRequest(
|
||||
secrets: [HEYGEN_API_KEY, HEYGEN_WEBHOOK_TOKEN],
|
||||
},
|
||||
async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.status(405).json({ ok: false, message: 'Method not allowed' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const expectedToken = getSecretValue(HEYGEN_WEBHOOK_TOKEN, 'HEYGEN_WEBHOOK_TOKEN')
|
||||
const token = trimString(req?.query?.token)
|
||||
|
||||
if (!token || token !== expectedToken) {
|
||||
res.status(401).json({ ok: false, message: 'Unauthorized webhook token' })
|
||||
if (req.method !== 'POST') {
|
||||
res.status(405).json({ ok: false, message: 'Method not allowed' })
|
||||
return
|
||||
}
|
||||
|
||||
const { eventType, callbackId, videoId } = extractWebhookPayload(req.body || {})
|
||||
try {
|
||||
const expectedToken = getSecretValue(HEYGEN_WEBHOOK_TOKEN, 'HEYGEN_WEBHOOK_TOKEN')
|
||||
const token = trimString(req?.query?.token)
|
||||
|
||||
if (!callbackId && !videoId) {
|
||||
logger.warn('[HeyGen] webhook ignored: missing identifiers', {
|
||||
eventType,
|
||||
bodyKeys: Object.keys(req.body || {}),
|
||||
})
|
||||
res.status(400).json({ ok: false, message: 'Missing callback_id or video_id' })
|
||||
return
|
||||
}
|
||||
if (!token || token !== expectedToken) {
|
||||
res.status(401).json({ ok: false, message: 'Unauthorized webhook token' })
|
||||
return
|
||||
}
|
||||
|
||||
const projectDoc = await findProjectForWebhook({ callbackId, videoId })
|
||||
if (!projectDoc) {
|
||||
logger.info('[HeyGen] webhook ignored: project not found', {
|
||||
callbackId,
|
||||
videoId,
|
||||
eventType,
|
||||
})
|
||||
res.status(202).json({ ok: true, ignored: true })
|
||||
return
|
||||
}
|
||||
const { eventType, callbackId, videoId } = extractWebhookPayload(req.body || {})
|
||||
|
||||
const project = projectDoc.data() || {}
|
||||
const currentCallbackId = trimString(project?.playbackCallbackId)
|
||||
const currentVideoId = trimString(project?.playbackJobId)
|
||||
if (!callbackId && !videoId) {
|
||||
logger.warn('[HeyGen] webhook ignored: missing identifiers', {
|
||||
eventType,
|
||||
bodyKeys: Object.keys(req.body || {}),
|
||||
})
|
||||
res.status(400).json({ ok: false, message: 'Missing callback_id or video_id' })
|
||||
return
|
||||
}
|
||||
|
||||
if (callbackId && currentCallbackId && callbackId !== currentCallbackId) {
|
||||
logger.info('[HeyGen] webhook ignored: stale callback_id', {
|
||||
projectId: projectDoc.id,
|
||||
callbackId,
|
||||
currentCallbackId,
|
||||
})
|
||||
res.status(202).json({ ok: true, ignored: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (videoId && currentVideoId && videoId !== currentVideoId) {
|
||||
logger.info('[HeyGen] webhook ignored: stale video_id', {
|
||||
projectId: projectDoc.id,
|
||||
videoId,
|
||||
currentVideoId,
|
||||
})
|
||||
res.status(202).json({ ok: true, ignored: true })
|
||||
return
|
||||
}
|
||||
|
||||
const canonicalVideoId = currentVideoId || videoId
|
||||
if (!canonicalVideoId) {
|
||||
logger.warn('[HeyGen] webhook ignored: no canonical video id', {
|
||||
projectId: projectDoc.id,
|
||||
callbackId,
|
||||
})
|
||||
res.status(202).json({ ok: true, ignored: true })
|
||||
return
|
||||
}
|
||||
|
||||
const canonicalVideo = await fetchCanonicalVideo(canonicalVideoId)
|
||||
const canonicalStatus = normalizeStatus(canonicalVideo?.status)
|
||||
|
||||
if (canonicalStatus === 'completed') {
|
||||
const canonicalVideoUrl = trimString(canonicalVideo?.video_url)
|
||||
if (!canonicalVideoUrl) {
|
||||
logger.warn('[HeyGen] webhook ignored: completed without video_url', {
|
||||
projectId: projectDoc.id,
|
||||
canonicalVideoId,
|
||||
const projectDoc = await findProjectForWebhook({ callbackId, videoId })
|
||||
if (!projectDoc) {
|
||||
logger.info('[HeyGen] webhook ignored: project not found', {
|
||||
callbackId,
|
||||
videoId,
|
||||
eventType,
|
||||
})
|
||||
res.status(202).json({ ok: true, ignored: true })
|
||||
return
|
||||
}
|
||||
|
||||
const notificationRef = db
|
||||
.collection('notifications')
|
||||
.doc(
|
||||
const project = projectDoc.data() || {}
|
||||
const currentCallbackId = trimString(project?.playbackCallbackId)
|
||||
const currentVideoId = trimString(project?.playbackJobId)
|
||||
|
||||
if (callbackId && currentCallbackId && callbackId !== currentCallbackId) {
|
||||
logger.info('[HeyGen] webhook ignored: stale callback_id', {
|
||||
projectId: projectDoc.id,
|
||||
callbackId,
|
||||
currentCallbackId,
|
||||
})
|
||||
res.status(202).json({ ok: true, ignored: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (videoId && currentVideoId && videoId !== currentVideoId) {
|
||||
logger.info('[HeyGen] webhook ignored: stale video_id', {
|
||||
projectId: projectDoc.id,
|
||||
videoId,
|
||||
currentVideoId,
|
||||
})
|
||||
res.status(202).json({ ok: true, ignored: true })
|
||||
return
|
||||
}
|
||||
|
||||
const canonicalVideoId = currentVideoId || videoId
|
||||
if (!canonicalVideoId) {
|
||||
logger.warn('[HeyGen] webhook ignored: no canonical video id', {
|
||||
projectId: projectDoc.id,
|
||||
callbackId,
|
||||
})
|
||||
res.status(202).json({ ok: true, ignored: true })
|
||||
return
|
||||
}
|
||||
|
||||
const canonicalVideo = await fetchCanonicalVideo(canonicalVideoId)
|
||||
const canonicalStatus = normalizeStatus(canonicalVideo?.status)
|
||||
|
||||
if (canonicalStatus === 'completed') {
|
||||
const canonicalVideoUrl = trimString(canonicalVideo?.video_url)
|
||||
if (!canonicalVideoUrl) {
|
||||
logger.warn('[HeyGen] webhook ignored: completed without video_url', {
|
||||
projectId: projectDoc.id,
|
||||
canonicalVideoId,
|
||||
})
|
||||
res.status(202).json({ ok: true, ignored: true })
|
||||
return
|
||||
}
|
||||
|
||||
const notificationRef = db
|
||||
.collection('notifications')
|
||||
.doc(
|
||||
buildPlaybackReadyNotificationId({
|
||||
projectId: projectDoc.id,
|
||||
videoId: canonicalVideoId,
|
||||
})
|
||||
)
|
||||
|
||||
await db.runTransaction(async (transaction) => {
|
||||
const [latestProjectSnap, notificationSnap] = await Promise.all([
|
||||
transaction.get(projectDoc.ref),
|
||||
transaction.get(notificationRef),
|
||||
])
|
||||
const latestProject = latestProjectSnap.data() || {}
|
||||
const userId = trimString(latestProject?.userId)
|
||||
const projectTitle = trimString(latestProject?.title) || 'ton projet'
|
||||
await db.runTransaction(async (transaction) => {
|
||||
const [latestProjectSnap, notificationSnap] = await Promise.all([
|
||||
transaction.get(projectDoc.ref),
|
||||
transaction.get(notificationRef),
|
||||
])
|
||||
const latestProject = latestProjectSnap.data() || {}
|
||||
const userId = trimString(latestProject?.userId)
|
||||
const projectTitle = trimString(latestProject?.title) || 'ton projet'
|
||||
|
||||
transaction.set(
|
||||
projectDoc.ref,
|
||||
transaction.set(
|
||||
projectDoc.ref,
|
||||
{
|
||||
playbackProvider: 'heygen',
|
||||
playbackStatus: PLAYBACK_STATUS.DRAFT_READY,
|
||||
playbackGenerating: false,
|
||||
playbackDraftUrl: canonicalVideoUrl,
|
||||
playbackJobId: canonicalVideoId,
|
||||
playbackCompletedAt: FieldValue.serverTimestamp(),
|
||||
playbackError: null,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
if (userId && !notificationSnap.exists) {
|
||||
transaction.set(notificationRef, {
|
||||
sender: 'SYSTEM',
|
||||
receiver: userId,
|
||||
receiverCollection: 'users',
|
||||
title: 'Ton playback IA est prêt !',
|
||||
message: `La vidéo IA de "${projectTitle}" est terminée. Tu peux maintenant la découvrir et la valider.`,
|
||||
time: FieldValue.serverTimestamp(),
|
||||
read: false,
|
||||
readAt: null,
|
||||
mailOnly: false,
|
||||
data: {
|
||||
type: 'PLAYBACK_GENERATION_SUCCESS',
|
||||
projectId: projectDoc.id,
|
||||
projectTitle,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
res.status(200).json({ ok: true, status: PLAYBACK_STATUS.DRAFT_READY })
|
||||
return
|
||||
}
|
||||
|
||||
if (canonicalStatus === 'failed') {
|
||||
const failureMessage =
|
||||
trimString(canonicalVideo?.failure_message) || 'La génération HeyGen a échoué'
|
||||
|
||||
await projectDoc.ref.set(
|
||||
{
|
||||
playbackProvider: 'heygen',
|
||||
playbackStatus: PLAYBACK_STATUS.DRAFT_READY,
|
||||
playbackStatus: PLAYBACK_STATUS.FAILED,
|
||||
playbackGenerating: false,
|
||||
playbackDraftUrl: canonicalVideoUrl,
|
||||
playbackError: failureMessage,
|
||||
playbackJobId: canonicalVideoId,
|
||||
playbackCompletedAt: FieldValue.serverTimestamp(),
|
||||
playbackError: null,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
if (userId && !notificationSnap.exists) {
|
||||
transaction.set(notificationRef, {
|
||||
sender: 'SYSTEM',
|
||||
receiver: userId,
|
||||
receiverCollection: 'users',
|
||||
title: 'Ton playback IA est prêt !',
|
||||
message: `La vidéo IA de "${projectTitle}" est terminée. Tu peux maintenant la découvrir et la valider.`,
|
||||
time: FieldValue.serverTimestamp(),
|
||||
read: false,
|
||||
readAt: null,
|
||||
mailOnly: false,
|
||||
data: {
|
||||
type: 'PLAYBACK_GENERATION_SUCCESS',
|
||||
projectId: projectDoc.id,
|
||||
projectTitle,
|
||||
},
|
||||
})
|
||||
}
|
||||
res.status(200).json({ ok: true, status: PLAYBACK_STATUS.FAILED })
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('[HeyGen] webhook acknowledged without terminal status', {
|
||||
projectId: projectDoc.id,
|
||||
canonicalVideoId,
|
||||
canonicalStatus,
|
||||
eventType,
|
||||
})
|
||||
|
||||
res.status(200).json({ ok: true, status: PLAYBACK_STATUS.DRAFT_READY })
|
||||
return
|
||||
res.status(202).json({ ok: true, status: canonicalStatus || 'processing' })
|
||||
} catch (error) {
|
||||
logger.error('[HeyGen] webhook failed', {
|
||||
message: error?.message || String(error || ''),
|
||||
})
|
||||
res.status(500).json({ ok: false, message: error?.message || 'Webhook failure' })
|
||||
}
|
||||
|
||||
if (canonicalStatus === 'failed') {
|
||||
const failureMessage =
|
||||
trimString(canonicalVideo?.failure_message) || 'La génération HeyGen a échoué'
|
||||
|
||||
await projectDoc.ref.set(
|
||||
{
|
||||
playbackProvider: 'heygen',
|
||||
playbackStatus: PLAYBACK_STATUS.FAILED,
|
||||
playbackGenerating: false,
|
||||
playbackError: failureMessage,
|
||||
playbackJobId: canonicalVideoId,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
res.status(200).json({ ok: true, status: PLAYBACK_STATUS.FAILED })
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('[HeyGen] webhook acknowledged without terminal status', {
|
||||
projectId: projectDoc.id,
|
||||
canonicalVideoId,
|
||||
canonicalStatus,
|
||||
eventType,
|
||||
})
|
||||
|
||||
res.status(202).json({ ok: true, status: canonicalStatus || 'processing' })
|
||||
} catch (error) {
|
||||
logger.error('[HeyGen] webhook failed', {
|
||||
message: error?.message || String(error || ''),
|
||||
})
|
||||
res.status(500).json({ ok: false, message: error?.message || 'Webhook failure' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+17
-14
@@ -36,21 +36,24 @@ exports.snapshotMonthlyTopSongs = onSchedule(
|
||||
const { scheduleTime } = event
|
||||
const context = buildMonthContext(scheduleTime ? new Date(scheduleTime) : new Date())
|
||||
|
||||
const topProjectsSnap = await refList.projects.orderBy('views', 'desc').limit(3).get()
|
||||
const topProjectsSnap = await refList.projects.orderBy('views', 'desc').limit(100).get()
|
||||
|
||||
const topProjects = topProjectsSnap.docs.map((doc, index) => {
|
||||
const data = doc.data() || {}
|
||||
return {
|
||||
rank: index + 1,
|
||||
projectId: doc.id,
|
||||
title: data.title || null,
|
||||
userId: data.userId || null,
|
||||
userName: data.userName || null,
|
||||
coverUrl: data.coverUrl || null,
|
||||
songUrl: data.songUrl || null,
|
||||
views: data.views || 0,
|
||||
}
|
||||
})
|
||||
const topProjects = topProjectsSnap.docs
|
||||
.filter((doc) => doc.data()?.songPublishedOnMusicLand !== false)
|
||||
.slice(0, 3)
|
||||
.map((doc, index) => {
|
||||
const data = doc.data() || {}
|
||||
return {
|
||||
rank: index + 1,
|
||||
projectId: doc.id,
|
||||
title: data.title || null,
|
||||
userId: data.userId || null,
|
||||
userName: data.userName || null,
|
||||
coverUrl: data.coverUrl || null,
|
||||
songUrl: data.songUrl || null,
|
||||
views: data.views || 0,
|
||||
}
|
||||
})
|
||||
|
||||
const docRef = firestore.collection('monthlyTopSongs').doc(context.monthKey)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user