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 { 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>, 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", }; }