refactor(lib): organize modules by feature
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
import "server-only";
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
videoUploadJobs,
|
||||
videoUploads,
|
||||
type TiktokSettings,
|
||||
type YoutubeSettings,
|
||||
} from "@/db/schema";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
|
||||
const VIDEO_TYPES = new Set(["video/mp4", "video/quicktime", "video/webm"]);
|
||||
|
||||
export type LatestVideo = {
|
||||
id: string;
|
||||
title: string;
|
||||
thumbnailUrl: string;
|
||||
url: string;
|
||||
createdAt: number;
|
||||
platform: "youtube" | "tiktok";
|
||||
};
|
||||
|
||||
export function validateVideoFile(file: File) {
|
||||
if (!VIDEO_TYPES.has(file.type))
|
||||
throw new Error("Use an MP4, MOV, or WebM video.");
|
||||
if (file.size <= 0) throw new Error("Video cannot be empty.");
|
||||
}
|
||||
|
||||
export function validateThumbnailFile(file: File) {
|
||||
if (!file.type.startsWith("image/"))
|
||||
throw new Error("Thumbnail must be an image.");
|
||||
if (file.size <= 0 || file.size > 10 * 1024 * 1024)
|
||||
throw new Error("Thumbnail must be smaller than 10 MB.");
|
||||
}
|
||||
|
||||
export async function getLatestYoutubeVideos(): Promise<LatestVideo[]> {
|
||||
const apiKey = process.env.YOUTUBE_API_KEY;
|
||||
const channelId = process.env.YOUTUBE_CHANNEL_ID;
|
||||
if (!apiKey || !channelId) return [];
|
||||
const channels = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${encodeURIComponent(channelId)}&key=${encodeURIComponent(apiKey)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (!channels.ok) throw new Error("YouTube channel lookup failed.");
|
||||
const channelData = (await channels.json()) as {
|
||||
items?: Array<{
|
||||
contentDetails?: { relatedPlaylists?: { uploads?: string } };
|
||||
}>;
|
||||
};
|
||||
const playlistId =
|
||||
channelData.items?.[0]?.contentDetails?.relatedPlaylists?.uploads;
|
||||
if (!playlistId) return [];
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId=${encodeURIComponent(playlistId)}&maxResults=10&key=${encodeURIComponent(apiKey)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (!response.ok) throw new Error("YouTube video list failed.");
|
||||
const data = (await response.json()) as {
|
||||
items?: Array<{
|
||||
contentDetails?: { videoId?: string };
|
||||
snippet?: {
|
||||
title?: string;
|
||||
publishedAt?: string;
|
||||
thumbnails?: Record<string, { url: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
return (data.items ?? []).flatMap((item) => {
|
||||
const id = item.contentDetails?.videoId;
|
||||
if (!id) return [];
|
||||
return [
|
||||
{
|
||||
id,
|
||||
title: item.snippet?.title ?? "Untitled",
|
||||
thumbnailUrl:
|
||||
item.snippet?.thumbnails?.high?.url ??
|
||||
item.snippet?.thumbnails?.default?.url ??
|
||||
"",
|
||||
url: `https://www.youtube.com/watch?v=${id}`,
|
||||
createdAt: Date.parse(item.snippet?.publishedAt ?? "") || 0,
|
||||
platform: "youtube" as const,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
async function getTiktokAccessToken() {
|
||||
const refreshToken = process.env.TIKTOK_REFRESH_TOKEN;
|
||||
const clientKey = process.env.TIKTOK_CLIENT_KEY;
|
||||
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
|
||||
if (!refreshToken || !clientKey || !clientSecret)
|
||||
return process.env.TIKTOK_ACCESS_TOKEN ?? null;
|
||||
const response = await fetch("https://open.tiktokapis.com/v2/oauth/token/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_key: clientKey,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("TikTok token refresh failed.");
|
||||
const data = (await response.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
};
|
||||
return data.access_token ?? null;
|
||||
}
|
||||
|
||||
export async function getLatestTiktokVideos(): Promise<LatestVideo[]> {
|
||||
const token = await getTiktokAccessToken();
|
||||
if (!token) return [];
|
||||
const response = await fetch(
|
||||
"https://open.tiktokapis.com/v2/video/list/?fields=id,title,cover_image_url,share_url,create_time",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ max_count: 10 }),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error("TikTok video list failed.");
|
||||
const data = (await response.json()) as {
|
||||
data?: {
|
||||
videos?: Array<{
|
||||
id: string;
|
||||
title?: string;
|
||||
cover_image_url?: string;
|
||||
share_url?: string;
|
||||
create_time?: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
return (data.data?.videos ?? []).map((video) => ({
|
||||
id: video.id,
|
||||
title: video.title ?? "Untitled",
|
||||
thumbnailUrl: video.cover_image_url ?? "",
|
||||
url: video.share_url ?? `https://www.tiktok.com/`,
|
||||
createdAt: video.create_time ?? 0,
|
||||
platform: "tiktok" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
async function youtubeAccessToken() {
|
||||
const refreshToken = process.env.YOUTUBE_REFRESH_TOKEN;
|
||||
const clientId = process.env.YOUTUBE_CLIENT_ID;
|
||||
const clientSecret = process.env.YOUTUBE_CLIENT_SECRET;
|
||||
if (!refreshToken || !clientId || !clientSecret)
|
||||
throw new Error("YouTube OAuth is not configured.");
|
||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("YouTube token refresh failed.");
|
||||
const data = (await response.json()) as { access_token?: string };
|
||||
if (!data.access_token)
|
||||
throw new Error("YouTube did not return an access token.");
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
async function publishYoutube(upload: typeof videoUploads.$inferSelect) {
|
||||
const token = await youtubeAccessToken();
|
||||
const settings = upload.youtubeSettings as YoutubeSettings;
|
||||
const body = {
|
||||
snippet: {
|
||||
title: upload.title,
|
||||
description: upload.description,
|
||||
tags: settings.tags,
|
||||
categoryId: settings.categoryId ?? "22",
|
||||
},
|
||||
status: {
|
||||
privacyStatus: upload.scheduledAt
|
||||
? "private"
|
||||
: (settings.privacyStatus ?? "public"),
|
||||
publishAt: upload.scheduledAt?.toISOString(),
|
||||
license: settings.license ?? "youtube",
|
||||
selfDeclaredMadeForKids: settings.madeForKids ?? false,
|
||||
containsSyntheticMedia: settings.containsSyntheticMedia ?? false,
|
||||
},
|
||||
};
|
||||
const init = await fetch(
|
||||
"https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
"X-Upload-Content-Type": upload.mimeType,
|
||||
"X-Upload-Content-Length": String(upload.size),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!init.ok)
|
||||
throw new Error(`YouTube upload initialization failed (${init.status}).`);
|
||||
const location = init.headers.get("location");
|
||||
if (!location) throw new Error("YouTube did not return an upload URL.");
|
||||
const video = await readFile(upload.videoPath);
|
||||
const result = await fetch(location, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": upload.mimeType,
|
||||
"Content-Length": String(video.byteLength),
|
||||
},
|
||||
body: video,
|
||||
});
|
||||
if (!result.ok) throw new Error(`YouTube upload failed (${result.status}).`);
|
||||
const data = (await result.json()) as { id?: string };
|
||||
if (!data.id) throw new Error("YouTube did not return a video ID.");
|
||||
if (upload.thumbnailPath) {
|
||||
const thumbnail = await readFile(upload.thumbnailPath);
|
||||
const thumb = await fetch(
|
||||
`https://www.googleapis.com/upload/youtube/v3/thumbnails/set?videoId=${encodeURIComponent(data.id)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "image/jpeg",
|
||||
"Content-Length": String(thumbnail.byteLength),
|
||||
},
|
||||
body: thumbnail,
|
||||
},
|
||||
);
|
||||
if (!thumb.ok)
|
||||
throw new Error(`YouTube thumbnail upload failed (${thumb.status}).`);
|
||||
}
|
||||
return { id: data.id, url: `https://www.youtube.com/watch?v=${data.id}` };
|
||||
}
|
||||
|
||||
async function publishTiktok(upload: typeof videoUploads.$inferSelect) {
|
||||
const token = await getTiktokAccessToken();
|
||||
if (!token) throw new Error("TikTok OAuth is not configured.");
|
||||
const settings = upload.tiktokSettings as TiktokSettings;
|
||||
const creatorResponse = await fetch(
|
||||
"https://open.tiktokapis.com/v2/post/publish/creator_info/query/",
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!creatorResponse.ok)
|
||||
throw new Error("TikTok creator information lookup failed.");
|
||||
const creator = (await creatorResponse.json()) as {
|
||||
data?: { privacy_level_options?: string[] };
|
||||
};
|
||||
const privacy =
|
||||
settings.privacyLevel ??
|
||||
creator.data?.privacy_level_options?.[0] ??
|
||||
"SELF_ONLY";
|
||||
const video = await readFile(upload.videoPath);
|
||||
const chunkSize = Math.min(10 * 1024 * 1024, video.byteLength);
|
||||
const init = await fetch(
|
||||
"https://open.tiktokapis.com/v2/post/publish/video/init/",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
post_info: {
|
||||
title: upload.title,
|
||||
privacy_level: privacy,
|
||||
disable_comment: settings.disableComment ?? false,
|
||||
disable_duet: settings.disableDuet ?? false,
|
||||
disable_stitch: settings.disableStitch ?? false,
|
||||
video_cover_timestamp_ms: settings.coverTimestampMs ?? 0,
|
||||
brand_content_toggle: settings.brandContentToggle ?? false,
|
||||
brand_organic_toggle: settings.brandOrganicToggle ?? false,
|
||||
is_aigc: settings.isAigc ?? false,
|
||||
},
|
||||
source_info: {
|
||||
source: "FILE_UPLOAD",
|
||||
video_size: video.byteLength,
|
||||
chunk_size: chunkSize,
|
||||
total_chunk_count: Math.ceil(video.byteLength / chunkSize),
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!init.ok)
|
||||
throw new Error(`TikTok upload initialization failed (${init.status}).`);
|
||||
const initData = (await init.json()) as {
|
||||
data?: { publish_id?: string; upload_url?: string };
|
||||
};
|
||||
const uploadUrl = initData.data?.upload_url;
|
||||
if (!uploadUrl || !initData.data?.publish_id)
|
||||
throw new Error("TikTok did not return an upload ticket.");
|
||||
for (let start = 0; start < video.byteLength; start += chunkSize) {
|
||||
const end = Math.min(start + chunkSize, video.byteLength) - 1;
|
||||
const result = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": upload.mimeType,
|
||||
"Content-Length": String(end - start + 1),
|
||||
"Content-Range": `bytes ${start}-${end}/${video.byteLength}`,
|
||||
},
|
||||
body: video.subarray(start, end + 1),
|
||||
});
|
||||
if (!result.ok)
|
||||
throw new Error(`TikTok video transfer failed (${result.status}).`);
|
||||
}
|
||||
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));
|
||||
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));
|
||||
} 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));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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(() => undefined);
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import "server-only";
|
||||
|
||||
import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
videoUploadJobs,
|
||||
videoUploads,
|
||||
type TiktokSettings,
|
||||
type YoutubeSettings,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
validateThumbnailFile,
|
||||
validateVideoFile,
|
||||
publishVideoJob,
|
||||
} from "@/lib/video/video-platforms";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const VIDEO_DIR = process.env.VIDEO_UPLOAD_DIR || "/nfs/erika";
|
||||
|
||||
export async function saveVideoUpload(formData: FormData, discordId: string) {
|
||||
const video = formData.get("video");
|
||||
const stagedVideoId = String(formData.get("stagedVideoId") ?? "");
|
||||
const thumbnail = formData.get("thumbnail");
|
||||
|
||||
let videoPath: string;
|
||||
let originalFilename: string;
|
||||
let mimeType: string;
|
||||
let videoSize: number;
|
||||
|
||||
if (stagedVideoId) {
|
||||
if (!/^[0-9a-f-]{36}$/i.test(stagedVideoId)) {
|
||||
throw new Error("Invalid staged video.");
|
||||
}
|
||||
const stagingDirectory = `${VIDEO_DIR}/staging/${discordId}`;
|
||||
const stagedFile = (await readdir(stagingDirectory)).find((name) =>
|
||||
name.startsWith(`${stagedVideoId}-`),
|
||||
);
|
||||
if (!stagedFile) throw new Error("Uploaded video was not found.");
|
||||
videoPath = `${stagingDirectory}/${stagedFile}`;
|
||||
originalFilename = String(formData.get("videoName") ?? stagedFile);
|
||||
mimeType = String(formData.get("videoMimeType") ?? "");
|
||||
videoSize = (await stat(videoPath)).size;
|
||||
if (!["video/mp4", "video/quicktime", "video/webm"].includes(mimeType)) {
|
||||
throw new Error("Use an MP4, MOV, or WebM video.");
|
||||
}
|
||||
} else if (video instanceof File) {
|
||||
validateVideoFile(video);
|
||||
await mkdir(VIDEO_DIR, { recursive: true });
|
||||
const temporaryId = randomUUID();
|
||||
const safeVideoName = video.name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
videoPath = `${VIDEO_DIR}/${temporaryId}-${safeVideoName}`;
|
||||
await writeFile(videoPath, Buffer.from(await video.arrayBuffer()));
|
||||
originalFilename = video.name;
|
||||
mimeType = video.type;
|
||||
videoSize = video.size;
|
||||
} else {
|
||||
throw new Error("Choose and finish uploading a video first.");
|
||||
}
|
||||
|
||||
if (thumbnail instanceof File && thumbnail.size > 0)
|
||||
validateThumbnailFile(thumbnail);
|
||||
const title = String(formData.get("title") ?? "").trim();
|
||||
if (!title) throw new Error("Title is required.");
|
||||
const scheduledValue = String(formData.get("scheduledAt") ?? "").trim();
|
||||
const scheduledAt = scheduledValue ? new Date(scheduledValue) : null;
|
||||
if (scheduledAt && Number.isNaN(scheduledAt.getTime()))
|
||||
throw new Error("Invalid schedule date.");
|
||||
if (scheduledAt && scheduledAt <= new Date())
|
||||
throw new Error("Schedule must be in the future.");
|
||||
|
||||
const id = randomUUID();
|
||||
let thumbnailPath: string | null = null;
|
||||
if (thumbnail instanceof File && thumbnail.size > 0) {
|
||||
thumbnailPath = `${VIDEO_DIR}/${id}-thumbnail-${thumbnail.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
|
||||
await writeFile(thumbnailPath, Buffer.from(await thumbnail.arrayBuffer()));
|
||||
}
|
||||
|
||||
const youtubeSettings: YoutubeSettings = JSON.parse(
|
||||
String(formData.get("youtubeSettings") || "{}"),
|
||||
);
|
||||
const tiktokSettings: TiktokSettings = JSON.parse(
|
||||
String(formData.get("tiktokSettings") || "{}"),
|
||||
);
|
||||
const [upload] = await db
|
||||
.insert(videoUploads)
|
||||
.values({
|
||||
id,
|
||||
title,
|
||||
description: String(formData.get("description") ?? ""),
|
||||
videoPath,
|
||||
thumbnailPath,
|
||||
originalFilename,
|
||||
mimeType,
|
||||
size: videoSize,
|
||||
scheduledAt,
|
||||
status: scheduledAt ? "scheduled" : "processing",
|
||||
youtubeSettings,
|
||||
tiktokSettings,
|
||||
createdByDiscordId: discordId,
|
||||
})
|
||||
.returning({ id: videoUploads.id });
|
||||
const jobs = await db
|
||||
.insert(videoUploadJobs)
|
||||
.values([
|
||||
{ uploadId: upload.id, platform: "youtube" },
|
||||
{ uploadId: upload.id, platform: "tiktok" },
|
||||
])
|
||||
.returning({ id: videoUploadJobs.id });
|
||||
if (!scheduledAt) {
|
||||
await Promise.allSettled(jobs.map((job) => publishVideoJob(job.id)));
|
||||
const remaining = await db.query.videoUploadJobs.findMany({
|
||||
where: (table, { eq, and, ne }) =>
|
||||
and(eq(table.uploadId, upload.id), ne(table.status, "completed")),
|
||||
});
|
||||
await db
|
||||
.update(videoUploads)
|
||||
.set({
|
||||
status: remaining.length === 0 ? "completed" : "partial",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(videoUploads.id, upload.id));
|
||||
}
|
||||
return upload.id;
|
||||
}
|
||||
Reference in New Issue
Block a user