-
-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathyoutube-upload.ts
More file actions
149 lines (131 loc) · 4.92 KB
/
youtube-upload.ts
File metadata and controls
149 lines (131 loc) · 4.92 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
import { google } from "googleapis";
import { Readable } from "node:stream";
const oauth2Client = new google.auth.OAuth2(
process.env.YOUTUBE_CLIENT_ID,
process.env.YOUTUBE_CLIENT_SECRET,
);
oauth2Client.setCredentials({
refresh_token: process.env.YOUTUBE_REFRESH_TOKEN,
});
const youtube = google.youtube({ version: "v3", auth: oauth2Client });
function getDefaultPrivacyStatus(): "public" | "private" | "unlisted" {
const envValue = process.env.YOUTUBE_UPLOAD_VISIBILITY?.toLowerCase();
if (envValue === "public" || envValue === "private" || envValue === "unlisted") {
return envValue;
}
return "private"; // Default to private when not set
}
interface UploadOptions {
title: string;
description: string;
tags: string[];
videoUrl: string;
categoryId?: string;
privacyStatus?: "public" | "private" | "unlisted";
madeForKids?: boolean;
}
/**
* Upload a video to YouTube (main channel video, 16:9).
*/
export async function uploadVideo(opts: UploadOptions): Promise<{ videoId: string; url: string }> {
const response = await fetch(opts.videoUrl);
if (!response.ok) throw new Error(`Failed to fetch video: ${response.statusText}`);
const resolvedPrivacyStatus = opts.privacyStatus || getDefaultPrivacyStatus();
console.log(`[youtube-upload] Uploading "${opts.title.slice(0, 60)}" with privacy: ${resolvedPrivacyStatus}`);
// Convert Web ReadableStream to Node.js Readable stream
// googleapis expects a Node.js stream with .pipe(), not a Web ReadableStream
const buffer = Buffer.from(await response.arrayBuffer());
const nodeStream = Readable.from(buffer);
const res = await youtube.videos.insert({
part: ["snippet", "status"],
requestBody: {
snippet: {
title: opts.title.slice(0, 100),
description: opts.description,
tags: opts.tags,
categoryId: opts.categoryId || "28", // Science & Technology
defaultLanguage: "en",
},
status: {
privacyStatus: resolvedPrivacyStatus,
selfDeclaredMadeForKids: opts.madeForKids ?? false,
},
},
media: {
body: nodeStream,
},
});
const videoId = res.data.id || "";
return { videoId, url: `https://youtube.com/watch?v=${videoId}` };
}
/**
* Upload a Short to YouTube (9:16 vertical).
* Shorts have different metadata optimization:
* - Title must include #Shorts
* - Shorter, punchier title (max 60 chars before #Shorts)
* - Description is minimal — hashtags and hook only
* - Tags focus on trending/discovery terms
*/
export async function uploadShort(opts: UploadOptions): Promise<{ videoId: string; url: string }> {
// Optimize title for Shorts: shorter, punchier, with #Shorts tag
const shortTitle = opts.title.length > 60
? `${opts.title.slice(0, 57)}... #Shorts`
: `${opts.title} #Shorts`;
// Shorts description: hook + hashtags only (viewers rarely read full descriptions)
const hookLine = opts.description.split("\n")[0] || opts.title;
const hashTags = opts.tags
.slice(0, 5)
.map((t) => `#${t.replace(/\s+/g, "").replace(/^#/, "")}`)
.join(" ");
const shortDescription = `${hookLine}\n\n${hashTags}\n\n#Shorts #CodingCatDev #Programming`;
// Shorts-specific tags: add trending discovery terms
const shortTags = [
...opts.tags.slice(0, 10),
"Shorts",
"Programming Shorts",
"Dev Tips",
"Coding Tips",
"CodingCat",
];
return uploadVideo({
...opts,
title: shortTitle,
description: shortDescription,
tags: [...new Set(shortTags)].slice(0, 15),
});
}
/**
* Generate Shorts-optimized metadata using Gemini.
* Called separately from the main video metadata generator
* because Shorts have very different SEO patterns.
*/
export async function generateShortsMetadata(
generateWithGemini: (prompt: string) => Promise<string>,
doc: { title: string; script?: { hook?: string } },
): Promise<{ title: string; description: string; tags: string[] }> {
const hook = doc.script?.hook || doc.title;
const prompt = `You are a YouTube Shorts SEO expert for CodingCat.dev (developer education).
Video hook: ${hook}
Original title: ${doc.title}
Generate metadata optimized specifically for YouTube Shorts (NOT long-form):
- Shorts titles should be punchy, curiosity-driven, max 50 chars (before #Shorts)
- Use patterns like "Did you know...", "This one trick...", "Stop doing this..."
- Description should be 1-2 lines max + hashtags
- Tags should focus on trending/discovery terms
Return JSON: {"title": "punchy title", "description": "short hook + hashtags", "tags": ["trending", "discovery", "terms"]}`;
const raw = await generateWithGemini(prompt);
try {
const parsed = JSON.parse(raw.replace(/```json\n?|\n?```/g, "").trim());
return {
title: (parsed.title?.slice(0, 50) || doc.title) + " #Shorts",
description: parsed.description || hook,
tags: Array.isArray(parsed.tags) ? [...parsed.tags.slice(0, 10), "Shorts", "CodingCat"] : ["Shorts"],
};
} catch {
return {
title: `${doc.title.slice(0, 50)} #Shorts`,
description: `${hook}\n\n#Shorts #CodingCatDev #Programming`,
tags: ["Shorts", "Programming", "CodingCat"],
};
}
}