TikTok V6

This commit is contained in:
2026-07-13 18:51:52 +02:00
parent 5b4a924de9
commit 48e9e2992c
9 changed files with 747 additions and 41 deletions

View File

@@ -1,5 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { getValidTiktokTokens, getLiveTiktokAccessToken, tiktokApi, uploadBinaryToTiktok } from '@/lib/tiktok';
import {
getValidTiktokTokens,
tiktokApi,
uploadBinaryToTiktok,
planTiktokChunks,
sniffVideoMimeType,
pollTiktokPublishStatus,
} from '@/lib/tiktok';
const isAdminRequest = (request: NextRequest) => {
const adminKey = process.env.TIKTOK_ADMIN_KEY;
@@ -33,12 +40,29 @@ export async function POST(request: NextRequest) {
if (typeof json?.videoBufferBase64 === 'string') {
const videoBuffer = Buffer.from(json.videoBufferBase64, 'base64');
const videoSize = videoBuffer.length;
if (!videoSize) {
return NextResponse.json(
{ error: 'videoBufferBase64 decoded to an empty file.' },
{ status: 400 }
);
}
// Validate by magic bytes, not by a client-supplied MIME string.
const mimeType = sniffVideoMimeType(videoBuffer);
if (!mimeType) {
return NextResponse.json(
{ error: 'Video must be MP4, MOV, or WebM (magic-byte check failed).' },
{ status: 400 }
);
}
const { chunkSize, totalChunkCount } = planTiktokChunks(videoSize);
const initBody = {
source_info: {
source: 'FILE_UPLOAD',
video_size: videoSize,
chunk_size: videoSize,
total_chunk_count: 1,
chunk_size: chunkSize,
total_chunk_count: totalChunkCount,
},
};
@@ -61,7 +85,7 @@ export async function POST(request: NextRequest) {
);
}
await uploadBinaryToTiktok(uploadUrl, videoBuffer, 'video/mp4');
await uploadBinaryToTiktok(uploadUrl, videoBuffer, mimeType);
} else if (Array.isArray(json?.photos)) {
const photos = json.photos as Array<{ url?: string } | string>;
if (!photos.length) {
@@ -94,6 +118,18 @@ export async function POST(request: NextRequest) {
const title = typeof json?.title === 'string' ? json.title.trim() : '';
const description = typeof json?.description === 'string' ? json.description.trim() : '';
if (title.length > 90) {
return NextResponse.json(
{ error: 'title must be at most 90 characters for TikTok photo posts.' },
{ status: 400 }
);
}
if (description.length > 4000) {
return NextResponse.json(
{ error: 'description must be at most 4000 characters for TikTok photo posts.' },
{ status: 400 }
);
}
const initBody = {
media_type: 'PHOTO',
@@ -143,9 +179,16 @@ export async function POST(request: NextRequest) {
);
}
// INITIATED is not delivery. Poll until the draft reaches the creator's
// inbox (SEND_TO_USER_INBOX) or fails, bounded so the request finishes.
const finalStatus = await pollTiktokPublishStatus(publishId, { maxWaitMs: 45_000 });
status = finalStatus.status || status;
return NextResponse.json({
publish_id: publishId,
status,
delivered: status === 'SEND_TO_USER_INBOX',
...(finalStatus.failReason ? { fail_reason: finalStatus.failReason } : {}),
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';