Align preview typography with FFmpeg output, add video/song title split, and ship OSS updates.
Separate YouTube video titles from on-video song/artist fields with Pro gating, serve curated fonts and watermark assets for 1:1 preview parity, and include billing/API/docs/deploy stack for self-hosted release. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
682020ff5a
commit
c8015937f9
@@ -0,0 +1,57 @@
|
||||
/** Extract a readable message from googleapis / Gaxios errors. */
|
||||
export function extractYouTubeErrorMessage(err: unknown): string {
|
||||
if (!err || typeof err !== "object") {
|
||||
return typeof err === "string" ? err : "Unknown YouTube error";
|
||||
}
|
||||
|
||||
const anyErr = err as {
|
||||
message?: string;
|
||||
errors?: Array<{ message?: string; reason?: string }>;
|
||||
response?: {
|
||||
data?: {
|
||||
error?: {
|
||||
message?: string;
|
||||
errors?: Array<{ message?: string; reason?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const nested =
|
||||
anyErr.response?.data?.error?.errors?.[0]?.message ||
|
||||
anyErr.response?.data?.error?.message ||
|
||||
anyErr.errors?.[0]?.message;
|
||||
|
||||
if (nested?.trim()) return nested.trim();
|
||||
if (anyErr.message?.trim()) return anyErr.message.trim();
|
||||
return "Unknown YouTube error";
|
||||
}
|
||||
|
||||
export function isYouTubeUploadLimitError(message: string) {
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes("exceeded the number of videos") ||
|
||||
lower.includes("uploadLimitExceeded") ||
|
||||
lower.includes("upload limit") ||
|
||||
(lower.includes("quota") && lower.includes("exceeded") && lower.includes("youtube"))
|
||||
);
|
||||
}
|
||||
|
||||
export const YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE =
|
||||
"YouTube upload limit reached: this Google/YouTube account has exceeded the number of videos " +
|
||||
"it may upload right now. This is YouTube's own daily limit, not your Songs2VID plan quota. " +
|
||||
"Try again later (often after 24 hours) or use a different YouTube channel.";
|
||||
|
||||
/** User-facing message for the dashboard (distinct from Songs2VID plan quota). */
|
||||
export function formatYouTubeErrorForUser(err: unknown): string {
|
||||
const raw = extractYouTubeErrorMessage(err);
|
||||
if (isYouTubeUploadLimitError(raw)) return YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** Normalize a stored job-item error string for display in the UI. */
|
||||
export function displayJobItemError(message: string | null | undefined): string | null {
|
||||
if (!message?.trim()) return null;
|
||||
if (isYouTubeUploadLimitError(message)) return YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE;
|
||||
return message.trim();
|
||||
}
|
||||
+151
-31
@@ -2,7 +2,9 @@ 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(
|
||||
@@ -25,11 +27,12 @@ export async function getYouTubeClient(userId: string) {
|
||||
process.env.GOOGLE_CLIENT_SECRET,
|
||||
);
|
||||
|
||||
let accessToken = connection.accessToken;
|
||||
let accessToken = decryptSecret(connection.accessToken);
|
||||
const refreshToken = decryptSecret(connection.refreshToken);
|
||||
let expiresAt = connection.expiresAt;
|
||||
|
||||
if (new Date() >= expiresAt) {
|
||||
const credentials = await refreshAccessToken(connection.refreshToken);
|
||||
const credentials = await refreshAccessToken(refreshToken);
|
||||
if (!credentials.access_token) {
|
||||
throw new Error("Failed to refresh YouTube access token");
|
||||
}
|
||||
@@ -40,13 +43,18 @@ export async function getYouTubeClient(userId: string) {
|
||||
|
||||
await prisma.youTubeConnection.update({
|
||||
where: { userId },
|
||||
data: { accessToken, expiresAt },
|
||||
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: connection.refreshToken,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
return google.youtube({ version: "v3", auth: oauth2Client });
|
||||
@@ -57,41 +65,153 @@ export async function uploadToYouTube(
|
||||
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 tags = parseTags(item.tags);
|
||||
const playlists: Array<{ id: string; title: string; itemCount: number }> = [];
|
||||
let pageToken: string | undefined;
|
||||
|
||||
const privacyStatus =
|
||||
item.privacy === "PUBLIC"
|
||||
? "public"
|
||||
: item.privacy === "PRIVATE"
|
||||
? "private"
|
||||
: "unlisted";
|
||||
do {
|
||||
const response = await youtube.playlists.list({
|
||||
part: ["snippet", "contentDetails"],
|
||||
mine: true,
|
||||
maxResults: 50,
|
||||
pageToken,
|
||||
});
|
||||
|
||||
const response = await youtube.videos.insert({
|
||||
part: ["snippet", "status"],
|
||||
notifySubscribers: item.notifySubscribers,
|
||||
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: {
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
categoryId: item.categoryId,
|
||||
playlistId,
|
||||
resourceId: {
|
||||
kind: "youtube#video",
|
||||
videoId,
|
||||
},
|
||||
},
|
||||
status: {
|
||||
privacyStatus,
|
||||
embeddable: item.embeddable,
|
||||
selfDeclaredMadeForKids: item.madeForKids,
|
||||
licenseId: 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");
|
||||
return videoId;
|
||||
}
|
||||
|
||||
export async function fetchYouTubeChannel(accessToken: string, refreshToken: string) {
|
||||
|
||||
Reference in New Issue
Block a user