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:
Atakan Doğan Özban
2026-07-27 16:26:19 +02:00
co-authored by Cursor
parent 682020ff5a
commit c8015937f9
206 changed files with 37078 additions and 638 deletions
+57
View File
@@ -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
View File
@@ -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) {