Spaces:
Running
Running
Upload 24 files
Browse files- backend/.env +3 -0
- backend/jsconfig.json +8 -0
- backend/package-lock.json +0 -0
- backend/package.json +12 -0
- backend/run.bat +1 -0
- backend/server.js +244 -0
- backend/service-account.json +13 -0
- backend/services/adminService.js +64 -0
- backend/services/ai.js +2 -0
- backend/services/ai/bookRecap.js +37 -0
- backend/services/ai/comicTranslator.js +64 -0
- backend/services/ai/creator.js +49 -0
- backend/services/ai/index.js +23 -0
- backend/services/ai/recap.js +32 -0
- backend/services/ai/srtTranslate.js +82 -0
- backend/services/ai/subtitle.js +54 -0
- backend/services/ai/transcribe.js +31 -0
- backend/services/ai/translate.js +33 -0
- backend/services/ai/tts.js +44 -0
- backend/services/ai/utils.js +73 -0
- backend/services/authService.js +39 -0
- backend/services/credit.js +123 -0
- backend/services/creditService.js +153 -0
- backend/services/firebase.js +36 -0
backend/.env
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PORT=8080
|
| 2 |
+
API_KEY="AIzaSyAUuxggsn9uNOEHDW4NiKpyrfCUpt8vLi4"
|
| 3 |
+
ADMIN_PASSWORD="Hello"
|
backend/jsconfig.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"baseUrl": ".",
|
| 4 |
+
"paths": {
|
| 5 |
+
"@/backend/*": ["./*"]
|
| 6 |
+
}
|
| 7 |
+
}
|
| 8 |
+
}
|
backend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/package.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"dependencies": {
|
| 3 |
+
"@google/genai": "^1.35.0",
|
| 4 |
+
"@google/generative-ai": "^0.24.1",
|
| 5 |
+
"axios": "^1.13.2",
|
| 6 |
+
"cors": "^2.8.5",
|
| 7 |
+
"dotenv": "^17.2.3",
|
| 8 |
+
"express": "^5.2.1",
|
| 9 |
+
"firebase-admin": "^13.6.0",
|
| 10 |
+
"fluent-ffmpeg": "^2.1.3"
|
| 11 |
+
}
|
| 12 |
+
}
|
backend/run.bat
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
npx tsx --tsconfig jsconfig.json server.js
|
backend/server.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import express from 'express';
|
| 3 |
+
import cors from 'cors';
|
| 4 |
+
import dotenv from 'dotenv';
|
| 5 |
+
import { exec } from 'child_process';
|
| 6 |
+
import { promisify } from 'util';
|
| 7 |
+
import fs from 'fs';
|
| 8 |
+
import path from 'path';
|
| 9 |
+
import { AIService } from '@/backend/services/ai';
|
| 10 |
+
import { AuthService } from '@/backend/services/authService';
|
| 11 |
+
import { CreditService } from '@/backend/services/creditService';
|
| 12 |
+
import { AdminService } from '@/backend/services/adminService';
|
| 13 |
+
|
| 14 |
+
const execPromise = promisify(exec);
|
| 15 |
+
dotenv.config();
|
| 16 |
+
|
| 17 |
+
const app = express();
|
| 18 |
+
|
| 19 |
+
// CRITICAL FIX: Increased limits to 100mb to allow processing of long audio/video base64 data
|
| 20 |
+
app.use(express.json({ limit: '100mb' }));
|
| 21 |
+
app.use(express.urlencoded({ limit: '100mb', extended: true }));
|
| 22 |
+
|
| 23 |
+
app.use(cors());
|
| 24 |
+
|
| 25 |
+
const apiRouter = express.Router();
|
| 26 |
+
|
| 27 |
+
apiRouter.post('/download', async (req, res) => {
|
| 28 |
+
const { url, quality, platform, sessionId, creditCost } = req.body;
|
| 29 |
+
|
| 30 |
+
try {
|
| 31 |
+
const eligibility = await CreditService.checkEligibility(sessionId, null, 'downloader', false, creditCost);
|
| 32 |
+
|
| 33 |
+
const timestamp = Date.now();
|
| 34 |
+
const fileBaseName = `tm_dl_${timestamp}`;
|
| 35 |
+
const outputDir = '/tmp';
|
| 36 |
+
|
| 37 |
+
// Determine file extension and format string
|
| 38 |
+
const extension = quality === 'audio' ? 'mp3' : 'mp4';
|
| 39 |
+
const outputPath = path.join(outputDir, `${fileBaseName}.${extension}`);
|
| 40 |
+
|
| 41 |
+
let formatStr = "";
|
| 42 |
+
if (quality === 'audio') {
|
| 43 |
+
formatStr = "-f 'ba' -x --audio-format mp3";
|
| 44 |
+
} else {
|
| 45 |
+
const qMap = {
|
| 46 |
+
'360p': 'bestvideo[height<=360]+bestaudio/best[height<=360]',
|
| 47 |
+
'720p': 'bestvideo[height<=720]+bestaudio/best[height<=720]',
|
| 48 |
+
'1080p': 'bestvideo[height<=1080]+bestaudio/best[height<=1080]'
|
| 49 |
+
};
|
| 50 |
+
const format = qMap[quality] || qMap['720p'];
|
| 51 |
+
formatStr = `-f "${format}" --merge-output-format mp4`;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const tiktokArgs = platform === 'tiktok' ? '--referer "https://www.tiktok.com/"' : '';
|
| 55 |
+
|
| 56 |
+
// CRITICAL FIX: Added --js-runtime node to fix JS engine missing error.
|
| 57 |
+
// Added --no-warnings and --no-check-certificate for better reliability on Cloud Run.
|
| 58 |
+
const command = `yt-dlp --no-playlist --no-warnings --no-check-certificate --js-runtime node ${formatStr} ${tiktokArgs} -o "${outputPath}" "${url.trim()}"`;
|
| 59 |
+
|
| 60 |
+
console.log(`[DOWNLOADER] Executing: ${command}`);
|
| 61 |
+
|
| 62 |
+
try {
|
| 63 |
+
await execPromise(command);
|
| 64 |
+
} catch (execErr) {
|
| 65 |
+
console.error("[DL-EXEC-FAIL]", execErr.stderr || execErr.message);
|
| 66 |
+
// Even if warnings are printed to stderr, check if file exists before crashing
|
| 67 |
+
if (!fs.existsSync(outputPath)) {
|
| 68 |
+
throw new Error(`Engine Error: ${execErr.stderr || execErr.message}`);
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Verify file existence explicitly
|
| 73 |
+
if (!fs.existsSync(outputPath)) {
|
| 74 |
+
throw new Error("Download engine failed. File not generated.");
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
await CreditService.commitDeduction(eligibility);
|
| 78 |
+
|
| 79 |
+
res.download(outputPath, `${fileBaseName}.${extension}`, (err) => {
|
| 80 |
+
if (err) console.error("Stream Error:", err);
|
| 81 |
+
try {
|
| 82 |
+
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
| 83 |
+
} catch (e) { console.error("Cleanup Error:", e); }
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
} catch (error) {
|
| 87 |
+
console.error("[DOWNLOAD-ERROR]", error.message);
|
| 88 |
+
res.status(500).json({ error: error.message });
|
| 89 |
+
}
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
const handleAiRequest = async (req, res, toolKey, taskFn) => {
|
| 93 |
+
const sessionId = req.headers['x-session-id'];
|
| 94 |
+
const guestId = req.headers['x-guest-id'];
|
| 95 |
+
const { isOwnApi, customApiKey, creditCost } = req.body;
|
| 96 |
+
|
| 97 |
+
try {
|
| 98 |
+
const eligibility = await CreditService.checkEligibility(sessionId, guestId, toolKey, isOwnApi, creditCost);
|
| 99 |
+
const apiKey = isOwnApi ? (customApiKey || process.env.API_KEY) : process.env.API_KEY;
|
| 100 |
+
const result = await taskFn(apiKey, isOwnApi);
|
| 101 |
+
await CreditService.commitDeduction(eligibility);
|
| 102 |
+
res.json(result);
|
| 103 |
+
} catch (error) {
|
| 104 |
+
console.error(`[BACKEND-API] Error in ${toolKey}:`, error.message);
|
| 105 |
+
res.status(error.message.includes('REACHED') || error.message.includes('INSUFFICIENT') ? 403 : 500).json({
|
| 106 |
+
error: error.message
|
| 107 |
+
});
|
| 108 |
+
}
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
apiRouter.post('/transcribe', (req, res) => {
|
| 112 |
+
handleAiRequest(req, res, 'count_transcript', async (key, isOwn) => {
|
| 113 |
+
return { text: await AIService.transcribe(req.body.media, req.body.mimeType, key, isOwn) };
|
| 114 |
+
});
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
apiRouter.post('/recap', (req, res) => {
|
| 118 |
+
handleAiRequest(req, res, 'count_transcript', async (key, isOwn) => {
|
| 119 |
+
return { recap: await AIService.recap(req.body.media, req.body.mimeType, req.body.targetLanguage, key, isOwn) };
|
| 120 |
+
});
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
apiRouter.post('/book-recap', (req, res) => {
|
| 124 |
+
handleAiRequest(req, res, 'count_translate', async (key, isOwn) => {
|
| 125 |
+
return { recap: await AIService.bookRecap(req.body.media, req.body.mimeType, req.body.targetLanguage, key, isOwn) };
|
| 126 |
+
});
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
apiRouter.post('/comic-translate', (req, res) => {
|
| 130 |
+
handleAiRequest(req, res, 'count_translate', async (key, isOwn) => {
|
| 131 |
+
return { pdfData: await AIService.comicTranslate(req.body.media, req.body.mimeType, req.body.targetLanguage, key, isOwn) };
|
| 132 |
+
});
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
apiRouter.post('/translate', (req, res) => {
|
| 136 |
+
handleAiRequest(req, res, 'count_translate', async (key, isOwn) => {
|
| 137 |
+
return await AIService.translate(req.body.text, req.body.targetLanguage, req.body.options, key, isOwn);
|
| 138 |
+
});
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
apiRouter.post('/srt-translate', (req, res) => {
|
| 142 |
+
handleAiRequest(req, res, 'count_srt_translate', async (key, isOwn) => {
|
| 143 |
+
return { srt: await AIService.srtTranslate(req.body.srtContent, req.body.sourceLanguage, req.body.targetLanguage, key, isOwn) };
|
| 144 |
+
});
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
apiRouter.post('/tts', (req, res) => {
|
| 148 |
+
handleAiRequest(req, res, 'count_tts', async (key, isOwn) => {
|
| 149 |
+
return { audioData: await AIService.tts(req.body.text, req.body.voiceId, req.body.tone, key, isOwn) };
|
| 150 |
+
});
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
apiRouter.post('/subtitle', (req, res) => {
|
| 154 |
+
req.setTimeout(1200000);
|
| 155 |
+
handleAiRequest(req, res, 'count_subtitle', async (key, isOwn) => {
|
| 156 |
+
return await AIService.subtitle(
|
| 157 |
+
req.body.media,
|
| 158 |
+
req.body.mimeType,
|
| 159 |
+
req.body.script,
|
| 160 |
+
key,
|
| 161 |
+
isOwn,
|
| 162 |
+
req.body.sourceLanguage,
|
| 163 |
+
req.body.startOffsetMs || 0,
|
| 164 |
+
req.body.lastScriptIndex || 0
|
| 165 |
+
);
|
| 166 |
+
});
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
apiRouter.post('/content-creator', (req, res) => {
|
| 170 |
+
handleAiRequest(req, res, 'count_creator_text', async (key, isOwn) => {
|
| 171 |
+
const script = await AIService.contentCreator(
|
| 172 |
+
req.body.topic, req.body.category, req.body.subTopics,
|
| 173 |
+
req.body.contentType, req.body.creatorGender, req.body.targetLanguage, key, isOwn
|
| 174 |
+
);
|
| 175 |
+
let imageUrl = null;
|
| 176 |
+
if (req.body.withImage) {
|
| 177 |
+
try { imageUrl = await AIService.generateImage(req.body.topic, key, isOwn); } catch(e) {}
|
| 178 |
+
}
|
| 179 |
+
return { script, imageUrl };
|
| 180 |
+
});
|
| 181 |
+
});
|
| 182 |
+
|
| 183 |
+
apiRouter.post('/secure/save-key', async (req, res) => {
|
| 184 |
+
try {
|
| 185 |
+
const { sessionId, apiKey } = req.body;
|
| 186 |
+
res.json(await AuthService.saveCustomKey(sessionId, apiKey));
|
| 187 |
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
apiRouter.post('/secure/update-session', async (req, res) => {
|
| 191 |
+
try {
|
| 192 |
+
const { userId, newSessionId } = req.body;
|
| 193 |
+
res.json(await AuthService.updateActiveSession(userId, newSessionId));
|
| 194 |
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 195 |
+
});
|
| 196 |
+
|
| 197 |
+
apiRouter.post('/admin/verify', async (req, res) => {
|
| 198 |
+
const { adminPassword } = req.body;
|
| 199 |
+
if (adminPassword === process.env.ADMIN_PASSWORD) res.json({ success: true });
|
| 200 |
+
else res.status(403).json({ error: "Unauthorized" });
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
apiRouter.post('/admin/update-settings', async (req, res) => {
|
| 204 |
+
const { adminPassword, settings } = req.body;
|
| 205 |
+
if (adminPassword !== process.env.ADMIN_PASSWORD) return res.status(403).json({ error: "Denied" });
|
| 206 |
+
try { res.json(await AdminService.updateSettings(settings)); } catch (e) { res.status(500).json({ error: e.message }); }
|
| 207 |
+
});
|
| 208 |
+
|
| 209 |
+
apiRouter.post('/admin/add-user', async (req, res) => {
|
| 210 |
+
const { adminPassword, userData, referrerCode } = req.body;
|
| 211 |
+
if (adminPassword !== process.env.ADMIN_PASSWORD) return res.status(403).json({ error: "Denied" });
|
| 212 |
+
try { res.json(await AdminService.addUser(userData, referrerCode)); } catch (e) { res.status(500).json({ error: e.message }); }
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
apiRouter.post('/admin/reactivate-user', async (req, res) => {
|
| 216 |
+
const { adminPassword, nodeKey, userClass, credits } = req.body;
|
| 217 |
+
if (adminPassword !== process.env.ADMIN_PASSWORD) return res.status(403).json({ error: "Denied" });
|
| 218 |
+
try { res.json(await AdminService.reactivateUser(nodeKey, userClass, credits)); } catch (e) { res.status(500).json({ error: e.message }); }
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
apiRouter.post('/admin/update-user-class', async (req, res) => {
|
| 222 |
+
const { adminPassword, nodeKey, userClass, credits } = req.body;
|
| 223 |
+
if (adminPassword !== process.env.ADMIN_PASSWORD) return res.status(403).json({ error: "Denied" });
|
| 224 |
+
try { res.json(await AdminService.updateUserClass(nodeKey, userClass, credits)); } catch (e) { res.status(500).json({ error: e.message }); }
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
apiRouter.post('/admin/topup-credits', async (req, res) => {
|
| 228 |
+
const { adminPassword, sessionId, amount } = req.body;
|
| 229 |
+
if (adminPassword !== process.env.ADMIN_PASSWORD) return res.status(403).json({ error: "Denied" });
|
| 230 |
+
try { res.json(await AdminService.topUpCredits(sessionId, amount)); } catch (e) { res.status(500).json({ error: e.message }); }
|
| 231 |
+
});
|
| 232 |
+
|
| 233 |
+
apiRouter.post('/admin/delete-user', async (req, res) => {
|
| 234 |
+
const { adminPassword, nodeKey } = req.body;
|
| 235 |
+
if (adminPassword !== process.env.ADMIN_PASSWORD) return res.status(403).json({ error: "Denied" });
|
| 236 |
+
try { res.json(await AdminService.deleteUser(nodeKey)); } catch (e) { res.status(500).json({ error: e.message }); }
|
| 237 |
+
});
|
| 238 |
+
|
| 239 |
+
app.use('/', apiRouter);
|
| 240 |
+
|
| 241 |
+
const PORT = process.env.PORT || 8080;
|
| 242 |
+
app.listen(PORT, () => {
|
| 243 |
+
console.log(`🚀 Master Backend Engine running on port ${PORT}`);
|
| 244 |
+
});
|
backend/service-account.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"type": "service_account",
|
| 3 |
+
"project_id": "klingwatermark",
|
| 4 |
+
"private_key_id": "8ac9baff7cdb9605fd3c390740659f5edae75bbe",
|
| 5 |
+
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDmZlD5OF0UhHst\ntgf8gDuQ9D2OurWowM9s9OutRoEby+XJ6ndRZhOyCzH5iFfMqVlWBes0Dr/zSMcE\nrP09DH+bntAUDZJ4iIhV2ODLE+m/hSNdM74uSUt8xlTXpZlpBFbX77VzgNlB+iTV\nkJ3edzrFvAnzbZcXrWA8iYA10yotvCKEk1sPm3h4SrsPS1DFgywCMtkgnLAjv47d\nqW8klGuzMVKGWysBlG/THzXmxr1CzxUdiSgbMIASLLS67nBNmTwd3doAfqF7HWvY\n3e8+Iw3qKNJw7o5QAYvLr2kfRJ68sCCI8ib2+OEhLdxDru7lRUllzlFt/+CyYkaX\nM1JS+mUDAgMBAAECggEAINtCfOf+zig+cLxe0zFuTTOne61ArrogWodq/KDrVI11\nTaA0N9V4xZE3JUC+VJ9p8AyUCC56TTV5vD73NjeMEyNIxTNSSgIefy+Yjx1d3kQc\nUPRKEMu/CiI2gzJIeGN4Bv9WPwyG7xO/IP2opcsXBI/WU3Kzc1r0OG3bbNGaWD8Q\n3looKPcvVsgxceDaH3zs0+tEtP1JQ2YKlXZVnpr1lFUExkNhLPOhva+H1aPgryRG\nu380OTTnnceXMfwlYXiXSBZNLA7LToQR856k3Wbtj75Zr8+7f/y6kxQCcwYIjDUF\nfxDy0QZpM6MAMHrGc4Xzy3MZBYoWTe4ZSEttGtGasQKBgQD2hIldQxL9D5OBLmxl\nOXGJZnZaxd0gXpCCe360bTUSFUrrExznDwIkimJQZK4y5UXh30nkULWdh4KjjwIA\nAUVWSKPLGWwDl2AH9f0b3JTpHF5RIkyloGBowXrFEK9tcX5mlFxgOWqdtJaF7zes\npQJdvZ736JVxMepwcPPVSfasZQKBgQDvQxCpLv7qBDO9vRboeFGA1soeCg2syU/B\nEhql+4+p1pV1qJjyQd/1BRngRZGaAV1uokhBwLKhmvTfqNit8HUvYzw3DG99sju0\n3RwWYRFgf6xV3Cb+xW09/4/P9udE2DeKnuSAnoPZMvRA7JI9wCsNzZPA7wGx8/Bv\nXbk7dBhxRwKBgQD0VgdEjd+7PX4JEzdS2S3Ebu8uJ2F13OTEv5ylPnzUkJAyET6b\ncc/A4fxyDGhwf7jVGJjHmIt6OL7uWCc2VAwialsWSfs6UAZZvaICxI4/wuNk7Wck\n3qHQEr0Zp+EIy+3pxHEO2rnm2AA9fg4jq2V9/h0bQMcma8AfdITpSacZ1QKBgAki\nYFJ1Lto0St1liKhbX5Exogm/jIIaNWdDj6zii7uKK66QPzaQeUJbbX94aHSetLhy\njZulBazRw6N+SLdrRK4IddYMLX14/nqLLnVUQ1uRxDyK6Ro041TImu7vmCiysHwk\nUMjwRExYe6a24WZmHb6rKIbnGQN4MqetxlKUvhIlAoGAH7cTswMWvGMkrk+g87PJ\n+jsiLixAXeQLf0Je+dYs7gZGDcyXDpxcFe66bIv90en35N4aYQdiqdJu+NOoLYPM\nvAhtWg1qy9puzpf6U719jODYduE0CNIxBncj4RUlbj9PK1JRvvPvKVq3GFuGbz6C\nex5i3ryBmIdFq2d69rh43Ew=\n-----END PRIVATE KEY-----\n",
|
| 6 |
+
"client_email": "firebase-adminsdk-fbsvc@klingwatermark.iam.gserviceaccount.com",
|
| 7 |
+
"client_id": "104214968547846116587",
|
| 8 |
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
| 9 |
+
"token_uri": "https://oauth2.googleapis.com/token",
|
| 10 |
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
| 11 |
+
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40klingwatermark.iam.gserviceaccount.com",
|
| 12 |
+
"universe_domain": "googleapis.com"
|
| 13 |
+
}
|
backend/services/adminService.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { db } from '@/backend/services/firebase';
|
| 3 |
+
|
| 4 |
+
export const AdminService = {
|
| 5 |
+
async updateSettings(settings) {
|
| 6 |
+
await db.ref('system/settings').update(settings);
|
| 7 |
+
return { success: true };
|
| 8 |
+
},
|
| 9 |
+
|
| 10 |
+
async addUser(userData, referrerCode) {
|
| 11 |
+
const membershipId = userData.Active_Session_ID;
|
| 12 |
+
|
| 13 |
+
const userPayload = {
|
| 14 |
+
...userData,
|
| 15 |
+
Login_Key: membershipId,
|
| 16 |
+
Active_Session_ID: membershipId,
|
| 17 |
+
Referrer: referrerCode || "NONE",
|
| 18 |
+
Status: 'ACTIVE',
|
| 19 |
+
Usage: {},
|
| 20 |
+
Last_Usage_Date: new Date().toISOString().split('T')[0]
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
await db.ref(`users/${Date.now()}`).set(userPayload);
|
| 24 |
+
return { success: true };
|
| 25 |
+
},
|
| 26 |
+
|
| 27 |
+
async reactivateUser(nodeKey, userClass, credits) {
|
| 28 |
+
const today = new Date();
|
| 29 |
+
const expDate = new Date(today.getTime() + 30 * 86400000).toISOString().split('T')[0];
|
| 30 |
+
|
| 31 |
+
const updates = {
|
| 32 |
+
Class: userClass,
|
| 33 |
+
Credits: credits,
|
| 34 |
+
Expired_Date: expDate,
|
| 35 |
+
Status: 'ACTIVE'
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
await db.ref(`users/${nodeKey}`).update(updates);
|
| 39 |
+
return { success: true };
|
| 40 |
+
},
|
| 41 |
+
|
| 42 |
+
async updateUserClass(nodeKey, userClass, credits) {
|
| 43 |
+
const updates = {
|
| 44 |
+
Class: userClass,
|
| 45 |
+
Credits: credits
|
| 46 |
+
};
|
| 47 |
+
await db.ref(`users/${nodeKey}`).update(updates);
|
| 48 |
+
return { success: true };
|
| 49 |
+
},
|
| 50 |
+
|
| 51 |
+
async topUpCredits(sessionId, amount) {
|
| 52 |
+
const snapshot = await db.ref('users').orderByChild('Active_Session_ID').equalTo(sessionId).once('value');
|
| 53 |
+
if (!snapshot.exists()) throw new Error("User not found");
|
| 54 |
+
const userKey = Object.keys(snapshot.val())[0];
|
| 55 |
+
const currentCredits = snapshot.val()[userKey].Credits || 0;
|
| 56 |
+
await db.ref(`users/${userKey}`).update({ Credits: currentCredits + amount });
|
| 57 |
+
return { success: true };
|
| 58 |
+
},
|
| 59 |
+
|
| 60 |
+
async deleteUser(nodeKey) {
|
| 61 |
+
await db.ref(`users/${nodeKey}`).remove();
|
| 62 |
+
return { success: true };
|
| 63 |
+
}
|
| 64 |
+
};
|
backend/services/ai.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
export { AIService } from '@/backend/services/ai/index';
|
backend/services/ai/bookRecap.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BOOK_RECAP_PROMPT } from '../../../prompts/bookRecap.js';
|
| 2 |
+
|
| 3 |
+
import { tryModels, getPrompt, DEFAULT_SAFETY_SETTINGS } from '@/backend/services/ai/utils';
|
| 4 |
+
|
| 5 |
+
export async function bookRecap(media, mimeType, targetLanguage, apiKey, isOwnApi = false) {
|
| 6 |
+
const models = ['gemini-3-flash-preview', 'gemini-flash-lite-latest'];
|
| 7 |
+
const isBurmese = targetLanguage.toLowerCase().includes('burm') || targetLanguage.includes('မြန်မာ');
|
| 8 |
+
|
| 9 |
+
// Clean language name in case any legacy strings remain
|
| 10 |
+
const cleanLanguage = targetLanguage.split(' (')[0];
|
| 11 |
+
|
| 12 |
+
const finalPrompt = BOOK_RECAP_PROMPT(targetLanguage);
|
| 13 |
+
|
| 14 |
+
return await tryModels(apiKey, models, async (ai, model) => {
|
| 15 |
+
const response = await ai.models.generateContent({
|
| 16 |
+
model: model,
|
| 17 |
+
contents: {
|
| 18 |
+
parts: [
|
| 19 |
+
{ inlineData: { data: media, mimeType } },
|
| 20 |
+
{ text: `Translate this entire document COMPLETELY (100% length) into ${cleanLanguage}. Do not summarize or skip any content.` }
|
| 21 |
+
]
|
| 22 |
+
},
|
| 23 |
+
config: {
|
| 24 |
+
temperature: 0.3,
|
| 25 |
+
systemInstruction: finalPrompt,
|
| 26 |
+
safetySettings: DEFAULT_SAFETY_SETTINGS,
|
| 27 |
+
thinkingConfig: { thinkingBudget: 0 }
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
const text = response.text;
|
| 32 |
+
if (!text || text.trim().length < 50) {
|
| 33 |
+
throw new Error("MODEL_FAILED_TO_TRANSLATE_DOCUMENT");
|
| 34 |
+
}
|
| 35 |
+
return text;
|
| 36 |
+
});
|
| 37 |
+
}
|
backend/services/ai/comicTranslator.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { COMIC_TRANSLATOR_PROMPT } from '../../../prompts/comicTranslator.js';
|
| 2 |
+
|
| 3 |
+
import { Type } from '@google/genai';
|
| 4 |
+
import { tryModels, getPrompt, DEFAULT_SAFETY_SETTINGS, cleanJson } from '@/backend/services/ai/utils';
|
| 5 |
+
|
| 6 |
+
export async function comicTranslate(media, mimeType, targetLanguage, apiKey, isOwnApi = false) {
|
| 7 |
+
const models = ['gemini-3-flash-preview', 'gemini-flash-lite-latest'];
|
| 8 |
+
const isBurmese = targetLanguage.toLowerCase().includes('burm') || targetLanguage.includes('မြန်မာ');
|
| 9 |
+
|
| 10 |
+
const finalPrompt = COMIC_TRANSLATOR_PROMPT(targetLanguage);
|
| 11 |
+
|
| 12 |
+
// AI identifies text locations and translations
|
| 13 |
+
return await tryModels(apiKey, models, async (ai, model) => {
|
| 14 |
+
const response = await ai.models.generateContent({
|
| 15 |
+
model: model,
|
| 16 |
+
contents: {
|
| 17 |
+
parts: [
|
| 18 |
+
{ inlineData: { data: media, mimeType } },
|
| 19 |
+
{ text: "TASK: Process this document page by page. For each page, identify all text bubbles. Provide their [ymin, xmin, ymax, xmax] coordinates and the translated text in " + targetLanguage + ". Output ONLY valid JSON." }
|
| 20 |
+
]
|
| 21 |
+
},
|
| 22 |
+
config: {
|
| 23 |
+
temperature: 0.1,
|
| 24 |
+
systemInstruction: finalPrompt,
|
| 25 |
+
safetySettings: DEFAULT_SAFETY_SETTINGS,
|
| 26 |
+
responseMimeType: "application/json",
|
| 27 |
+
responseSchema: {
|
| 28 |
+
type: Type.OBJECT,
|
| 29 |
+
properties: {
|
| 30 |
+
pages: {
|
| 31 |
+
type: Type.ARRAY,
|
| 32 |
+
items: {
|
| 33 |
+
type: Type.OBJECT,
|
| 34 |
+
properties: {
|
| 35 |
+
page_number: { type: Type.INTEGER },
|
| 36 |
+
text_blocks: {
|
| 37 |
+
type: Type.ARRAY,
|
| 38 |
+
items: {
|
| 39 |
+
type: Type.OBJECT,
|
| 40 |
+
properties: {
|
| 41 |
+
translated_text: { type: Type.STRING },
|
| 42 |
+
box_2d: {
|
| 43 |
+
type: Type.ARRAY,
|
| 44 |
+
items: { type: Type.NUMBER },
|
| 45 |
+
description: "[ymin, xmin, ymax, xmax] coordinates normalized 0-1000"
|
| 46 |
+
},
|
| 47 |
+
background_color: { type: Type.STRING }
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
},
|
| 55 |
+
required: ['pages']
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
// The backend server will receive this JSON and perform the heavy image manipulation
|
| 61 |
+
// returning a final processed URL or Base64 to the client.
|
| 62 |
+
return JSON.parse(cleanJson(response.text));
|
| 63 |
+
});
|
| 64 |
+
}
|
backend/services/ai/creator.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { CONTENT_CREATOR_PROMPT } from '../../../prompts/contentCreator.js';
|
| 2 |
+
|
| 3 |
+
import { tryModels, getPrompt, DEFAULT_SAFETY_SETTINGS } from '@/backend/services/ai/utils';
|
| 4 |
+
|
| 5 |
+
export async function contentCreator(topic, category, subTopics, contentType, gender, targetLang, apiKey, isOwnApi = false) {
|
| 6 |
+
const models = ['gemini-3-flash-preview', 'gemini-flash-lite-latest'];
|
| 7 |
+
const isBurmese = targetLang.toLowerCase().includes('burm') || targetLang.includes('မြန်မာ');
|
| 8 |
+
const finalPrompt = CONTENT_CREATOR_PROMPT(topic, category, subTopics, contentType, gender, targetLang);
|
| 9 |
+
return await tryModels(apiKey, models, async (ai, model) => {
|
| 10 |
+
const response = await ai.models.generateContent({
|
| 11 |
+
model,
|
| 12 |
+
contents: [{ parts: [{ text: `Topic: ${topic}. Category: ${category}.` }] }],
|
| 13 |
+
config: { temperature: 0.8, systemInstruction: finalPrompt, safetySettings: DEFAULT_SAFETY_SETTINGS }
|
| 14 |
+
});
|
| 15 |
+
return response.text;
|
| 16 |
+
});
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export async function generateImage(prompt, apiKey, isOwnApi = false) {
|
| 20 |
+
const models = ['gemini-2.5-flash-image', 'gemini-3-pro-image-preview'];
|
| 21 |
+
|
| 22 |
+
// Improved visual prompt for better generation
|
| 23 |
+
const visualPrompt = `High-quality cinematic illustrative 3D character design or scene showing: ${prompt}. Vivid colors, detailed environment, 8k resolution style.`;
|
| 24 |
+
|
| 25 |
+
return await tryModels(apiKey, models, async (ai, model) => {
|
| 26 |
+
const response = await ai.models.generateContent({
|
| 27 |
+
model: model,
|
| 28 |
+
contents: {
|
| 29 |
+
parts: [
|
| 30 |
+
{ text: visualPrompt }
|
| 31 |
+
]
|
| 32 |
+
},
|
| 33 |
+
config: {
|
| 34 |
+
imageConfig: { aspectRatio: "1:1" }
|
| 35 |
+
}
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
// Thoroughly check all candidates and parts for inlineData
|
| 39 |
+
for (const candidate of response.candidates || []) {
|
| 40 |
+
for (const part of candidate.content?.parts || []) {
|
| 41 |
+
if (part.inlineData) {
|
| 42 |
+
return `data:image/png;base64,${part.inlineData.data}`;
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
throw new Error("EMPTY_IMAGE_DATA_RESPONSE");
|
| 48 |
+
});
|
| 49 |
+
}
|
backend/services/ai/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { transcribe } from '@/backend/services/ai/transcribe';
|
| 3 |
+
import { recap } from '@/backend/services/ai/recap';
|
| 4 |
+
import { bookRecap } from '@/backend/services/ai/bookRecap';
|
| 5 |
+
import { comicTranslate } from '@/backend/services/ai/comicTranslator';
|
| 6 |
+
import { translate } from '@/backend/services/ai/translate';
|
| 7 |
+
import { srtTranslate } from '@/backend/services/ai/srtTranslate';
|
| 8 |
+
import { tts } from '@/backend/services/ai/tts';
|
| 9 |
+
import { subtitle } from '@/backend/services/ai/subtitle';
|
| 10 |
+
import { contentCreator, generateImage } from '@/backend/services/ai/creator';
|
| 11 |
+
|
| 12 |
+
export const AIService = {
|
| 13 |
+
transcribe,
|
| 14 |
+
recap,
|
| 15 |
+
bookRecap,
|
| 16 |
+
comicTranslate,
|
| 17 |
+
translate,
|
| 18 |
+
srtTranslate,
|
| 19 |
+
tts,
|
| 20 |
+
subtitle,
|
| 21 |
+
contentCreator,
|
| 22 |
+
generateImage
|
| 23 |
+
};
|
backend/services/ai/recap.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { RECAP_PROMPT } from '../../../prompts/recap.js';
|
| 2 |
+
|
| 3 |
+
import { tryModels, getPrompt, DEFAULT_SAFETY_SETTINGS } from '@/backend/services/ai/utils';
|
| 4 |
+
|
| 5 |
+
export async function recap(media, mimeType, targetLanguage, apiKey, isOwnApi = false) {
|
| 6 |
+
// UPDATED: Aligned with the 'no Pro' preference
|
| 7 |
+
const models = ['gemini-3-flash-preview', 'gemini-flash-lite-latest'];
|
| 8 |
+
const finalPrompt = RECAP_PROMPT(targetLanguage);
|
| 9 |
+
return await tryModels(apiKey, models, async (ai, model) => {
|
| 10 |
+
const response = await ai.models.generateContent({
|
| 11 |
+
model: model,
|
| 12 |
+
contents: {
|
| 13 |
+
parts: [
|
| 14 |
+
{ inlineData: { data: media, mimeType } },
|
| 15 |
+
{ text: "Narrate an extremely detailed cinematic recap." }
|
| 16 |
+
]
|
| 17 |
+
},
|
| 18 |
+
config: {
|
| 19 |
+
temperature: 0.4,
|
| 20 |
+
systemInstruction: finalPrompt,
|
| 21 |
+
safetySettings: DEFAULT_SAFETY_SETTINGS,
|
| 22 |
+
thinkingConfig: { thinkingBudget: 0 }
|
| 23 |
+
}
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
const text = response.text;
|
| 27 |
+
if (!text || text.trim().length < 10) {
|
| 28 |
+
throw new Error("MODEL_FAILED_TO_GENERATE_RECAP");
|
| 29 |
+
}
|
| 30 |
+
return text;
|
| 31 |
+
});
|
| 32 |
+
}
|
backend/services/ai/srtTranslate.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { SRT_TRANSLATOR_PROMPT } from '../../../prompts/srttranslator.js';
|
| 2 |
+
import { tryModels, getPrompt, cleanSRTOutput, DEFAULT_SAFETY_SETTINGS } from '@/backend/services/ai/utils';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Counts the number of SRT blocks in a given text.
|
| 6 |
+
*/
|
| 7 |
+
function countSrtBlocks(text) {
|
| 8 |
+
if (!text) return 0;
|
| 9 |
+
// Improved regex to count standard SRT blocks accurately
|
| 10 |
+
const matches = text.match(/^\d+\s*\r?\n\d{2}:\d{2}:\d{2},\d{3}/gm);
|
| 11 |
+
return matches ? matches.length : 0;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export async function srtTranslate(srtContent, sourceLanguage, targetLanguage, apiKey, isOwnApi = false) {
|
| 15 |
+
const models = ['gemini-3-flash-preview', 'gemini-flash-lite-latest'];
|
| 16 |
+
const finalPrompt = SRT_TRANSLATOR_PROMPT(sourceLanguage, targetLanguage);
|
| 17 |
+
|
| 18 |
+
// Normalize line endings and split into blocks
|
| 19 |
+
const blocks = srtContent.replace(/\r\n/g, '\n').split(/\n\s*\n/).filter(b => b.trim().length > 0);
|
| 20 |
+
|
| 21 |
+
const CHUNK_SIZE = 120; // Increased to 120 as requested for higher throughput
|
| 22 |
+
const BATCH_SIZE = 2; // Enabled Parallel processing (2 at a time)
|
| 23 |
+
const COOLDOWN = 1000; // Minimal cooldown between parallel batches
|
| 24 |
+
|
| 25 |
+
console.log(`[AI-SRT-SAFE-MAX] Segment Blocks: ${blocks.length}. Chunks: ${Math.ceil(blocks.length/CHUNK_SIZE)}.`);
|
| 26 |
+
|
| 27 |
+
const chunkTexts = [];
|
| 28 |
+
const chunkBlockCounts = [];
|
| 29 |
+
for (let i = 0; i < blocks.length; i += CHUNK_SIZE) {
|
| 30 |
+
const slice = blocks.slice(i, i + CHUNK_SIZE);
|
| 31 |
+
chunkTexts.push(slice.join('\n\n'));
|
| 32 |
+
chunkBlockCounts.push(slice.length);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const results = [];
|
| 36 |
+
for (let i = 0; i < chunkTexts.length; i += BATCH_SIZE) {
|
| 37 |
+
const currentBatchIndices = Array.from({ length: Math.min(BATCH_SIZE, chunkTexts.length - i) }, (_, k) => i + k);
|
| 38 |
+
|
| 39 |
+
const batchPromises = currentBatchIndices.map(idx =>
|
| 40 |
+
tryModels(apiKey, models, async (ai, model) => {
|
| 41 |
+
const inputText = chunkTexts[idx];
|
| 42 |
+
const expectedCount = chunkBlockCounts[idx];
|
| 43 |
+
|
| 44 |
+
let response = await ai.models.generateContent({
|
| 45 |
+
model: model,
|
| 46 |
+
contents: [{ parts: [{ text: `Translate these ${expectedCount} blocks now. Maintain all IDs:\n\n${inputText}` }] }],
|
| 47 |
+
config: {
|
| 48 |
+
temperature: 0.1,
|
| 49 |
+
systemInstruction: finalPrompt,
|
| 50 |
+
safetySettings: DEFAULT_SAFETY_SETTINGS,
|
| 51 |
+
thinkingConfig: { thinkingBudget: 0 }
|
| 52 |
+
}
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
let translatedText = cleanSRTOutput(response.text);
|
| 56 |
+
let actualCount = countSrtBlocks(translatedText);
|
| 57 |
+
|
| 58 |
+
// --- INTEGRITY AUTO-RECOVERY ---
|
| 59 |
+
if (actualCount < expectedCount) {
|
| 60 |
+
console.warn(`[AI-SRT] Gap detected in chunk ${idx} (${actualCount}/${expectedCount}). Retrying with max precision...`);
|
| 61 |
+
response = await ai.models.generateContent({
|
| 62 |
+
model: model,
|
| 63 |
+
contents: [{ parts: [{ text: `CRITICAL: Do not skip lines. You MUST return exactly ${expectedCount} blocks. Translate ALL now:\n\n${inputText}` }] }],
|
| 64 |
+
config: { temperature: 0.0, systemInstruction: finalPrompt, safetySettings: DEFAULT_SAFETY_SETTINGS }
|
| 65 |
+
});
|
| 66 |
+
translatedText = cleanSRTOutput(response.text);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return translatedText;
|
| 70 |
+
})
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
const batchResults = await Promise.all(batchPromises);
|
| 74 |
+
results.push(...batchResults);
|
| 75 |
+
|
| 76 |
+
if (i + BATCH_SIZE < chunkTexts.length) {
|
| 77 |
+
await new Promise(r => setTimeout(r, COOLDOWN));
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
return results.join('\n\n').trim();
|
| 82 |
+
}
|
backend/services/ai/subtitle.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { SUBTITLE_PROMPT } from '../../../prompts/subtitle.js';
|
| 2 |
+
|
| 3 |
+
import { tryModels, getPrompt, cleanSRTOutput, DEFAULT_SAFETY_SETTINGS } from '@/backend/services/ai/utils';
|
| 4 |
+
import { transcribe } from '@/backend/services/ai/transcribe';
|
| 5 |
+
|
| 6 |
+
const formatMsToSRT = (ms) => {
|
| 7 |
+
const hours = Math.floor(ms / 3600000);
|
| 8 |
+
const mins = Math.floor((ms % 3600000) / 60000);
|
| 9 |
+
const secs = Math.floor((ms % 60000) / 1000);
|
| 10 |
+
const mms = Math.floor(ms % 1000);
|
| 11 |
+
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')},${mms.toString().padStart(3, '0')}`;
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
export async function subtitle(mediaBase64, mimeType, fullScript, apiKey, isOwnApi = false, sourceLanguage = 'English', startOffsetMs = 0, lastScriptIndex = 0) {
|
| 15 |
+
let scriptToProcess = fullScript || "";
|
| 16 |
+
|
| 17 |
+
// If script is empty, transcribe the whole media
|
| 18 |
+
if (!scriptToProcess) {
|
| 19 |
+
scriptToProcess = await transcribe(mediaBase64, mimeType, apiKey, isOwnApi);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const isBurmese = sourceLanguage.toLowerCase().includes('burm') || sourceLanguage.includes('မြန်မာ');
|
| 23 |
+
const promptTemplate = getPrompt('subtitle.txt');
|
| 24 |
+
|
| 25 |
+
// REMOVED: 15-second restriction logic.
|
| 26 |
+
// ADDED: Explicit instruction to process the ENTIRE audio.
|
| 27 |
+
const finalPrompt = promptTemplate
|
| 28 |
+
.replace('{{script}}', scriptToProcess)
|
| 29 |
+
.replace('{{language}}', isBurmese ? "Burmese (Conversational)" : sourceLanguage)
|
| 30 |
+
.replace('15-second audio input', 'the provided audio file')
|
| 31 |
+
.replace('Align the script', 'Align the COMPLETE script from start to finish');
|
| 32 |
+
|
| 33 |
+
const rawSRT = await tryModels(apiKey, ['gemini-3-flash-preview'], async (ai, model) => {
|
| 34 |
+
const response = await ai.models.generateContent({
|
| 35 |
+
model,
|
| 36 |
+
contents: {
|
| 37 |
+
parts: [
|
| 38 |
+
{ inlineData: { data: mediaBase64, mimeType: 'audio/wav' } },
|
| 39 |
+
{ text: `GENERATE FULL SRT: Listen to this entire file and align every word from the reference script. Return the complete SRT.` }
|
| 40 |
+
]
|
| 41 |
+
},
|
| 42 |
+
config: {
|
| 43 |
+
temperature: 0,
|
| 44 |
+
systemInstruction: finalPrompt,
|
| 45 |
+
safetySettings: DEFAULT_SAFETY_SETTINGS
|
| 46 |
+
}
|
| 47 |
+
});
|
| 48 |
+
return cleanSRTOutput(response.text);
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
return {
|
| 52 |
+
srt: rawSRT || ""
|
| 53 |
+
};
|
| 54 |
+
}
|
backend/services/ai/transcribe.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { TRANSCRIPTION_PROMPT } from '../../../prompts/transcription.js';
|
| 2 |
+
|
| 3 |
+
import { tryModels, getPrompt, DEFAULT_SAFETY_SETTINGS } from '@/backend/services/ai/utils';
|
| 4 |
+
|
| 5 |
+
export async function transcribe(media, mimeType, apiKey, isOwnApi = false) {
|
| 6 |
+
const models = ['gemini-3-flash-preview', 'gemini-flash-lite-latest'];
|
| 7 |
+
const systemPrompt = TRANSCRIPTION_PROMPT;
|
| 8 |
+
return await tryModels(apiKey, models, async (ai, model) => {
|
| 9 |
+
const response = await ai.models.generateContent({
|
| 10 |
+
model: model,
|
| 11 |
+
contents: {
|
| 12 |
+
parts: [
|
| 13 |
+
{ inlineData: { data: media, mimeType } },
|
| 14 |
+
{ text: "Transcribe accurately and completely. Do not skip any dialogue." }
|
| 15 |
+
]
|
| 16 |
+
},
|
| 17 |
+
config: {
|
| 18 |
+
temperature: 0.1,
|
| 19 |
+
systemInstruction: systemPrompt,
|
| 20 |
+
safetySettings: DEFAULT_SAFETY_SETTINGS,
|
| 21 |
+
thinkingConfig: { thinkingBudget: 0 }
|
| 22 |
+
}
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
const text = response.text;
|
| 26 |
+
if (!text || text.trim().length < 2) {
|
| 27 |
+
throw new Error("EMPTY_RESPONSE_FROM_MODEL");
|
| 28 |
+
}
|
| 29 |
+
return text;
|
| 30 |
+
});
|
| 31 |
+
}
|
backend/services/ai/translate.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { TRANSLATION_PROMPT } from '../../../prompts/translation.js';
|
| 2 |
+
|
| 3 |
+
import { Type } from '@google/genai';
|
| 4 |
+
import { tryModels, getPrompt, cleanJson, DEFAULT_SAFETY_SETTINGS } from '@/backend/services/ai/utils';
|
| 5 |
+
|
| 6 |
+
export async function translate(text, targetLanguage, options, apiKey, isOwnApi = false) {
|
| 7 |
+
const models = ['gemini-3-flash-preview', 'gemini-flash-lite-latest'];
|
| 8 |
+
const isBurmese = targetLanguage.toLowerCase().includes('burm') || targetLanguage.includes('မြန်မာ');
|
| 9 |
+
const finalPrompt = TRANSLATION_PROMPT(targetLanguage, options);
|
| 10 |
+
|
| 11 |
+
return await tryModels(apiKey, models, async (ai, model) => {
|
| 12 |
+
const response = await ai.models.generateContent({
|
| 13 |
+
model: model,
|
| 14 |
+
contents: [{ parts: [{ text: `CONTENT: ${text}` }] }],
|
| 15 |
+
config: {
|
| 16 |
+
temperature: 0.7,
|
| 17 |
+
systemInstruction: finalPrompt,
|
| 18 |
+
responseMimeType: "application/json",
|
| 19 |
+
safetySettings: DEFAULT_SAFETY_SETTINGS,
|
| 20 |
+
responseSchema: {
|
| 21 |
+
type: Type.OBJECT,
|
| 22 |
+
properties: {
|
| 23 |
+
translation: { type: Type.STRING },
|
| 24 |
+
deepMeaning: { type: Type.STRING, nullable: true },
|
| 25 |
+
suggestions: { type: Type.OBJECT, nullable: true, properties: { videoTitles: { type: Type.ARRAY, items: { type: Type.STRING } }, thumbnailTexts: { type: Type.ARRAY, items: { type: Type.STRING } } } }
|
| 26 |
+
},
|
| 27 |
+
required: ['translation']
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
});
|
| 31 |
+
return JSON.parse(cleanJson(response.text));
|
| 32 |
+
});
|
| 33 |
+
}
|
backend/services/ai/tts.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { AI_VOICE_PROMPT } from '../../../prompts/aiVoice.js';
|
| 2 |
+
|
| 3 |
+
import { tryModels, getPrompt, DEFAULT_SAFETY_SETTINGS } from '@/backend/services/ai/utils';
|
| 4 |
+
import { Modality } from '@google/genai';
|
| 5 |
+
|
| 6 |
+
export async function tts(text, voiceName, tone, apiKey, isOwnApi = false) {
|
| 7 |
+
// Strict requirement: Max 3500 characters
|
| 8 |
+
const ABSOLUTE_MAX_LENGTH = 3500;
|
| 9 |
+
if (text && text.length > ABSOLUTE_MAX_LENGTH) {
|
| 10 |
+
throw new Error(`Text is too long (${text.length} chars). Maximum allowed is ${ABSOLUTE_MAX_LENGTH} characters.`);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const models = ['gemini-2.5-flash-preview-tts', 'gemini-2.5-pro-preview-tts'];
|
| 14 |
+
const promptInstructions = AI_VOICE_PROMPT(tone);
|
| 15 |
+
|
| 16 |
+
// For TTS models, prepending the tone instructions to the text is more stable than systemInstruction
|
| 17 |
+
const textWithInstructions = `${promptInstructions}\n\nSCRIPT TO SPEAK:\n${text}`;
|
| 18 |
+
|
| 19 |
+
console.log(`[TTS] Generating content... Length: ${text?.length || 0}`);
|
| 20 |
+
|
| 21 |
+
const chunkBase64 = await tryModels(apiKey, models, async (ai, model) => {
|
| 22 |
+
const response = await ai.models.generateContent({
|
| 23 |
+
model: model,
|
| 24 |
+
contents: [{ parts: [{ text: textWithInstructions }] }],
|
| 25 |
+
config: {
|
| 26 |
+
responseModalities: [Modality.AUDIO],
|
| 27 |
+
safetySettings: DEFAULT_SAFETY_SETTINGS,
|
| 28 |
+
speechConfig: {
|
| 29 |
+
voiceConfig: {
|
| 30 |
+
prebuiltVoiceConfig: {
|
| 31 |
+
voiceName: voiceName || 'Zephyr'
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
const part = response.candidates?.[0]?.content?.parts.find(p => p.inlineData);
|
| 39 |
+
if (part?.inlineData?.data) return part.inlineData.data;
|
| 40 |
+
throw new Error("EMPTY_AUDIO_DATA_FROM_GEMINI");
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
return chunkBase64;
|
| 44 |
+
}
|
backend/services/ai/utils.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { GoogleGenAI } from '@google/genai';
|
| 3 |
+
import fs from 'fs';
|
| 4 |
+
import path from 'path';
|
| 5 |
+
import { fileURLToPath } from 'url';
|
| 6 |
+
|
| 7 |
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
| 8 |
+
|
| 9 |
+
export const getPrompt = (filename) => {
|
| 10 |
+
try {
|
| 11 |
+
const filePath = path.resolve(__dirname, '..', '..', 'prompts', filename);
|
| 12 |
+
if (fs.existsSync(filePath)) {
|
| 13 |
+
return fs.readFileSync(filePath, 'utf8');
|
| 14 |
+
} else {
|
| 15 |
+
const internalPath = path.resolve(__dirname, '..', '..', '..', 'prompts', filename);
|
| 16 |
+
return fs.existsSync(internalPath) ? fs.readFileSync(internalPath, 'utf8') : "";
|
| 17 |
+
}
|
| 18 |
+
} catch (e) {
|
| 19 |
+
console.error(`[AI-ENGINE] Prompt Load Error (${filename}):`, e.message);
|
| 20 |
+
return "";
|
| 21 |
+
}
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
export function cleanJson(raw) {
|
| 25 |
+
if (!raw) return "";
|
| 26 |
+
return raw.replace(/```json/gi, '').replace(/```/gi, '').trim();
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export function cleanSRTOutput(text) {
|
| 30 |
+
if (!text) return "";
|
| 31 |
+
let cleaned = text.replace(/```srt/gi, '').replace(/```/gi, '').trim();
|
| 32 |
+
const firstIndex = cleaned.search(/^\d+\s*\r?\n\d{2}:\d{2}:\d{2},\d{3}/m);
|
| 33 |
+
if (firstIndex !== -1) {
|
| 34 |
+
cleaned = cleaned.substring(firstIndex);
|
| 35 |
+
}
|
| 36 |
+
return cleaned;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export const DEFAULT_SAFETY_SETTINGS = [
|
| 40 |
+
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH' },
|
| 41 |
+
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_ONLY_HIGH' },
|
| 42 |
+
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'BLOCK_ONLY_HIGH' },
|
| 43 |
+
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH' },
|
| 44 |
+
{ category: 'HARM_CATEGORY_CIVIC_INTEGRITY', threshold: 'BLOCK_ONLY_HIGH' }
|
| 45 |
+
];
|
| 46 |
+
|
| 47 |
+
export async function tryModels(apiKey, modelList, taskFn) {
|
| 48 |
+
let lastError = null;
|
| 49 |
+
const ai = new GoogleGenAI({ apiKey });
|
| 50 |
+
const MAX_RETRIES_PER_MODEL = 2;
|
| 51 |
+
|
| 52 |
+
for (const modelName of modelList) {
|
| 53 |
+
for (let attempt = 0; attempt <= MAX_RETRIES_PER_MODEL; attempt++) {
|
| 54 |
+
try {
|
| 55 |
+
return await taskFn(ai, modelName);
|
| 56 |
+
} catch (e) {
|
| 57 |
+
const rawError = (e.message || "Unknown error").toLowerCase();
|
| 58 |
+
const isRateLimit = rawError.includes('429') || rawError.includes('quota') || rawError.includes('limit');
|
| 59 |
+
|
| 60 |
+
if (isRateLimit && attempt < MAX_RETRIES_PER_MODEL) {
|
| 61 |
+
const waitTime = (attempt + 1) * 5000;
|
| 62 |
+
console.warn(`[AI-ENGINE] Rate limit on ${modelName}. Retry in ${waitTime}ms...`);
|
| 63 |
+
await new Promise(r => setTimeout(r, waitTime));
|
| 64 |
+
continue;
|
| 65 |
+
}
|
| 66 |
+
console.error(`[AI-ENGINE] Error on ${modelName}:`, rawError);
|
| 67 |
+
lastError = e;
|
| 68 |
+
break;
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
throw lastError;
|
| 73 |
+
}
|
backend/services/authService.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { db } from '@/backend/services/firebase';
|
| 2 |
+
|
| 3 |
+
export const AuthService = {
|
| 4 |
+
async saveCustomKey(sessionId, apiKey) {
|
| 5 |
+
const snapshot = await db.ref('users').orderByChild('Active_Session_ID').equalTo(sessionId).once('value');
|
| 6 |
+
if (!snapshot.exists()) throw new Error("User not found.");
|
| 7 |
+
const userKey = Object.keys(snapshot.val())[0];
|
| 8 |
+
await db.ref(`users/${userKey}`).update({ Private_API_Key: apiKey });
|
| 9 |
+
return { success: true };
|
| 10 |
+
},
|
| 11 |
+
|
| 12 |
+
async updateActiveSession(userId, newSessionId) {
|
| 13 |
+
if (!userId) throw new Error("Membership ID is required.");
|
| 14 |
+
|
| 15 |
+
// Find user by their PERMANENT Login_Key first
|
| 16 |
+
let snapshot = await db.ref('users').orderByChild('Login_Key').equalTo(userId).once('value');
|
| 17 |
+
|
| 18 |
+
// Fallback: If not found, try searching by Active_Session_ID (for legacy users)
|
| 19 |
+
if (!snapshot.exists()) {
|
| 20 |
+
snapshot = await db.ref('users').orderByChild('Active_Session_ID').equalTo(userId).once('value');
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
if (!snapshot.exists()) throw new Error("Membership ID not found in records.");
|
| 24 |
+
|
| 25 |
+
const userKey = Object.keys(snapshot.val())[0];
|
| 26 |
+
const userData = snapshot.val()[userKey];
|
| 27 |
+
|
| 28 |
+
const updates = { Active_Session_ID: newSessionId };
|
| 29 |
+
|
| 30 |
+
// If this was a legacy user without a Login_Key, set it now to stabilize their account
|
| 31 |
+
if (!userData.Login_Key) {
|
| 32 |
+
updates.Login_Key = userId;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
await db.ref(`users/${userKey}`).update(updates);
|
| 36 |
+
|
| 37 |
+
return { success: true };
|
| 38 |
+
}
|
| 39 |
+
};
|
backend/services/credit.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { db } from '@/backend/services/firebase';
|
| 3 |
+
|
| 4 |
+
export async function checkEligibility(sessionId, toolKey, isOwnKey) {
|
| 5 |
+
if (!sessionId) throw new Error("Authentication failed. No session ID provided.");
|
| 6 |
+
|
| 7 |
+
const now = new Date();
|
| 8 |
+
const today = now.toLocaleDateString('en-CA'); // Robust YYYY-MM-DD
|
| 9 |
+
|
| 10 |
+
// Map toolKey to prefix-aware usage key
|
| 11 |
+
const usageKey = isOwnKey ? `own_${toolKey}` : `app_${toolKey}`;
|
| 12 |
+
|
| 13 |
+
// 1. Fetch System Tier Settings
|
| 14 |
+
const sysSnapshot = await db.ref('system/settings/tiers').once('value');
|
| 15 |
+
const tiers = sysSnapshot.val() || {};
|
| 16 |
+
|
| 17 |
+
const toolMapper = {
|
| 18 |
+
'count_transcript': 'transcript',
|
| 19 |
+
'count_translate': 'translate',
|
| 20 |
+
'count_srt_translate': 'srt_translator',
|
| 21 |
+
'count_tts': 'tts',
|
| 22 |
+
'count_subtitle': 'subtitle_gen',
|
| 23 |
+
'count_creator_text': 'content_creator',
|
| 24 |
+
'count_creator_image': 'ai_image'
|
| 25 |
+
};
|
| 26 |
+
const tierToolKey = toolMapper[toolKey] || toolKey;
|
| 27 |
+
|
| 28 |
+
let userKey, userData, userClass;
|
| 29 |
+
|
| 30 |
+
if (sessionId === 'free_access') {
|
| 31 |
+
userClass = 'FREE';
|
| 32 |
+
userData = { Usage: {}, Credits: 0, Class: 'FREE' };
|
| 33 |
+
} else {
|
| 34 |
+
const snapshot = await db.ref('users').orderByChild('Active_Session_ID').equalTo(sessionId).once('value');
|
| 35 |
+
if (!snapshot.exists()) throw new Error("Invalid Membership ID. Please login again.");
|
| 36 |
+
userKey = Object.keys(snapshot.val())[0];
|
| 37 |
+
userData = snapshot.val()[userKey];
|
| 38 |
+
if (userData.Status !== 'ACTIVE') throw new Error("Account suspended. Please contact admin.");
|
| 39 |
+
userClass = userData.Class === 'MEMBER+' ? 'MEMBER_PLUS' : 'MEMBER';
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const tierConfig = tiers[userClass] || { tools: {}, ownTools: {}, limits: {}, ownLimits: {}, apiAccess: { app: true, own: true } };
|
| 43 |
+
|
| 44 |
+
// 2. CHECK API ACCESS PERMISSIONS
|
| 45 |
+
if (isOwnKey && tierConfig.apiAccess?.own === false) {
|
| 46 |
+
throw new Error(`OWN_API_RESTRICTED: Your ${userClass} class is not allowed to use private API keys.`);
|
| 47 |
+
}
|
| 48 |
+
if (!isOwnKey && tierConfig.apiAccess?.app === false) {
|
| 49 |
+
throw new Error(`APP_API_RESTRICTED: Shared App API is currently disabled for your class.`);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// 3. CHECK SPECIFIC TOOL STATUS FOR TIER (Mode Specific)
|
| 53 |
+
const toolMap = isOwnKey ? (tierConfig.ownTools || {}) : (tierConfig.tools || {});
|
| 54 |
+
if (toolMap[tierToolKey] === false) {
|
| 55 |
+
throw new Error(`TOOL_DISABLED: This tool is disabled in ${isOwnKey ? 'Own API' : 'App API'} mode for ${userClass} class.`);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const usage = (userData.Last_Usage_Date === today) ? (userData.Usage || {}) : {};
|
| 59 |
+
|
| 60 |
+
// 4. CHECK LIMITS (Mode Specific)
|
| 61 |
+
const limit = isOwnKey ? (tierConfig.ownLimits?.[tierToolKey] ?? -1) : (tierConfig.limits?.[tierToolKey] ?? -1);
|
| 62 |
+
|
| 63 |
+
if (limit !== -1) {
|
| 64 |
+
const currentCount = parseInt(usage[usageKey] || 0);
|
| 65 |
+
if (currentCount >= limit) {
|
| 66 |
+
throw new Error(`DAILY_QUOTA_REACHED: Your ${userClass} daily limit for ${isOwnKey ? 'Own' : 'App'} API has been reached.`);
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// 5. CHECK CREDITS (Only if using App API)
|
| 71 |
+
let cost = 0;
|
| 72 |
+
if (!isOwnKey) {
|
| 73 |
+
const costMap = {
|
| 74 |
+
'count_transcript': 1, 'count_translate': 1, 'count_srt_translate': 5,
|
| 75 |
+
'count_tts': 3, 'count_subtitle': 1, 'count_creator_text': 1, 'count_creator_image': 5
|
| 76 |
+
};
|
| 77 |
+
cost = costMap[toolKey] || 1;
|
| 78 |
+
|
| 79 |
+
if (userClass === 'MEMBER_PLUS') {
|
| 80 |
+
const freeTools = ['count_transcript', 'count_translate', 'count_subtitle', 'count_creator_text'];
|
| 81 |
+
if (freeTools.includes(toolKey)) cost = 0;
|
| 82 |
+
}
|
| 83 |
+
if (userClass === 'FREE') cost = 0;
|
| 84 |
+
|
| 85 |
+
const currentCredits = userData.Credits || 0;
|
| 86 |
+
if (cost > 0 && currentCredits < cost) {
|
| 87 |
+
throw new Error(`INSUFFICIENT_BALANCE: This task costs ${cost} credits. Your balance is ${currentCredits}.`);
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
return { userKey, userData, usageKey, cost, today, isGuest: (userClass === 'FREE'), isOwnKey };
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
export async function commitDeduction(eligibilityData, toolKey) {
|
| 95 |
+
if (!eligibilityData) return;
|
| 96 |
+
const { userKey, userData, usageKey, cost, today, isGuest } = eligibilityData;
|
| 97 |
+
|
| 98 |
+
if (isGuest) return;
|
| 99 |
+
|
| 100 |
+
const updates = {};
|
| 101 |
+
if (cost > 0) {
|
| 102 |
+
const newBalance = (userData.Credits || 0) - cost;
|
| 103 |
+
updates[`users/${userKey}/Credits`] = newBalance >= 0 ? newBalance : 0;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
if (userData.Last_Usage_Date !== today) {
|
| 107 |
+
updates[`users/${userKey}/Last_Usage_Date`] = today;
|
| 108 |
+
updates[`users/${userKey}/Usage`] = { [usageKey]: 1 };
|
| 109 |
+
} else {
|
| 110 |
+
const currentUsage = (userData.Usage && userData.Usage[usageKey]) || 0;
|
| 111 |
+
updates[`users/${userKey}/Usage/${usageKey}`] = currentUsage + 1;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
await db.ref().update(updates);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
export async function processFreePool(toolKey) {
|
| 118 |
+
const today = new Date().toLocaleDateString('en-CA');
|
| 119 |
+
const poolPath = `system/daily_pool/${today}/app_${toolKey}`; // Default to app prefix for free pool
|
| 120 |
+
const snapshot = await db.ref(poolPath).once('value');
|
| 121 |
+
const current = snapshot.val() || 0;
|
| 122 |
+
await db.ref(poolPath).set(current + 1);
|
| 123 |
+
}
|
backend/services/creditService.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { db } from '@/backend/services/firebase';
|
| 3 |
+
|
| 4 |
+
export const CreditService = {
|
| 5 |
+
async checkEligibility(sessionId, guestId, toolKey, isOwnKey, creditCostOverride = null) {
|
| 6 |
+
if (!sessionId) throw new Error("Authentication failed.");
|
| 7 |
+
|
| 8 |
+
const now = new Date();
|
| 9 |
+
const today = now.toLocaleDateString('en-CA');
|
| 10 |
+
const usageKey = isOwnKey ? `own_${toolKey}` : `app_${toolKey}`;
|
| 11 |
+
|
| 12 |
+
// 1. Fetch System Tier Settings
|
| 13 |
+
const sysSnapshot = await db.ref('system/settings/tiers').once('value');
|
| 14 |
+
const tiers = sysSnapshot.val() || {};
|
| 15 |
+
|
| 16 |
+
const toolMapper = {
|
| 17 |
+
'count_transcript': 'transcript',
|
| 18 |
+
'count_translate': 'translate',
|
| 19 |
+
'count_srt_translate': 'srt_translator',
|
| 20 |
+
'count_tts': 'tts',
|
| 21 |
+
'count_subtitle': 'subtitle_gen',
|
| 22 |
+
'count_creator_text': 'content_creator',
|
| 23 |
+
'count_creator_image': 'ai_image',
|
| 24 |
+
'downloader': 'downloader',
|
| 25 |
+
'book_recap': 'book_recap'
|
| 26 |
+
};
|
| 27 |
+
const tierToolKey = toolMapper[toolKey] || toolKey;
|
| 28 |
+
|
| 29 |
+
let userKey, userData, userClass, storagePath;
|
| 30 |
+
|
| 31 |
+
if (sessionId === 'free_access') {
|
| 32 |
+
userClass = 'FREE';
|
| 33 |
+
if (!guestId || guestId === 'anonymous') {
|
| 34 |
+
// Return generic guest data for free mode
|
| 35 |
+
userData = { Usage: {}, Credits: 0, Class: 'FREE', Last_Usage_Date: today };
|
| 36 |
+
storagePath = `guests/temp_guest`;
|
| 37 |
+
} else {
|
| 38 |
+
storagePath = `guests/${guestId}/${today}`;
|
| 39 |
+
const usageSnapshot = await db.ref(storagePath).once('value');
|
| 40 |
+
const currentUsage = usageSnapshot.val() || {};
|
| 41 |
+
userData = { Usage: currentUsage, Credits: 0, Class: 'FREE', Last_Usage_Date: today };
|
| 42 |
+
}
|
| 43 |
+
} else {
|
| 44 |
+
// Find user by temporary session ID
|
| 45 |
+
const snapshot = await db.ref('users').orderByChild('Active_Session_ID').equalTo(sessionId).once('value');
|
| 46 |
+
if (!snapshot.exists()) throw new Error("Invalid Session. Please login again.");
|
| 47 |
+
|
| 48 |
+
userKey = Object.keys(snapshot.val())[0];
|
| 49 |
+
userData = snapshot.val()[userKey];
|
| 50 |
+
if (userData.Status !== 'ACTIVE') throw new Error("Account suspended.");
|
| 51 |
+
|
| 52 |
+
userClass = userData.Class === 'MEMBER+' ? 'MEMBER_PLUS' : (userData.Class === 'BASIC' ? 'BASIC' : 'MEMBER');
|
| 53 |
+
storagePath = `users/${userKey}`;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const tierConfig = tiers[userClass] || { apiAccess: { app: true, own: true } };
|
| 57 |
+
|
| 58 |
+
// Check if user is trying to use their own API but it's disabled for their class
|
| 59 |
+
if (isOwnKey && tierConfig.apiAccess?.own === false) throw new Error("OWN_API_RESTRICTED");
|
| 60 |
+
if (!isOwnKey && tierConfig.apiAccess?.app === false) throw new Error("APP_API_RESTRICTED");
|
| 61 |
+
|
| 62 |
+
// Check Specific Tool Toggle (Mode Specific)
|
| 63 |
+
const toolToggleMap = isOwnKey ? (tierConfig.ownTools || {}) : (tierConfig.tools || {});
|
| 64 |
+
if (toolToggleMap[tierToolKey] === false) throw new Error("TOOL_DISABLED");
|
| 65 |
+
|
| 66 |
+
const usage = (userData.Last_Usage_Date === today) ? (userData.Usage || {}) : {};
|
| 67 |
+
|
| 68 |
+
// 1. Declare the limit variable based on the tier settings
|
| 69 |
+
let limit = isOwnKey
|
| 70 |
+
? (tierConfig.ownLimits?.[tierToolKey] ?? -1)
|
| 71 |
+
: (tierConfig.limits?.[tierToolKey] ?? -1);
|
| 72 |
+
|
| 73 |
+
// 2. Override: If using Own API Key, force the limit to -1 (Unlimited)
|
| 74 |
+
if (isOwnKey) {
|
| 75 |
+
limit = -1;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// 3. Perform the check only if a limit exists
|
| 79 |
+
if (limit !== -1) {
|
| 80 |
+
const currentCount = parseInt(usage[usageKey] || 0);
|
| 81 |
+
if (currentCount >= limit) {
|
| 82 |
+
throw new Error("DAILY_LIMIT_REACHED");
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
if (isOwnKey) {
|
| 87 |
+
limit = -1; // -1 means "No Limit" or "Unlimited" in your logic (LOL kyaw Gyi)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Check Daily Frequency Limit (Mode Specific)
|
| 91 |
+
// let limit = isOwnKey ? (tierConfig.ownLimits?.[tierToolKey] ?? -1) : (tierConfig.limits?.[tierToolKey] ?? -1);
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
if (limit !== -1) {
|
| 95 |
+
const currentCount = parseInt(usage[usageKey] || 0);
|
| 96 |
+
if (currentCount >= limit) throw new Error("DAILY_LIMIT_REACHED");
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
let cost = 0;
|
| 100 |
+
if (userClass !== 'FREE' && !isOwnKey) {
|
| 101 |
+
const costMap = {
|
| 102 |
+
'count_transcript': 4,
|
| 103 |
+
'count_translate': 4,
|
| 104 |
+
'count_srt_translate': 7,
|
| 105 |
+
'count_tts': 3,
|
| 106 |
+
'count_subtitle': 2,
|
| 107 |
+
'count_creator_text': 3,
|
| 108 |
+
'count_creator_image': 5,
|
| 109 |
+
'downloader': 2,
|
| 110 |
+
'book_recap': 8
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
cost = (creditCostOverride !== null) ? creditCostOverride : (costMap[toolKey] || 1);
|
| 114 |
+
|
| 115 |
+
if (cost > 0 && (userData.Credits || 0) < cost) {
|
| 116 |
+
throw new Error(`INSUFFICIENT_BALANCE: Needs ${cost} credits.`);
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
return { userKey, storagePath, userData, usageKey, cost, today, isGuest: (userClass === 'FREE'), isOwnKey };
|
| 121 |
+
},
|
| 122 |
+
|
| 123 |
+
async commitDeduction(eligibilityData) {
|
| 124 |
+
if (!eligibilityData) return;
|
| 125 |
+
const { storagePath, userData, usageKey, cost, today, isGuest, isOwnKey } = eligibilityData;
|
| 126 |
+
|
| 127 |
+
// Own API mode records usage but never deducts credits
|
| 128 |
+
const finalCost = isOwnKey ? 0 : cost;
|
| 129 |
+
|
| 130 |
+
const updates = {};
|
| 131 |
+
if (isGuest) {
|
| 132 |
+
if (storagePath === 'guests/temp_guest') return;
|
| 133 |
+
const currentUsage = (userData.Usage?.[usageKey]) || 0;
|
| 134 |
+
updates[`${storagePath}/${usageKey}`] = currentUsage + 1;
|
| 135 |
+
await db.ref().update(updates);
|
| 136 |
+
return;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
if (finalCost > 0) {
|
| 140 |
+
const newBalance = (userData.Credits || 0) - finalCost;
|
| 141 |
+
updates[`${storagePath}/Credits`] = Math.max(0, newBalance);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
if (userData.Last_Usage_Date !== today) {
|
| 145 |
+
updates[`${storagePath}/Last_Usage_Date`] = today;
|
| 146 |
+
updates[`${storagePath}/Usage`] = { [usageKey]: 1 };
|
| 147 |
+
} else {
|
| 148 |
+
const currentUsage = (userData.Usage?.[usageKey]) || 0;
|
| 149 |
+
updates[`${storagePath}/Usage/${usageKey}`] = currentUsage + 1;
|
| 150 |
+
}
|
| 151 |
+
await db.ref().update(updates);
|
| 152 |
+
}
|
| 153 |
+
};
|
backend/services/firebase.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import admin from 'firebase-admin';
|
| 3 |
+
import fs from 'fs';
|
| 4 |
+
import path from 'path';
|
| 5 |
+
|
| 6 |
+
// Service Account file path - MUST be from transcript-master project
|
| 7 |
+
const serviceAccountPath = path.resolve(process.cwd(), 'service-account.json');
|
| 8 |
+
|
| 9 |
+
try {
|
| 10 |
+
if (!admin.apps.length) {
|
| 11 |
+
// Explicitly using transcript-master project details
|
| 12 |
+
const config = {
|
| 13 |
+
databaseURL: "https://klingwatermark-default-rtdb.asia-southeast1.firebasedatabase.app",
|
| 14 |
+
projectId: "klingwatermark"
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
if (fs.existsSync(serviceAccountPath)) {
|
| 18 |
+
const serviceAccount = JSON.parse(fs.readFileSync(serviceAccountPath, 'utf8'));
|
| 19 |
+
// Safety check: ensure the file matches the database project
|
| 20 |
+
if (serviceAccount.project_id !== "transcript-master") {
|
| 21 |
+
console.error(`❌ CRITICAL: service-account.json is for project "${serviceAccount.project_id}", but database is "transcript-master". Please download the correct key.`);
|
| 22 |
+
}
|
| 23 |
+
config.credential = admin.credential.cert(serviceAccount);
|
| 24 |
+
console.log("✅ Firebase Admin: Using service-account.json for transcript-master");
|
| 25 |
+
} else {
|
| 26 |
+
console.warn("ℹ️ Firebase Admin: service-account.json NOT FOUND. DB access will fail.");
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
admin.initializeApp(config);
|
| 30 |
+
console.log("🚀 Firebase Admin Service Initialized for transcript-master");
|
| 31 |
+
}
|
| 32 |
+
} catch (error) {
|
| 33 |
+
console.error("❌ Firebase Init Error:", error.message);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export const db = admin.database();
|