feat: implement admin video upload system with platform integration for YouTube and TikTok

This commit is contained in:
2026-08-02 02:07:35 +07:00 Unverified
parent 7cec08fb77
commit 698b12039a
22 changed files with 1727 additions and 14 deletions
-9
View File
@@ -42,13 +42,4 @@ next-env.d.ts
public/form
drizzle/*
!drizzle/0010_rainy_speedball.sql
!drizzle/0012_clammy_the_stranger.sql
!drizzle/0013_regular_unus.sql
!drizzle/0014_lethal_pepper_potts.sql
!drizzle/0015_form_closes_at.sql
!drizzle/0016_wide_talos.sql
!drizzle/meta/
drizzle/meta/*
!drizzle/meta/0015_snapshot.json
!drizzle/meta/0016_snapshot.json
+2
View File
@@ -57,6 +57,7 @@ bun run secrets:scan
- `/leaderboard/vc` — voice activity leaderboard
- `/sse/[topic]` — authenticated realtime updates
- `/api/upload` — authenticated image uploads stored in PostgreSQL
- `/admin/upload` — admin video publishing workspace for YouTube and TikTok
Forms use server actions for authorization, persistence, validation, Discord webhooks, and cache invalidation. Public form drafts are stored locally in the browser.
@@ -78,4 +79,5 @@ The production image is built in separate dependency, build, and runner stages.
- Form access checks allowed and denied Discord roles.
- Uploaded image contents are checked against their declared image type before storage.
- TikTok OAuth is a private setup utility and requires an admin session plus verified OAuth state.
- Video files are stored under `/nfs/erika`; configure `VIDEO_UPLOAD_DIR` only if the mounted path differs.
- Rotate credentials if an environment file, build cache, logs, or deployment host may have been exposed.
+19
View File
@@ -0,0 +1,19 @@
"use server";
import { requireAdmin } from "@/lib/auth";
import { publishVideoJob } from "@/lib/video-platforms";
import { revalidatePath } from "next/cache";
export async function createVideoUpload(formData: FormData) {
const discordId = await requireAdmin();
const { saveVideoUpload } = await import("@/lib/video-upload-storage");
const id = await saveVideoUpload(formData, discordId);
revalidatePath("/admin/upload");
return { id };
}
export async function retryVideoJob(jobId: string) {
await requireAdmin();
await publishVideoJob(jobId).catch(() => undefined);
revalidatePath("/admin/upload");
}
+493
View File
@@ -0,0 +1,493 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import { CalendarClock, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { DateTimePicker } from "@/components/date-time-picker";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
export function UploadForm() {
const formRef = useRef<HTMLFormElement>(null);
const uploadRequestRef = useRef<XMLHttpRequest | null>(null);
const [pending, startTransition] = useTransition();
const [scheduledAt, setScheduledAt] = useState<Date>();
const [video, setVideo] = useState<File | null>(null);
const [stagedVideoId, setStagedVideoId] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatus, setUploadStatus] = useState<
"idle" | "uploading" | "completed" | "failed"
>("idle");
const [youtube, setYoutube] = useState({
privacyStatus: "public",
categoryId: "22",
tags: "",
madeForKids: false,
containsSyntheticMedia: false,
});
const [tiktok, setTiktok] = useState({
privacyLevel: "PUBLIC_TO_EVERYONE",
disableComment: false,
disableDuet: false,
disableStitch: false,
coverTimestampMs: 0,
isAigc: false,
});
function uploadVideo(file: File) {
uploadRequestRef.current?.abort();
setVideo(file);
setStagedVideoId(null);
setUploadProgress(0);
setUploadStatus("uploading");
const request = new XMLHttpRequest();
uploadRequestRef.current = request;
request.open("POST", "/api/admin/video-file");
request.setRequestHeader("Content-Type", file.type);
request.setRequestHeader("X-File-Name", encodeURIComponent(file.name));
request.upload.onprogress = (event) => {
if (event.lengthComputable) {
setUploadProgress(Math.round((event.loaded / event.total) * 100));
}
};
request.onload = () => {
try {
const data = JSON.parse(request.responseText) as {
stagedVideoId?: string;
error?: string;
};
if (
request.status < 200 ||
request.status >= 300 ||
!data.stagedVideoId
) {
throw new Error(data.error || "Video upload failed.");
}
setStagedVideoId(data.stagedVideoId);
setUploadProgress(100);
setUploadStatus("completed");
toast.success("Video uploaded. You can finish the metadata now.");
} catch (error) {
setUploadStatus("failed");
toast.error(
error instanceof Error ? error.message : "Video upload failed.",
);
}
};
request.onerror = () => {
setUploadStatus("failed");
toast.error(
"Video upload failed because the connection was interrupted.",
);
};
request.send(file);
}
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
form.delete("video");
form.set("stagedVideoId", stagedVideoId ?? "");
form.set("videoName", video?.name ?? "");
form.set("videoMimeType", video?.type ?? "");
form.set("scheduledAt", scheduledAt?.toISOString() ?? "");
form.set(
"youtubeSettings",
JSON.stringify({
...youtube,
tags: youtube.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
}),
);
form.set(
"tiktokSettings",
JSON.stringify({
...tiktok,
coverTimestampMs: Number(tiktok.coverTimestampMs) || 0,
}),
);
startTransition(async () => {
try {
const response = await fetch("/api/admin/video-upload", {
method: "POST",
body: form,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Upload failed.");
}
toast.success(scheduledAt ? "Upload scheduled." : "Upload started.");
formRef.current?.reset();
setVideo(null);
setStagedVideoId(null);
setUploadProgress(0);
setUploadStatus("idle");
setScheduledAt(undefined);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed.");
}
});
}
return (
<form
ref={formRef}
onSubmit={submit}
className="grid items-start gap-4 md:grid-cols-2"
>
<Card>
<CardHeader>
<CardTitle>New upload</CardTitle>
<CardDescription>One source file, two platform jobs.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<FieldGroup>
<Field>
<FieldLabel htmlFor="video">Video file</FieldLabel>
<Input
id="video"
type="file"
accept="video/mp4,video/quicktime,video/webm"
required
onChange={(event) => {
const file = event.target.files?.[0];
if (file) uploadVideo(file);
}}
/>
<FieldDescription>
{video
? `${video.name} · ${(video.size / 1024 / 1024).toFixed(1)} MB`
: "MP4, MOV, or WebM. No application size limit."}
</FieldDescription>
</Field>
{uploadStatus !== "idle" && (
<Field>
<div className="flex items-center justify-between gap-3 text-sm">
<FieldLabel>Background upload</FieldLabel>
<span className="text-muted-foreground">
{uploadStatus === "failed"
? "Failed"
: `${uploadProgress}%`}
</span>
</div>
<Progress value={uploadProgress} />
<FieldDescription>
{uploadStatus === "completed"
? "Upload complete. The file is ready to publish."
: uploadStatus === "failed"
? "Choose the file again to retry."
: "You can continue filling in metadata while this runs."}
</FieldDescription>
</Field>
)}
<Field>
<FieldLabel htmlFor="thumbnail">YouTube thumbnail</FieldLabel>
<Input
id="thumbnail"
name="thumbnail"
type="file"
accept="image/*"
/>
<FieldDescription>
TikTok uses the video frame selected below.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="title">Shared title / caption</FieldLabel>
<Input
id="title"
name="title"
placeholder="Give this video a title"
required
maxLength={2200}
/>
</Field>
<Field>
<FieldLabel htmlFor="description">YouTube description</FieldLabel>
<Textarea
id="description"
name="description"
rows={7}
placeholder="Tell viewers about this video"
/>
</Field>
<Field>
<FieldLabel htmlFor="scheduledAt">Publish date</FieldLabel>
<DateTimePicker
id="scheduledAt"
value={scheduledAt}
onChange={setScheduledAt}
placeholder="Publish immediately"
/>
<FieldDescription>
Leave empty to publish as soon as both platforms accept the
upload.
</FieldDescription>
</Field>
</FieldGroup>
</CardContent>
</Card>
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>YouTube</CardTitle>
<CardDescription>
Platform-specific publishing settings.
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel>Visibility</FieldLabel>
<Select
value={youtube.privacyStatus}
onValueChange={(value) =>
setYoutube((state) => ({
...state,
privacyStatus: value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="public">Public</SelectItem>
<SelectItem value="unlisted">Unlisted</SelectItem>
<SelectItem value="private">Private</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="category">Category ID</FieldLabel>
<Input
id="category"
value={youtube.categoryId}
onChange={(event) =>
setYoutube((state) => ({
...state,
categoryId: event.target.value,
}))
}
/>
</Field>
<Field>
<FieldLabel htmlFor="tags">Tags</FieldLabel>
<Input
id="tags"
value={youtube.tags}
onChange={(event) =>
setYoutube((state) => ({
...state,
tags: event.target.value,
}))
}
placeholder="gaming, erika, shorts"
/>
</Field>
<Field orientation="horizontal">
<Checkbox
checked={youtube.madeForKids}
onCheckedChange={(checked) =>
setYoutube((state) => ({
...state,
madeForKids: checked === true,
}))
}
/>
<FieldLabel>Made for kids</FieldLabel>
</Field>
<Field orientation="horizontal">
<Checkbox
checked={youtube.containsSyntheticMedia}
onCheckedChange={(checked) =>
setYoutube((state) => ({
...state,
containsSyntheticMedia: checked === true,
}))
}
/>
<FieldLabel>Contains altered or synthetic media</FieldLabel>
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>TikTok</CardTitle>
<CardDescription>Direct Post controls.</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel>Privacy</FieldLabel>
<Select
value={tiktok.privacyLevel}
onValueChange={(value) =>
setTiktok((state) => ({
...state,
privacyLevel: value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="PUBLIC_TO_EVERYONE">
Everyone
</SelectItem>
<SelectItem value="FOLLOWER_OF_CREATOR">
Followers
</SelectItem>
<SelectItem value="MUTUAL_FOLLOW_FRIENDS">
Friends
</SelectItem>
<SelectItem value="SELF_ONLY">Only me</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="cover">
Cover frame (milliseconds)
</FieldLabel>
<Input
id="cover"
type="number"
min="0"
value={tiktok.coverTimestampMs}
onChange={(event) =>
setTiktok((state) => ({
...state,
coverTimestampMs: Number(event.target.value),
}))
}
/>
</Field>
<FieldSet>
<FieldLegend variant="label">Engagement</FieldLegend>
<Field orientation="horizontal">
<Switch
checked={!tiktok.disableComment}
onCheckedChange={(checked) =>
setTiktok((state) => ({
...state,
disableComment: !checked,
}))
}
/>
<FieldLabel>Allow comments</FieldLabel>
</Field>
<Field orientation="horizontal">
<Switch
checked={!tiktok.disableDuet}
onCheckedChange={(checked) =>
setTiktok((state) => ({
...state,
disableDuet: !checked,
}))
}
/>
<FieldLabel>Allow Duet</FieldLabel>
</Field>
<Field orientation="horizontal">
<Switch
checked={!tiktok.disableStitch}
onCheckedChange={(checked) =>
setTiktok((state) => ({
...state,
disableStitch: !checked,
}))
}
/>
<FieldLabel>Allow Stitch</FieldLabel>
</Field>
</FieldSet>
<Field orientation="horizontal">
<Checkbox
checked={tiktok.isAigc}
onCheckedChange={(checked) =>
setTiktok((state) => ({
...state,
isAigc: checked === true,
}))
}
/>
<FieldLabel>AI-generated content</FieldLabel>
</Field>
</FieldGroup>
</CardContent>
</Card>
<Button
type="submit"
size="lg"
disabled={pending || uploadStatus !== "completed"}
>
{pending ? (
<CalendarClock data-icon="inline-start" />
) : (
<Upload data-icon="inline-start" />
)}
{pending
? "Sending…"
: scheduledAt
? "Schedule both uploads"
: "Upload to both platforms"}
</Button>
</div>
</form>
);
}
+229
View File
@@ -0,0 +1,229 @@
import { AdminShellServer } from "@/components/admin-shell-server";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
getLatestTiktokVideos,
getLatestYoutubeVideos,
} from "@/lib/video-platforms";
import { db } from "@/db";
import { videoUploads } from "@/db/schema";
import { desc } from "drizzle-orm";
import {
Clapperboard,
ExternalLink,
History,
PlaySquare,
UploadCloud,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { UploadForm } from "./client";
import { retryVideoJob } from "./actions";
export const dynamic = "force-dynamic";
function statusVariant(status: string) {
return status === "completed"
? "default"
: status === "failed"
? "destructive"
: "secondary";
}
export default async function AdminUploadPage() {
const [youtube, tiktok, uploads] = await Promise.all([
getLatestYoutubeVideos().catch(() => []),
getLatestTiktokVideos().catch(() => []),
db.query.videoUploads.findMany({
orderBy: [desc(videoUploads.createdAt)],
limit: 20,
}),
]);
const allJobs = uploads.length
? await db.query.videoUploadJobs.findMany({
where: (table, { inArray }) =>
inArray(
table.uploadId,
uploads.map((upload) => upload.id),
),
})
: [];
const jobsByUpload = new Map<string, typeof allJobs>();
for (const job of allJobs)
jobsByUpload.set(job.uploadId, [
...(jobsByUpload.get(job.uploadId) ?? []),
job,
]);
return (
<AdminShellServer>
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 pb-12 md:p-6">
<div>
<p className="text-sm font-medium text-primary">Creator studio</p>
<h1 className="text-3xl font-bold tracking-tight">
Upload everywhere
</h1>
<p className="mt-1 text-muted-foreground">
Prepare one video, then publish it to YouTube and TikTok together.
</p>
</div>
<Tabs defaultValue="upload" className="w-full">
<TabsList className="grid w-full grid-cols-3 md:w-fit">
<TabsTrigger value="upload">
<UploadCloud data-icon="inline-start" />
Upload
</TabsTrigger>
<TabsTrigger value="recent">
<Clapperboard data-icon="inline-start" />
Recent videos
</TabsTrigger>
<TabsTrigger value="history">
<History data-icon="inline-start" />
History
</TabsTrigger>
</TabsList>
<TabsContent value="upload">
<UploadForm />
</TabsContent>
<TabsContent value="recent">
<section className="grid gap-4 xl:grid-cols-2">
<LatestCard title="Latest YouTube videos" videos={youtube} />
<LatestCard title="Latest TikTok videos" videos={tiktok} />
</section>
</TabsContent>
<TabsContent value="history">
<Card>
<CardHeader>
<CardTitle>Upload history</CardTitle>
<CardDescription>
Scheduled and recent publishing jobs.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{uploads.length === 0 ? (
<p className="text-sm text-muted-foreground">
No uploads yet.
</p>
) : (
uploads.map((upload) => (
<div
key={upload.id}
className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-center md:justify-between"
>
<div className="min-w-0">
<p className="truncate font-medium">{upload.title}</p>
<p className="text-sm text-muted-foreground">
{upload.scheduledAt
? `Scheduled ${upload.scheduledAt.toLocaleString("en-GB", { timeZone: "Asia/Bangkok" })}`
: upload.createdAt.toLocaleString("en-GB", {
timeZone: "Asia/Bangkok",
})}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={statusVariant(upload.status)}>
{upload.status}
</Badge>
{(jobsByUpload.get(upload.id) ?? []).map((job) => (
<span
key={job.id}
className="flex items-center gap-1"
>
<Badge variant={statusVariant(job.status)}>
{job.platform}: {job.status}
</Badge>
{job.status === "failed" && (
<form action={retryVideoJob.bind(null, job.id)}>
<Button
type="submit"
size="sm"
variant="outline"
>
Retry
</Button>
</form>
)}
</span>
))}
</div>
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</main>
</AdminShellServer>
);
}
function LatestCard({
title,
videos,
}: {
title: string;
videos: Array<{
id: string;
title: string;
thumbnailUrl: string;
url: string;
platform: "youtube" | "tiktok";
}>;
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>Most recent 10 published videos.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
{videos.length === 0 ? (
<p className="text-sm text-muted-foreground">
Connect the platform API to load videos.
</p>
) : (
videos.map((video) => (
<Link
key={video.id}
href={video.url}
target="_blank"
className="group flex gap-3 rounded-lg border p-2 transition-colors hover:border-primary/60"
>
<div className="relative aspect-square w-28 shrink-0 overflow-hidden rounded-md bg-muted">
{video.thumbnailUrl ? (
<Image
src={video.thumbnailUrl}
alt=""
fill
sizes="112px"
unoptimized={video.platform === "tiktok"}
referrerPolicy="no-referrer"
className="object-cover"
/>
) : (
<PlaySquare className="absolute inset-0 m-auto text-muted-foreground" />
)}
</div>
<span className="min-w-0 text-sm font-medium leading-snug group-hover:underline">
{video.title}
</span>
<ExternalLink className="mt-0.5 shrink-0 text-muted-foreground" />
</Link>
))
)}
</CardContent>
</Card>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { createWriteStream } from "node:fs";
import { mkdir, unlink } from "node:fs/promises";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth";
export const dynamic = "force-dynamic";
const VIDEO_DIR = process.env.VIDEO_UPLOAD_DIR || "/nfs/erika";
const VIDEO_TYPES = new Set(["video/mp4", "video/quicktime", "video/webm"]);
function safeFileName(value: string) {
return value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(-180) || "video";
}
export async function POST(request: Request) {
let filePath: string | null = null;
try {
const discordId = await requireAdmin();
const encodedName = request.headers.get("x-file-name");
const mimeType = request.headers.get("content-type") ?? "";
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (!encodedName || !request.body) {
return NextResponse.json(
{ error: "No video provided." },
{ status: 400 },
);
}
if (!VIDEO_TYPES.has(mimeType)) {
return NextResponse.json(
{ error: "Use an MP4, MOV, or WebM video." },
{ status: 400 },
);
}
const originalFilename = decodeURIComponent(encodedName);
const stagedVideoId = randomUUID();
const stagingDirectory = `${VIDEO_DIR}/staging/${discordId}`;
filePath = `${stagingDirectory}/${stagedVideoId}-${safeFileName(originalFilename)}`;
await mkdir(stagingDirectory, { recursive: true });
await pipeline(
Readable.fromWeb(request.body as never),
createWriteStream(filePath, { flags: "wx" }),
);
return NextResponse.json({
stagedVideoId,
originalFilename,
mimeType,
size: contentLength,
});
} catch (error) {
if (filePath) await unlink(filePath).catch(() => undefined);
const message = error instanceof Error ? error.message : "Upload failed.";
const status =
message === "Unauthorized" || message === "Not authenticated" ? 401 : 400;
return NextResponse.json({ error: message }, { status });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth";
import { saveVideoUpload } from "@/lib/video-upload-storage";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
try {
const discordId = await requireAdmin();
const id = await saveVideoUpload(await request.formData(), discordId);
return NextResponse.json({ id });
} catch (error) {
const message = error instanceof Error ? error.message : "Upload failed.";
const status =
message === "Unauthorized" || message === "Not authenticated" ? 401 : 400;
return NextResponse.json({ error: message }, { status });
}
}
+1
View File
@@ -20,6 +20,7 @@ function escapeHtml(value: unknown) {
* Displays the tokens so you can copy them to your .env file.
*/
export async function GET(request: NextRequest) {
await requireAdmin();
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
+2 -2
View File
@@ -11,7 +11,7 @@ const TIKTOK_OAUTH_STATE_COOKIE = "tiktok-oauth-state";
* Visit this URL once to start the OAuth flow and get your refresh token.
*/
export async function GET() {
await requireAdmin();
const clientKey = process.env.TIKTOK_CLIENT_KEY;
const redirectUri = `${process.env.BASE_URL}/api/auth/tiktok/callback`;
@@ -27,7 +27,7 @@ export async function GET() {
const params = new URLSearchParams({
client_key: clientKey,
scope: "user.info.basic,user.info.profile,user.info.stats,video.list",
scope: "user.info.basic,user.info.profile,user.info.stats,video.list,video.publish",
response_type: "code",
redirect_uri: redirectUri,
state: csrfState,
+50
View File
@@ -0,0 +1,50 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth";
function escapeHtml(value: unknown) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
export async function GET(request: NextRequest) {
await requireAdmin();
const params = new URL(request.url).searchParams;
const cookieStore = await cookies();
const expected = cookieStore.get("youtube-oauth-state")?.value;
cookieStore.delete("youtube-oauth-state");
if (!expected || expected !== params.get("state"))
return NextResponse.json(
{ error: "Invalid OAuth state." },
{ status: 400 },
);
const code = params.get("code");
if (!code)
return NextResponse.json(
{ error: "No authorization code received." },
{ status: 400 },
);
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: process.env.YOUTUBE_CLIENT_ID ?? "",
client_secret: process.env.YOUTUBE_CLIENT_SECRET ?? "",
redirect_uri: `${process.env.BASE_URL}/api/auth/youtube/callback`,
grant_type: "authorization_code",
}),
});
const data = (await response.json()) as Record<string, unknown>;
if (!response.ok || !data.refresh_token)
return NextResponse.json(
{ error: "YouTube token exchange failed." },
{ status: 400 },
);
const html = `<!doctype html><html><body style="font-family:system-ui;max-width:720px;margin:40px auto;padding:20px"><h1>YouTube OAuth success</h1><p>Copy these values into your server environment:</p><pre>YOUTUBE_REFRESH_TOKEN=${escapeHtml(data.refresh_token)}\nYOUTUBE_ACCESS_TOKEN=${escapeHtml(data.access_token)}</pre><p>Restart the app after saving them.</p></body></html>`;
return new NextResponse(html, { headers: { "Content-Type": "text/html" } });
}
+37
View File
@@ -0,0 +1,37 @@
import { randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth";
const COOKIE = "youtube-oauth-state";
export async function GET() {
await requireAdmin();
const clientId = process.env.YOUTUBE_CLIENT_ID;
const redirectUri = `${process.env.BASE_URL}/api/auth/youtube/callback`;
if (!clientId)
return NextResponse.json(
{ error: "YOUTUBE_CLIENT_ID is not configured." },
{ status: 500 },
);
const state = randomBytes(32).toString("hex");
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
url.search = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
access_type: "offline",
prompt: "consent",
scope: "https://www.googleapis.com/auth/youtube.upload",
state,
}).toString();
const response = NextResponse.redirect(url);
(await cookies()).set(COOKIE, state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 600,
path: "/api/auth/youtube",
});
return response;
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { processDueVideoUploads } from "@/lib/video-platforms";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const authorization = request.headers.get("authorization");
if (
!process.env.CRON_SECRET ||
authorization !== `Bearer ${process.env.CRON_SECRET}`
)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
await processDueVideoUploads();
return NextResponse.json({ ok: true });
}
+13 -1
View File
@@ -15,7 +15,7 @@ import {
SidebarInset,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { ClipboardList, Server, LayoutDashboard, LucideIcon } from "lucide-react";
import { ClipboardList, KeyRound, Server, LayoutDashboard, Upload, LucideIcon } from "lucide-react";
import { ThemeToggle } from "@/components/theme-toggle";
import { DropdownMenuAvatar, DiscordMemberProfile } from "@/components/dropdown-menu-avatar";
@@ -45,6 +45,18 @@ const NAV_ITEMS: NavItem[] = [
icon: Server,
isActive: (pathname) => pathname.startsWith("/admin/backend"),
},
{
href: "/admin/upload",
label: "Upload",
icon: Upload,
isActive: (pathname) => pathname.startsWith("/admin/upload"),
},
{
href: "/admin/login",
label: "Login",
icon: KeyRound,
isActive: (pathname) => pathname.startsWith("/admin/login"),
},
];
export function AdminShell({
+1
View File
@@ -1,2 +1,3 @@
export * from "./schema/account";
export * from "./schema/form";
export * from "./schema/video";
+117
View File
@@ -0,0 +1,117 @@
import {
index,
bigint,
integer,
jsonb,
pgTable,
text,
timestamp,
} from "drizzle-orm/pg-core";
import { uuidv7 } from "uuidv7";
export type VideoUploadStatus =
| "draft"
| "scheduled"
| "processing"
| "completed"
| "partial"
| "failed";
export type VideoPlatform = "youtube" | "tiktok";
export type VideoJobStatus = "pending" | "processing" | "completed" | "failed";
export const videoUploads = pgTable(
"video_upload",
{
id: text("id")
.primaryKey()
.$defaultFn(() => uuidv7()),
title: text("title").notNull(),
description: text("description").notNull().default(""),
videoPath: text("video_path").notNull(),
thumbnailPath: text("thumbnail_path"),
originalFilename: text("original_filename").notNull(),
mimeType: text("mime_type").notNull(),
size: bigint("size", { mode: "number" }).notNull(),
scheduledAt: timestamp("scheduled_at", {
mode: "date",
withTimezone: true,
}),
status: text("status")
.$type<VideoUploadStatus>()
.notNull()
.default("draft"),
youtubeSettings: jsonb("youtube_settings")
.$type<YoutubeSettings>()
.notNull()
.default({}),
tiktokSettings: jsonb("tiktok_settings")
.$type<TiktokSettings>()
.notNull()
.default({}),
createdByDiscordId: text("created_by_discord_id").notNull(),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [
index("video_upload_status_scheduled_idx").on(
table.status,
table.scheduledAt,
),
],
);
export const videoUploadJobs = pgTable(
"video_upload_job",
{
id: text("id")
.primaryKey()
.$defaultFn(() => uuidv7()),
uploadId: text("upload_id")
.notNull()
.references(() => videoUploads.id, { onDelete: "cascade" }),
platform: text("platform").$type<VideoPlatform>().notNull(),
status: text("status").$type<VideoJobStatus>().notNull().default("pending"),
externalId: text("external_id"),
externalUrl: text("external_url"),
error: text("error"),
attempts: integer("attempts").notNull().default(0),
startedAt: timestamp("started_at", { mode: "date", withTimezone: true }),
completedAt: timestamp("completed_at", {
mode: "date",
withTimezone: true,
}),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [
index("video_upload_job_upload_platform_idx").on(
table.uploadId,
table.platform,
),
],
);
export type YoutubeSettings = {
tags?: string[];
categoryId?: string;
privacyStatus?: "public" | "private" | "unlisted";
license?: "youtube" | "creativeCommon";
madeForKids?: boolean;
containsSyntheticMedia?: boolean;
};
export type TiktokSettings = {
privacyLevel?: string;
disableComment?: boolean;
disableDuet?: boolean;
disableStitch?: boolean;
coverTimestampMs?: number;
brandContentToggle?: boolean;
brandOrganicToggle?: boolean;
isAigc?: boolean;
};
+2
View File
@@ -8,6 +8,8 @@ services:
- "3000"
env_file:
- .env
volumes:
- /nfs/erika:/nfs/erika
nginx:
image: nginx:alpine
+137
View File
@@ -0,0 +1,137 @@
# คู่มือตั้งค่า YouTube API สำหรับ Erika
คู่มือนี้ใช้สำหรับเชื่อมช่อง YouTube หนึ่งช่องกับหน้า `/admin/upload` เพื่ออ่านรายการวิดีโอล่าสุด อัปโหลดวิดีโอ ตั้งภาพปก และตั้งเวลาเผยแพร่
## 1. สร้าง Google Cloud Project
1. เข้า [Google Cloud Console](https://console.cloud.google.com/)
2. กดชื่อโปรเจกต์ด้านบน แล้วเลือก **New project**
3. ตั้งชื่อ เช่น `Erika YouTube Uploader`
4. กด **Create** และเลือกโปรเจกต์ที่สร้างใหม่
## 2. เปิดใช้ YouTube Data API v3
1. เปิด [YouTube Data API v3](https://console.cloud.google.com/apis/library/youtube.googleapis.com)
2. ตรวจสอบว่าเลือกโปรเจกต์ถูกต้อง
3. กด **Enable**
API นี้ใช้ทั้งอ่านวิดีโอล่าสุดและอัปโหลดวิดีโอ โดยคำขอทุกแบบจะใช้โควตาของโปรเจกต์ ดูรายละเอียดได้ที่ [YouTube Data API Overview](https://developers.google.com/youtube/v3/getting-started)
## 3. ตั้งค่า OAuth consent screen
1. ไปที่ **Google Auth Platform** → **Branding**
2. ใส่ชื่อแอป อีเมลซัพพอร์ต และอีเมลติดต่อผู้พัฒนา
3. ไปที่ **Audience**
4. ถ้าใช้ส่วนตัว ให้เลือก **External** และสถานะ **Testing**
5. เพิ่มบัญชี Google ที่เป็นเจ้าของช่อง YouTube ในรายการ **Test users**
6. ไปที่ **Data Access** และเพิ่ม scope:
```text
https://www.googleapis.com/auth/youtube.upload
```
scope นี้อนุญาตให้ Erika จัดการวิดีโอที่อัปโหลดไปยังช่อง ดูรายละเอียด OAuth ของ YouTube ได้ที่ [OAuth 2.0 for Web Server Applications](https://developers.google.com/youtube/v3/guides/auth/server-side-web-apps)
## 4. สร้าง OAuth Client
1. ไปที่ **Google Auth Platform** → **Clients**
2. กด **Create client**
3. เลือกชนิด **Web application**
4. ตั้งชื่อ เช่น `Erika Admin`
5. ใน **Authorized redirect URIs** เพิ่ม URL นี้ โดยเปลี่ยนโดเมนให้ตรงกับ `BASE_URL`:
```text
https://your-domain.com/api/auth/youtube/callback
```
ตัวอย่าง production:
```text
https://erika.sudloh.com/api/auth/youtube/callback
```
ถ้าทดสอบในเครื่องและ `BASE_URL=http://localhost:4000` ให้เพิ่ม:
```text
http://localhost:4000/api/auth/youtube/callback
```
6. กด **Create** แล้วคัดลอก **Client ID** และ **Client secret**
## 5. สร้าง API Key สำหรับรายการวิดีโอล่าสุด
1. ไปที่ **APIs & Services** → **Credentials**
2. กด **Create credentials** → **API key**
3. เปิด **Edit API key**
4. ใน **API restrictions** เลือก **Restrict key**
5. เลือกเฉพาะ **YouTube Data API v3** แล้วบันทึก
หา Channel ID ได้จาก YouTube Studio หรือ URL/ข้อมูลขั้นสูงของช่อง
## 6. เพิ่มค่าในไฟล์ environment
เพิ่มค่าต่อไปนี้ใน `.env` และ `.env.local` ตามฐานข้อมูล/สภาพแวดล้อมที่ใช้งาน:
```env
BASE_URL=https://your-domain.com
YOUTUBE_API_KEY=your_api_key
YOUTUBE_CHANNEL_ID=your_channel_id
YOUTUBE_CLIENT_ID=your_oauth_client_id
YOUTUBE_CLIENT_SECRET=your_oauth_client_secret
```
อย่าใส่ secret ในตัวแปรที่ขึ้นต้นด้วย `NEXT_PUBLIC_` และอย่า commit ไฟล์ `.env`
## 7. ขอ Refresh Token
1. รีสตาร์ตแอปหลังเพิ่ม environment variables
2. ล็อกอินด้วยบัญชี Discord ที่อยู่ใน `ADMIN_DISCORD_IDS`
3. เปิด URL:
```text
https://your-domain.com/api/auth/youtube
```
4. เลือกบัญชี Google ที่เป็นเจ้าของช่อง YouTube
5. อนุญาตสิทธิ์อัปโหลดวิดีโอ
6. หน้า callback จะแสดง `YOUTUBE_REFRESH_TOKEN`
7. คัดลอกไปใส่ `.env` หรือ `.env.local`:
```env
YOUTUBE_REFRESH_TOKEN=your_refresh_token
```
8. รีสตาร์ตแอปอีกครั้ง
Refresh token เป็นข้อมูลลับ ห้ามส่งให้ผู้อื่นหรือบันทึกลง Git
## 8. ทดสอบ
1. เปิด `/admin/upload`
2. ไปแท็บ **Recent videos** และตรวจสอบว่าวิดีโอ YouTube ล่าสุดแสดงขึ้นมา
3. ไปแท็บ **Upload** เลือกไฟล์วิดีโอและรอแถบ Background upload ครบ 100%
4. กรอกชื่อ รายละเอียด และตั้งค่า YouTube
5. กด **Upload to both platforms** หรือเลือกวันเวลาแล้วกด **Schedule both uploads**
6. ตรวจสอบผลในแท็บ **History**
## แก้ปัญหาที่พบบ่อย
### `redirect_uri_mismatch`
ค่า redirect URI ใน Google Cloud ต้องตรงกับ `${BASE_URL}/api/auth/youtube/callback` ทุกตัวอักษร รวม `http/https` โดเมน พอร์ต และ path
### ไม่ได้รับ Refresh Token
ลบสิทธิ์แอปเดิมจากบัญชี Google แล้วเปิด `/api/auth/youtube` ใหม่ ระบบของ Erika ส่ง `access_type=offline` และ `prompt=consent` เพื่อขอ refresh token ใหม่
### เห็นหน้า “Google hasn’t verified this app”
ถ้าแอปอยู่ในโหมด Testing ให้เพิ่มบัญชี Google เป็น Test user ก่อน สำหรับแอปสาธารณะอาจต้องผ่านขั้นตอน OAuth verification ของ Google
### วิดีโอถูกจำกัดเป็น Private
โปรเจกต์ API ที่ยังไม่ผ่านการตรวจสอบจาก YouTube อาจถูกจำกัดให้อัปโหลดเป็น Private จนกว่าจะผ่าน API audit
### Quota เต็ม
ตรวจสอบที่ **Google Cloud Console** → **APIs & Services** → **YouTube Data API v3** → **Quotas** การอัปโหลดวิดีโอใช้โควตาสูงกว่าการอ่านข้อมูลทั่วไป
+8 -1
View File
@@ -120,6 +120,13 @@
"when": 1785435766276,
"tag": "0016_wide_talos",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1785435766277,
"tag": "0017_friendly_video_upload",
"breakpoints": true
}
]
}
}
+389
View File
@@ -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));
}
}
+125
View File
@@ -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-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;
}
+2 -1
View File
@@ -43,9 +43,10 @@ http {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 200s;
}
}
client_max_body_size 100M;
client_max_body_size 0;
}
+3
View File
@@ -34,3 +34,6 @@ Erika is a Next.js website for the content creator "Erika!". It has two main fea
| `ROBLOX_USER_ID` | Roblox user ID to fetch follower count |
| `TIKTOK_CLIENT_KEY` / `TIKTOK_CLIENT_SECRET` | TikTok Official API credentials |
| `TIKTOK_ACCESS_TOKEN` / `TIKTOK_REFRESH_TOKEN` / `TIKTOK_OPEN_ID` | TikTok OAuth tokens from one-time setup at `/api/auth/tiktok` |
| `YOUTUBE_CLIENT_ID` / `YOUTUBE_CLIENT_SECRET` | YouTube OAuth application credentials |
| `YOUTUBE_REFRESH_TOKEN` | YouTube OAuth refresh token from `/api/auth/youtube` |
| `VIDEO_UPLOAD_DIR` | Optional override for the mounted video directory; defaults to `/nfs/erika` |