232 lines
6.6 KiB
TypeScript
232 lines
6.6 KiB
TypeScript
import { google } from "googleapis";
|
|
import fs from "fs";
|
|
import { prisma } from "../db";
|
|
import { parseTags } from "../constants";
|
|
import { decryptSecret, encryptSecret } from "../crypto/secrets";
|
|
import type { JobItem } from "@prisma/client";
|
|
import { formatYouTubeErrorForUser } from "./errors";
|
|
|
|
async function refreshAccessToken(refreshToken: string) {
|
|
const oauth2Client = new google.auth.OAuth2(
|
|
process.env.GOOGLE_CLIENT_ID,
|
|
process.env.GOOGLE_CLIENT_SECRET,
|
|
);
|
|
oauth2Client.setCredentials({ refresh_token: refreshToken });
|
|
const { credentials } = await oauth2Client.refreshAccessToken();
|
|
return credentials;
|
|
}
|
|
|
|
export async function getYouTubeClient(userId: string) {
|
|
const connection = await prisma.youTubeConnection.findUnique({
|
|
where: { userId },
|
|
});
|
|
if (!connection) throw new Error("YouTube account not connected");
|
|
|
|
const oauth2Client = new google.auth.OAuth2(
|
|
process.env.GOOGLE_CLIENT_ID,
|
|
process.env.GOOGLE_CLIENT_SECRET,
|
|
);
|
|
|
|
let accessToken = decryptSecret(connection.accessToken);
|
|
const refreshToken = decryptSecret(connection.refreshToken);
|
|
let expiresAt = connection.expiresAt;
|
|
|
|
if (new Date() >= expiresAt) {
|
|
const credentials = await refreshAccessToken(refreshToken);
|
|
if (!credentials.access_token) {
|
|
throw new Error("Failed to refresh YouTube access token");
|
|
}
|
|
accessToken = credentials.access_token;
|
|
expiresAt = credentials.expiry_date
|
|
? new Date(credentials.expiry_date)
|
|
: new Date(Date.now() + 3600 * 1000);
|
|
|
|
await prisma.youTubeConnection.update({
|
|
where: { userId },
|
|
data: {
|
|
accessToken: encryptSecret(accessToken),
|
|
// Re-encrypt refresh token if it was still plaintext legacy
|
|
refreshToken: encryptSecret(refreshToken),
|
|
expiresAt,
|
|
},
|
|
});
|
|
}
|
|
|
|
oauth2Client.setCredentials({
|
|
access_token: accessToken,
|
|
refresh_token: refreshToken,
|
|
});
|
|
|
|
return google.youtube({ version: "v3", auth: oauth2Client });
|
|
}
|
|
|
|
export async function uploadToYouTube(
|
|
userId: string,
|
|
videoPath: string,
|
|
item: JobItem,
|
|
): Promise<string> {
|
|
try {
|
|
const youtube = await getYouTubeClient(userId);
|
|
const tags = parseTags(item.tags);
|
|
|
|
const privacyStatus =
|
|
item.privacy === "PUBLIC"
|
|
? "public"
|
|
: item.privacy === "PRIVATE"
|
|
? "private"
|
|
: "unlisted";
|
|
|
|
const response = await youtube.videos.insert({
|
|
part: ["snippet", "status"],
|
|
notifySubscribers: item.notifySubscribers,
|
|
requestBody: {
|
|
snippet: {
|
|
title: item.title,
|
|
description: item.description,
|
|
tags: tags.length > 0 ? tags : undefined,
|
|
categoryId: item.categoryId,
|
|
},
|
|
status: {
|
|
privacyStatus,
|
|
embeddable: item.embeddable,
|
|
selfDeclaredMadeForKids: item.madeForKids,
|
|
license: item.creativeCommons ? "creativeCommon" : "youtube",
|
|
},
|
|
},
|
|
media: {
|
|
body: fs.createReadStream(videoPath),
|
|
},
|
|
});
|
|
|
|
const videoId = response.data.id;
|
|
if (!videoId) throw new Error("YouTube upload succeeded but no video ID returned");
|
|
|
|
if (item.playlistId) {
|
|
try {
|
|
await addVideoToPlaylist(youtube, item.playlistId, videoId);
|
|
} catch (playlistErr) {
|
|
throw new Error(
|
|
`Video uploaded (${videoId}) but failed to add to playlist: ${formatYouTubeErrorForUser(playlistErr)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return videoId;
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message.startsWith("Video uploaded (")) {
|
|
throw err;
|
|
}
|
|
throw new Error(formatYouTubeErrorForUser(err));
|
|
}
|
|
}
|
|
|
|
export type PlaylistPrivacy = "public" | "unlisted" | "private";
|
|
|
|
export type CreatePlaylistInput = {
|
|
title: string;
|
|
description?: string;
|
|
privacy?: PlaylistPrivacy;
|
|
};
|
|
|
|
function toPlaylistPrivacyStatus(privacy?: PlaylistPrivacy) {
|
|
if (privacy === "public") return "public";
|
|
if (privacy === "unlisted") return "unlisted";
|
|
return "private";
|
|
}
|
|
|
|
export async function listYouTubePlaylists(userId: string) {
|
|
const youtube = await getYouTubeClient(userId);
|
|
const playlists: Array<{ id: string; title: string; itemCount: number }> = [];
|
|
let pageToken: string | undefined;
|
|
|
|
do {
|
|
const response = await youtube.playlists.list({
|
|
part: ["snippet", "contentDetails"],
|
|
mine: true,
|
|
maxResults: 50,
|
|
pageToken,
|
|
});
|
|
|
|
for (const playlist of response.data.items ?? []) {
|
|
if (!playlist.id || !playlist.snippet?.title) continue;
|
|
playlists.push({
|
|
id: playlist.id,
|
|
title: playlist.snippet.title,
|
|
itemCount: playlist.contentDetails?.itemCount ?? 0,
|
|
});
|
|
}
|
|
|
|
pageToken = response.data.nextPageToken ?? undefined;
|
|
} while (pageToken);
|
|
|
|
return playlists;
|
|
}
|
|
|
|
export async function createYouTubePlaylist(userId: string, input: CreatePlaylistInput) {
|
|
const title = input.title?.trim();
|
|
if (!title) throw new Error("Playlist title is required");
|
|
|
|
try {
|
|
const youtube = await getYouTubeClient(userId);
|
|
const response = await youtube.playlists.insert({
|
|
part: ["snippet", "status"],
|
|
requestBody: {
|
|
snippet: {
|
|
title,
|
|
description: input.description?.trim() || "",
|
|
},
|
|
status: {
|
|
privacyStatus: toPlaylistPrivacyStatus(input.privacy),
|
|
},
|
|
},
|
|
});
|
|
|
|
const id = response.data.id;
|
|
if (!id) throw new Error("YouTube created the playlist but returned no ID");
|
|
|
|
return {
|
|
id,
|
|
title: response.data.snippet?.title || title,
|
|
itemCount: 0,
|
|
privacy: input.privacy ?? "private",
|
|
};
|
|
} catch (err) {
|
|
throw new Error(formatYouTubeErrorForUser(err));
|
|
}
|
|
}
|
|
|
|
async function addVideoToPlaylist(
|
|
youtube: Awaited<ReturnType<typeof getYouTubeClient>>,
|
|
playlistId: string,
|
|
videoId: string,
|
|
) {
|
|
await youtube.playlistItems.insert({
|
|
part: ["snippet"],
|
|
requestBody: {
|
|
snippet: {
|
|
playlistId,
|
|
resourceId: {
|
|
kind: "youtube#video",
|
|
videoId,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function fetchYouTubeChannel(accessToken: string, refreshToken: string) {
|
|
const oauth2Client = new google.auth.OAuth2(
|
|
process.env.GOOGLE_CLIENT_ID,
|
|
process.env.GOOGLE_CLIENT_SECRET,
|
|
);
|
|
oauth2Client.setCredentials({ access_token: accessToken, refresh_token: refreshToken });
|
|
const youtube = google.youtube({ version: "v3", auth: oauth2Client });
|
|
const response = await youtube.channels.list({ part: ["snippet"], mine: true });
|
|
const channel = response.data.items?.[0];
|
|
if (!channel?.id) throw new Error("No YouTube channel found for this account");
|
|
return {
|
|
channelId: channel.id,
|
|
channelTitle: channel.snippet?.title || "Unknown Channel",
|
|
};
|
|
}
|