Initial open-source self-hosted Songs2YT edition
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user