fix(video): make publishing transitions safe

This commit is contained in:
2026-08-09 21:15:27 +07:00 Unverified
parent e17a615c07
commit 26c0b5af18
3 changed files with 400 additions and 102 deletions
+78 -102
View File
@@ -6,8 +6,13 @@ import {
type TiktokSettings,
type YoutubeSettings,
} from "@/db/schema";
import { and, desc, eq } from "drizzle-orm";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { sse } from "@/lib/realtime/sse";
import {
createVideoPublishingModule,
type VideoPublishJobRecord,
type VideoPublishUploadRecord,
} from "@/lib/video/video-publishing";
const VIDEO_TYPES = new Set(["video/mp4", "video/quicktime", "video/webm"]);
@@ -330,105 +335,76 @@ async function publishTiktok(upload: typeof videoUploads.$inferSelect) {
return { id: initData.data.publish_id, url: "https://www.tiktok.com/" };
}
export async function publishVideoJob(jobId: string) {
const job = await db.query.videoUploadJobs.findFirst({
where: (table, { eq }) => eq(table.id, jobId),
});
if (!job || job.status === "completed") return;
const upload = await db.query.videoUploads.findFirst({
where: (table, { eq }) => eq(table.id, job.uploadId),
});
if (!upload) throw new Error("Upload record not found.");
await db
.update(videoUploadJobs)
.set({
status: "processing",
attempts: job.attempts + 1,
startedAt: new Date(),
error: null,
})
.where(eq(videoUploadJobs.id, job.id));
await sse.uploads.pub("update", {
uploadId: upload.id,
jobId: job.id,
platform: job.platform,
status: "processing",
});
try {
const result =
job.platform === "youtube"
? await publishYoutube(upload)
: await publishTiktok(upload);
await db
.update(videoUploadJobs)
.set({
status: "completed",
externalId: result.id,
externalUrl: result.url,
completedAt: new Date(),
})
.where(eq(videoUploadJobs.id, job.id));
await sse.uploads.pub("update", {
uploadId: upload.id,
jobId: job.id,
platform: job.platform,
status: "completed",
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Platform upload failed.";
await db
.update(videoUploadJobs)
.set({ status: "failed", error: message })
.where(eq(videoUploadJobs.id, job.id));
await sse.uploads.pub("update", {
uploadId: upload.id,
jobId: job.id,
platform: job.platform,
status: "failed",
});
throw error;
}
}
type DatabaseVideoUpload = typeof videoUploads.$inferSelect;
type DatabaseVideoJob = typeof videoUploadJobs.$inferSelect;
export async function processDueVideoUploads() {
const due = await db.query.videoUploads.findMany({
where: (table, { eq, lte, or, isNull }) =>
and(
or(eq(table.status, "scheduled"), eq(table.status, "partial")),
or(isNull(table.scheduledAt), lte(table.scheduledAt, new Date())),
),
orderBy: [desc(videoUploads.createdAt)],
});
for (const upload of due) {
const jobs = await db.query.videoUploadJobs.findMany({
where: (table, { eq, and, ne }) =>
and(eq(table.uploadId, upload.id), ne(table.status, "completed")),
});
for (const job of jobs) {
await publishVideoJob(job.id).catch((error) => {
const message =
error instanceof Error ? error.message : "Platform upload failed.";
console.error(`[${job.platform}] ${job.id}: ${message}`);
});
}
const remaining = await db.query.videoUploadJobs.findMany({
where: (table, { eq, and, ne }) =>
and(eq(table.uploadId, upload.id), ne(table.status, "completed")),
});
const nextStatus =
remaining.length === 0
? "completed"
: remaining.some((job) => job.status === "completed")
? "partial"
: "failed";
await db
.update(videoUploads)
.set({ status: nextStatus, updatedAt: new Date() })
.where(eq(videoUploads.id, upload.id));
await sse.uploads.pub("update", {
uploadId: upload.id,
status: nextStatus,
});
}
}
const videoPublishing = createVideoPublishingModule<
DatabaseVideoUpload & VideoPublishUploadRecord,
DatabaseVideoJob & VideoPublishJobRecord
>({
store: {
claimJob: async (jobId, startedAt) => {
const [job] = await db
.update(videoUploadJobs)
.set({
status: "processing",
attempts: sql`${videoUploadJobs.attempts} + 1`,
startedAt,
error: null,
})
.where(
and(
eq(videoUploadJobs.id, jobId),
inArray(videoUploadJobs.status, ["pending", "failed"]),
),
)
.returning();
return job ?? null;
},
findUpload: async (uploadId) => (await db.query.videoUploads.findFirst({
where: (table, { eq }) => eq(table.id, uploadId),
})) ?? null,
markCompleted: async (job, result, completedAt) => {
await db
.update(videoUploadJobs)
.set({
status: "completed",
externalId: result.id,
externalUrl: result.url,
completedAt,
})
.where(eq(videoUploadJobs.id, job.id));
},
markFailed: async (job, message) => {
await db
.update(videoUploadJobs)
.set({ status: "failed", error: message })
.where(eq(videoUploadJobs.id, job.id));
},
findDueUploads: (now) => db.query.videoUploads.findMany({
where: (table, { eq, lte, or, isNull }) =>
and(
or(eq(table.status, "scheduled"), eq(table.status, "partial")),
or(isNull(table.scheduledAt), lte(table.scheduledAt, now)),
),
orderBy: [desc(videoUploads.createdAt)],
}),
findJobs: (uploadId) => db.query.videoUploadJobs.findMany({
where: (table, { eq }) => eq(table.uploadId, uploadId),
}),
updateUploadStatus: async (upload, status, updatedAt) => {
await db
.update(videoUploads)
.set({ status, updatedAt })
.where(eq(videoUploads.id, upload.id));
},
},
publishers: {
youtube: publishYoutube,
tiktok: publishTiktok,
},
notify: (event) => sse.uploads.pub("update", event),
});
export const publishVideoJob = videoPublishing.publishJob;
export const processDueVideoUploads = videoPublishing.processDue;