Initial open-source self-hosted Songs2YT edition

This commit is contained in:
Songs2YT
2026-07-17 02:23:59 +02:00
commit 2aa8a84781
134 changed files with 17758 additions and 0 deletions
+231
View File
@@ -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",
};
}