Spaces:
Running
Running
Aniket Mishra commited on
Commit Β·
dbaf05a
1
Parent(s): 1ef7ac1
feat: 5x backend speed & reliability upgrade (SQLite WAL storage, concurrent uploads, stream tuning, in-memory cache, auto-retry, quota auto-scheduling & rotation)
Browse files- .env.example +8 -1
- .gitignore +3 -0
- Dockerfile +16 -11
- db.js +606 -0
- package-lock.json +22 -0
- package.json +1 -0
- server.js +471 -410
.env.example
CHANGED
|
@@ -1,4 +1,11 @@
|
|
| 1 |
GOOGLE_CLIENT_ID=
|
| 2 |
GOOGLE_CLIENT_SECRET=
|
| 3 |
GOOGLE_REDIRECT_URI=
|
| 4 |
-
GOOGLE_REFRESH_TOKEN=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
GOOGLE_CLIENT_ID=
|
| 2 |
GOOGLE_CLIENT_SECRET=
|
| 3 |
GOOGLE_REDIRECT_URI=
|
| 4 |
+
GOOGLE_REFRESH_TOKEN=
|
| 5 |
+
|
| 6 |
+
# Optional: Additional credential sets for multi-key quota rotation
|
| 7 |
+
# Format: comma-separated triplets of clientId:clientSecret:refreshToken
|
| 8 |
+
# GOOGLE_EXTRA_CREDENTIALS=clientId1:secret1:token1,clientId2:secret2:token2
|
| 9 |
+
|
| 10 |
+
# Upload concurrency β number of simultaneous DriveβYouTube streams (default: 3)
|
| 11 |
+
# UPLOAD_CONCURRENCY=3
|
.gitignore
CHANGED
|
@@ -4,3 +4,6 @@ node_modules/
|
|
| 4 |
*.log
|
| 5 |
.env
|
| 6 |
firebase-applet-config.json
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
*.log
|
| 5 |
.env
|
| 6 |
firebase-applet-config.json
|
| 7 |
+
data/*.db*
|
| 8 |
+
data/*.migrated
|
| 9 |
+
data/*.json
|
Dockerfile
CHANGED
|
@@ -1,27 +1,32 @@
|
|
| 1 |
-
#
|
| 2 |
-
FROM node:20-alpine
|
| 3 |
|
| 4 |
-
# Install
|
| 5 |
-
RUN apk add --no-cache
|
| 6 |
|
| 7 |
-
# Set working directory
|
| 8 |
WORKDIR /app
|
| 9 |
|
| 10 |
-
# Copy package definition
|
| 11 |
COPY package*.json ./
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
#
|
| 14 |
-
|
| 15 |
|
| 16 |
# Copy application files
|
| 17 |
COPY server.js ./
|
|
|
|
| 18 |
COPY public ./public
|
| 19 |
-
COPY data ./data
|
| 20 |
|
| 21 |
-
# Create data directory
|
| 22 |
RUN mkdir -p data
|
| 23 |
|
| 24 |
-
# Expose
|
| 25 |
ENV PORT=3000
|
| 26 |
EXPOSE 3000
|
| 27 |
|
|
|
|
| 1 |
+
# Multi-stage build for better-sqlite3 native compilation
|
| 2 |
+
FROM node:20-alpine AS builder
|
| 3 |
|
| 4 |
+
# Install build tools for native addons (better-sqlite3)
|
| 5 |
+
RUN apk add --no-cache python3 make g++
|
| 6 |
|
|
|
|
| 7 |
WORKDIR /app
|
| 8 |
|
| 9 |
+
# Copy package definition and install
|
| 10 |
COPY package*.json ./
|
| 11 |
+
RUN npm ci --omit=dev
|
| 12 |
+
|
| 13 |
+
# --- Production stage (minimal image) ---
|
| 14 |
+
FROM node:20-alpine
|
| 15 |
+
|
| 16 |
+
WORKDIR /app
|
| 17 |
|
| 18 |
+
# Copy pre-built node_modules from builder
|
| 19 |
+
COPY --from=builder /app/node_modules ./node_modules
|
| 20 |
|
| 21 |
# Copy application files
|
| 22 |
COPY server.js ./
|
| 23 |
+
COPY db.js ./
|
| 24 |
COPY public ./public
|
|
|
|
| 25 |
|
| 26 |
+
# Create data directory
|
| 27 |
RUN mkdir -p data
|
| 28 |
|
| 29 |
+
# Expose port
|
| 30 |
ENV PORT=3000
|
| 31 |
EXPOSE 3000
|
| 32 |
|
db.js
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SQLite Data Layer β WAL-mode persistent store
|
| 3 |
+
* Replaces job_state.json and uploaded_history.json with indexed SQLite tables.
|
| 4 |
+
* All prepared statements are pre-compiled at module load for sub-millisecond queries.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
'use strict';
|
| 8 |
+
|
| 9 |
+
const path = require('path');
|
| 10 |
+
const fs = require('fs');
|
| 11 |
+
const Database = require('better-sqlite3');
|
| 12 |
+
|
| 13 |
+
const DATA_DIR = path.join(__dirname, 'data');
|
| 14 |
+
const DB_PATH = path.join(DATA_DIR, 'app.db');
|
| 15 |
+
const STATE_FILE = path.join(DATA_DIR, 'job_state.json');
|
| 16 |
+
const HISTORY_FILE = path.join(DATA_DIR, 'uploaded_history.json');
|
| 17 |
+
|
| 18 |
+
// Ensure data directory exists
|
| 19 |
+
if (!fs.existsSync(DATA_DIR)) {
|
| 20 |
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// Open database with WAL mode for concurrent read/write safety
|
| 24 |
+
const db = new Database(DB_PATH);
|
| 25 |
+
db.pragma('journal_mode = WAL');
|
| 26 |
+
db.pragma('synchronous = NORMAL');
|
| 27 |
+
db.pragma('cache_size = -64000'); // 64MB cache
|
| 28 |
+
db.pragma('busy_timeout = 5000');
|
| 29 |
+
db.pragma('foreign_keys = ON');
|
| 30 |
+
|
| 31 |
+
// βββ Schema ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
+
|
| 33 |
+
db.exec(`
|
| 34 |
+
CREATE TABLE IF NOT EXISTS uploaded_history (
|
| 35 |
+
id TEXT PRIMARY KEY,
|
| 36 |
+
videoId TEXT,
|
| 37 |
+
name TEXT,
|
| 38 |
+
originalName TEXT,
|
| 39 |
+
customTitle TEXT,
|
| 40 |
+
batch TEXT DEFAULT 'Batch',
|
| 41 |
+
subject TEXT DEFAULT 'Lecture',
|
| 42 |
+
folderPath TEXT DEFAULT '',
|
| 43 |
+
channelId TEXT,
|
| 44 |
+
size INTEGER DEFAULT 0,
|
| 45 |
+
createdTime TEXT,
|
| 46 |
+
status TEXT DEFAULT 'completed',
|
| 47 |
+
percentage INTEGER DEFAULT 100,
|
| 48 |
+
uploadedBytes INTEGER DEFAULT 0,
|
| 49 |
+
totalBytes INTEGER DEFAULT 0,
|
| 50 |
+
speedMBps REAL DEFAULT 0,
|
| 51 |
+
etaSeconds INTEGER DEFAULT 0,
|
| 52 |
+
youtubeUrl TEXT DEFAULT '',
|
| 53 |
+
thumbnailUrl TEXT DEFAULT '',
|
| 54 |
+
studioUrl TEXT DEFAULT '',
|
| 55 |
+
error TEXT
|
| 56 |
+
);
|
| 57 |
+
|
| 58 |
+
CREATE INDEX IF NOT EXISTS idx_history_videoId ON uploaded_history(videoId);
|
| 59 |
+
CREATE INDEX IF NOT EXISTS idx_history_channelId ON uploaded_history(channelId);
|
| 60 |
+
CREATE INDEX IF NOT EXISTS idx_history_createdTime ON uploaded_history(createdTime);
|
| 61 |
+
CREATE INDEX IF NOT EXISTS idx_history_name ON uploaded_history(name COLLATE NOCASE);
|
| 62 |
+
CREATE INDEX IF NOT EXISTS idx_history_customTitle ON uploaded_history(customTitle COLLATE NOCASE);
|
| 63 |
+
|
| 64 |
+
CREATE TABLE IF NOT EXISTS job_state (
|
| 65 |
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
| 66 |
+
data TEXT NOT NULL DEFAULT '{}'
|
| 67 |
+
);
|
| 68 |
+
|
| 69 |
+
CREATE TABLE IF NOT EXISTS settings (
|
| 70 |
+
key TEXT PRIMARY KEY,
|
| 71 |
+
value TEXT
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
CREATE TABLE IF NOT EXISTS credentials (
|
| 75 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 76 |
+
clientId TEXT NOT NULL,
|
| 77 |
+
clientSecret TEXT NOT NULL,
|
| 78 |
+
refreshToken TEXT NOT NULL,
|
| 79 |
+
label TEXT DEFAULT 'Default',
|
| 80 |
+
isActive INTEGER DEFAULT 1,
|
| 81 |
+
quotaUsedToday INTEGER DEFAULT 0,
|
| 82 |
+
lastResetAt TEXT
|
| 83 |
+
);
|
| 84 |
+
`);
|
| 85 |
+
|
| 86 |
+
// βββ Prepared Statements βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 87 |
+
|
| 88 |
+
const stmts = {
|
| 89 |
+
// History
|
| 90 |
+
insertHistory: db.prepare(`
|
| 91 |
+
INSERT OR REPLACE INTO uploaded_history
|
| 92 |
+
(id, videoId, name, originalName, customTitle, batch, subject, folderPath,
|
| 93 |
+
channelId, size, createdTime, status, percentage, uploadedBytes, totalBytes,
|
| 94 |
+
speedMBps, etaSeconds, youtubeUrl, thumbnailUrl, studioUrl, error)
|
| 95 |
+
VALUES
|
| 96 |
+
(@id, @videoId, @name, @originalName, @customTitle, @batch, @subject, @folderPath,
|
| 97 |
+
@channelId, @size, @createdTime, @status, @percentage, @uploadedBytes, @totalBytes,
|
| 98 |
+
@speedMBps, @etaSeconds, @youtubeUrl, @thumbnailUrl, @studioUrl, @error)
|
| 99 |
+
`),
|
| 100 |
+
|
| 101 |
+
selectAllHistory: db.prepare(`SELECT * FROM uploaded_history ORDER BY rowid DESC`),
|
| 102 |
+
|
| 103 |
+
selectHistoryByChannel: db.prepare(`SELECT * FROM uploaded_history WHERE channelId = ? ORDER BY rowid DESC`),
|
| 104 |
+
|
| 105 |
+
selectHistoryById: db.prepare(`SELECT * FROM uploaded_history WHERE id = ?`),
|
| 106 |
+
|
| 107 |
+
selectHistoryByVideoId: db.prepare(`SELECT * FROM uploaded_history WHERE videoId = ?`),
|
| 108 |
+
|
| 109 |
+
deleteAllHistory: db.prepare(`DELETE FROM uploaded_history`),
|
| 110 |
+
|
| 111 |
+
deleteHistoryById: db.prepare(`DELETE FROM uploaded_history WHERE id = ?`),
|
| 112 |
+
|
| 113 |
+
checkDuplicate: db.prepare(`
|
| 114 |
+
SELECT id, videoId, youtubeUrl, customTitle, name FROM uploaded_history
|
| 115 |
+
WHERE id = ? OR customTitle = ? COLLATE NOCASE OR name = ? COLLATE NOCASE
|
| 116 |
+
LIMIT 1
|
| 117 |
+
`),
|
| 118 |
+
|
| 119 |
+
countHistoryInCycle: db.prepare(`
|
| 120 |
+
SELECT COUNT(*) as cnt FROM uploaded_history
|
| 121 |
+
WHERE createdTime >= ? AND channelId = ?
|
| 122 |
+
`),
|
| 123 |
+
|
| 124 |
+
countAllHistoryInCycle: db.prepare(`
|
| 125 |
+
SELECT COUNT(*) as cnt FROM uploaded_history
|
| 126 |
+
WHERE createdTime >= ?
|
| 127 |
+
`),
|
| 128 |
+
|
| 129 |
+
// Job state
|
| 130 |
+
upsertJobState: db.prepare(`INSERT OR REPLACE INTO job_state (id, data) VALUES (1, ?)`),
|
| 131 |
+
selectJobState: db.prepare(`SELECT data FROM job_state WHERE id = 1`),
|
| 132 |
+
|
| 133 |
+
// Settings
|
| 134 |
+
getSetting: db.prepare(`SELECT value FROM settings WHERE key = ?`),
|
| 135 |
+
setSetting: db.prepare(`INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`),
|
| 136 |
+
getAllSettings: db.prepare(`SELECT key, value FROM settings`),
|
| 137 |
+
deleteSetting: db.prepare(`DELETE FROM settings WHERE key = ?`),
|
| 138 |
+
|
| 139 |
+
// Credentials
|
| 140 |
+
insertCredential: db.prepare(`
|
| 141 |
+
INSERT INTO credentials (clientId, clientSecret, refreshToken, label, isActive)
|
| 142 |
+
VALUES (?, ?, ?, ?, 1)
|
| 143 |
+
`),
|
| 144 |
+
selectActiveCredentials: db.prepare(`
|
| 145 |
+
SELECT * FROM credentials WHERE isActive = 1 ORDER BY quotaUsedToday ASC
|
| 146 |
+
`),
|
| 147 |
+
selectAllCredentials: db.prepare(`SELECT id, label, isActive, quotaUsedToday, lastResetAt FROM credentials`),
|
| 148 |
+
updateCredentialQuota: db.prepare(`UPDATE credentials SET quotaUsedToday = ? WHERE id = ?`),
|
| 149 |
+
resetAllCredentialQuotas: db.prepare(`UPDATE credentials SET quotaUsedToday = 0, lastResetAt = ?`),
|
| 150 |
+
deleteCredential: db.prepare(`DELETE FROM credentials WHERE id = ?`),
|
| 151 |
+
selectCredentialById: db.prepare(`SELECT * FROM credentials WHERE id = ?`)
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
// βββ Transactions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 155 |
+
|
| 156 |
+
const bulkInsertHistory = db.transaction((records) => {
|
| 157 |
+
stmts.deleteAllHistory.run();
|
| 158 |
+
for (const rec of records) {
|
| 159 |
+
stmts.insertHistory.run(normalizeHistoryRecord(rec));
|
| 160 |
+
}
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
// βββ Helper Functions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 164 |
+
|
| 165 |
+
function normalizeHistoryRecord(rec) {
|
| 166 |
+
return {
|
| 167 |
+
id: rec.id || '',
|
| 168 |
+
videoId: rec.videoId || rec.id || '',
|
| 169 |
+
name: rec.customTitle || rec.name || rec.originalName || '',
|
| 170 |
+
originalName: rec.originalName || rec.name || '',
|
| 171 |
+
customTitle: rec.customTitle || rec.name || '',
|
| 172 |
+
batch: rec.batch || 'Batch',
|
| 173 |
+
subject: rec.subject || 'Lecture',
|
| 174 |
+
folderPath: rec.folderPath || '',
|
| 175 |
+
channelId: rec.channelId || null,
|
| 176 |
+
size: parseInt(rec.size || rec.totalBytes || '0', 10),
|
| 177 |
+
createdTime: rec.createdTime || new Date().toISOString(),
|
| 178 |
+
status: rec.status || 'completed',
|
| 179 |
+
percentage: rec.percentage != null ? rec.percentage : 100,
|
| 180 |
+
uploadedBytes: parseInt(rec.uploadedBytes || rec.totalBytes || rec.size || '0', 10),
|
| 181 |
+
totalBytes: parseInt(rec.totalBytes || rec.size || '0', 10),
|
| 182 |
+
speedMBps: parseFloat(rec.speedMBps || '0'),
|
| 183 |
+
etaSeconds: parseInt(rec.etaSeconds || '0', 10),
|
| 184 |
+
youtubeUrl: rec.youtubeUrl || (rec.videoId ? `https://youtu.be/${rec.videoId}` : ''),
|
| 185 |
+
thumbnailUrl: rec.thumbnailUrl || (rec.videoId ? `https://img.youtube.com/vi/${rec.videoId}/mqdefault.jpg` : ''),
|
| 186 |
+
studioUrl: rec.studioUrl || (rec.videoId ? `https://studio.youtube.com/video/${rec.videoId}/edit` : ''),
|
| 187 |
+
error: rec.error || null
|
| 188 |
+
};
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
// βββ Exported API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 192 |
+
|
| 193 |
+
/**
|
| 194 |
+
* Load full upload history β returns Array<Object> identical to the old JSON array.
|
| 195 |
+
*/
|
| 196 |
+
function loadUploadedHistory() {
|
| 197 |
+
try {
|
| 198 |
+
return stmts.selectAllHistory.all();
|
| 199 |
+
} catch (err) {
|
| 200 |
+
console.error('db.loadUploadedHistory error:', err);
|
| 201 |
+
return [];
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
/**
|
| 206 |
+
* Persist entire history array (used only for bulk clear/replace).
|
| 207 |
+
* Wraps in a transaction: DELETE ALL β INSERT each record.
|
| 208 |
+
*/
|
| 209 |
+
function persistUploadedHistory(historyArray) {
|
| 210 |
+
try {
|
| 211 |
+
if (!Array.isArray(historyArray) || historyArray.length === 0) {
|
| 212 |
+
stmts.deleteAllHistory.run();
|
| 213 |
+
return;
|
| 214 |
+
}
|
| 215 |
+
bulkInsertHistory(historyArray);
|
| 216 |
+
} catch (err) {
|
| 217 |
+
console.error('db.persistUploadedHistory error:', err);
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
/**
|
| 222 |
+
* Save or update a single completed file in history.
|
| 223 |
+
* Uses INSERT OR REPLACE with normalized record β ~0.1ms vs ~15ms for JSON cycle.
|
| 224 |
+
*/
|
| 225 |
+
function saveCompletedFileToHistory(fileObj) {
|
| 226 |
+
if (!fileObj || !fileObj.id) return;
|
| 227 |
+
try {
|
| 228 |
+
stmts.insertHistory.run(normalizeHistoryRecord(fileObj));
|
| 229 |
+
} catch (err) {
|
| 230 |
+
console.error('db.saveCompletedFileToHistory error:', err);
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
/**
|
| 235 |
+
* Remove a single record from history by Drive file ID.
|
| 236 |
+
*/
|
| 237 |
+
function deleteHistoryById(id) {
|
| 238 |
+
try {
|
| 239 |
+
stmts.deleteHistoryById.run(id);
|
| 240 |
+
} catch (err) {
|
| 241 |
+
console.error('db.deleteHistoryById error:', err);
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
/**
|
| 246 |
+
* Check if a file is a duplicate by driveFileId, customTitle, or fileName.
|
| 247 |
+
* Returns { isDuplicate: boolean, existing: Object|null }
|
| 248 |
+
* Uses indexed queries β O(log n) vs O(n) full-array scan.
|
| 249 |
+
*/
|
| 250 |
+
function isDuplicate(driveFileId, customTitle, fileName) {
|
| 251 |
+
try {
|
| 252 |
+
const row = stmts.checkDuplicate.get(
|
| 253 |
+
driveFileId || '',
|
| 254 |
+
(customTitle || '').trim(),
|
| 255 |
+
(fileName || '').trim()
|
| 256 |
+
);
|
| 257 |
+
return { isDuplicate: !!row, existing: row || null };
|
| 258 |
+
} catch (err) {
|
| 259 |
+
console.error('db.isDuplicate error:', err);
|
| 260 |
+
return { isDuplicate: false, existing: null };
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
/**
|
| 265 |
+
* Find a history record by Drive file ID.
|
| 266 |
+
*/
|
| 267 |
+
function findHistoryByDriveId(id) {
|
| 268 |
+
try {
|
| 269 |
+
return stmts.selectHistoryById.get(id) || null;
|
| 270 |
+
} catch (err) {
|
| 271 |
+
return null;
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
/**
|
| 276 |
+
* Find a history record by YouTube video ID.
|
| 277 |
+
*/
|
| 278 |
+
function findHistoryByVideoId(videoId) {
|
| 279 |
+
try {
|
| 280 |
+
return stmts.selectHistoryByVideoId.get(videoId) || null;
|
| 281 |
+
} catch (err) {
|
| 282 |
+
return null;
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
/**
|
| 287 |
+
* Get history filtered by channel ID β indexed WHERE channelId = ?.
|
| 288 |
+
*/
|
| 289 |
+
function getHistoryByChannel(channelId) {
|
| 290 |
+
if (!channelId) return [];
|
| 291 |
+
try {
|
| 292 |
+
return stmts.selectHistoryByChannel.all(channelId);
|
| 293 |
+
} catch (err) {
|
| 294 |
+
return [];
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
/**
|
| 299 |
+
* Count uploads since a given ISO timestamp, optionally filtered by channel.
|
| 300 |
+
* Used by /api/quota-health for O(log n) cycle counting.
|
| 301 |
+
*/
|
| 302 |
+
function getUploadsInCycle(sinceIso, channelId) {
|
| 303 |
+
try {
|
| 304 |
+
if (channelId) {
|
| 305 |
+
const row = stmts.countHistoryInCycle.get(sinceIso, channelId);
|
| 306 |
+
return row ? row.cnt : 0;
|
| 307 |
+
} else {
|
| 308 |
+
const row = stmts.countAllHistoryInCycle.get(sinceIso);
|
| 309 |
+
return row ? row.cnt : 0;
|
| 310 |
+
}
|
| 311 |
+
} catch (err) {
|
| 312 |
+
return 0;
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
/**
|
| 317 |
+
* Load job state from SQLite β returns the parsed JSON object.
|
| 318 |
+
* If no state exists, returns null (caller provides default).
|
| 319 |
+
*/
|
| 320 |
+
function loadJobStateFromDB() {
|
| 321 |
+
try {
|
| 322 |
+
const row = stmts.selectJobState.get();
|
| 323 |
+
if (row && row.data) {
|
| 324 |
+
return JSON.parse(row.data);
|
| 325 |
+
}
|
| 326 |
+
} catch (err) {
|
| 327 |
+
console.error('db.loadJobStateFromDB error:', err);
|
| 328 |
+
}
|
| 329 |
+
return null;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
/**
|
| 333 |
+
* Persist job state to SQLite β debounce-friendly, accepts full state object.
|
| 334 |
+
*/
|
| 335 |
+
let _saveStateTimeout = null;
|
| 336 |
+
function persistJobStateToDB(state) {
|
| 337 |
+
try {
|
| 338 |
+
if (_saveStateTimeout) clearTimeout(_saveStateTimeout);
|
| 339 |
+
_saveStateTimeout = setTimeout(() => {
|
| 340 |
+
try {
|
| 341 |
+
// Strip `files` from stored state to avoid bloat β files are in uploaded_history table
|
| 342 |
+
// We store only the job metadata; files are merged back on load
|
| 343 |
+
const stateToStore = { ...state };
|
| 344 |
+
// Keep files in the stored state for backward compatibility and active queue tracking
|
| 345 |
+
stmts.upsertJobState.run(JSON.stringify(stateToStore));
|
| 346 |
+
} catch (err) {
|
| 347 |
+
console.error('db.persistJobStateToDB write error:', err);
|
| 348 |
+
}
|
| 349 |
+
}, 200);
|
| 350 |
+
} catch (err) {
|
| 351 |
+
console.error('db.persistJobStateToDB error:', err);
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
/**
|
| 356 |
+
* Force-flush job state immediately (no debounce). Used before process exit.
|
| 357 |
+
*/
|
| 358 |
+
function flushJobState(state) {
|
| 359 |
+
try {
|
| 360 |
+
if (_saveStateTimeout) clearTimeout(_saveStateTimeout);
|
| 361 |
+
stmts.upsertJobState.run(JSON.stringify(state));
|
| 362 |
+
} catch (err) {
|
| 363 |
+
console.error('db.flushJobState error:', err);
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
// βββ Settings ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 368 |
+
|
| 369 |
+
function getSetting(key) {
|
| 370 |
+
try {
|
| 371 |
+
const row = stmts.getSetting.get(key);
|
| 372 |
+
return row ? row.value : null;
|
| 373 |
+
} catch (err) {
|
| 374 |
+
return null;
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
function setSetting(key, value) {
|
| 379 |
+
try {
|
| 380 |
+
stmts.setSetting.run(key, String(value));
|
| 381 |
+
} catch (err) {
|
| 382 |
+
console.error('db.setSetting error:', err);
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
function getAllSettings() {
|
| 387 |
+
try {
|
| 388 |
+
const rows = stmts.getAllSettings.all();
|
| 389 |
+
const result = {};
|
| 390 |
+
for (const row of rows) {
|
| 391 |
+
result[row.key] = row.value;
|
| 392 |
+
}
|
| 393 |
+
return result;
|
| 394 |
+
} catch (err) {
|
| 395 |
+
return {};
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
function deleteSetting(key) {
|
| 400 |
+
try {
|
| 401 |
+
stmts.deleteSetting.run(key);
|
| 402 |
+
} catch (err) {
|
| 403 |
+
console.error('db.deleteSetting error:', err);
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
// βββ Credentials βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 408 |
+
|
| 409 |
+
function addCredential(clientId, clientSecret, refreshToken, label) {
|
| 410 |
+
try {
|
| 411 |
+
stmts.insertCredential.run(clientId, clientSecret, refreshToken, label || 'Default');
|
| 412 |
+
} catch (err) {
|
| 413 |
+
console.error('db.addCredential error:', err);
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
function getActiveCredentials() {
|
| 418 |
+
try {
|
| 419 |
+
return stmts.selectActiveCredentials.all();
|
| 420 |
+
} catch (err) {
|
| 421 |
+
return [];
|
| 422 |
+
}
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
function getAllCredentials() {
|
| 426 |
+
try {
|
| 427 |
+
return stmts.selectAllCredentials.all();
|
| 428 |
+
} catch (err) {
|
| 429 |
+
return [];
|
| 430 |
+
}
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
function incrementCredentialQuota(credentialId) {
|
| 434 |
+
try {
|
| 435 |
+
const cred = stmts.selectCredentialById.get(credentialId);
|
| 436 |
+
if (cred) {
|
| 437 |
+
stmts.updateCredentialQuota.run((cred.quotaUsedToday || 0) + 1, credentialId);
|
| 438 |
+
}
|
| 439 |
+
} catch (err) {
|
| 440 |
+
console.error('db.incrementCredentialQuota error:', err);
|
| 441 |
+
}
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
function resetAllCredentialQuotas() {
|
| 445 |
+
try {
|
| 446 |
+
stmts.resetAllCredentialQuotas.run(new Date().toISOString());
|
| 447 |
+
} catch (err) {
|
| 448 |
+
console.error('db.resetAllCredentialQuotas error:', err);
|
| 449 |
+
}
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
function removeCredential(id) {
|
| 453 |
+
try {
|
| 454 |
+
stmts.deleteCredential.run(id);
|
| 455 |
+
} catch (err) {
|
| 456 |
+
console.error('db.removeCredential error:', err);
|
| 457 |
+
}
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
function getCredentialById(id) {
|
| 461 |
+
try {
|
| 462 |
+
return stmts.selectCredentialById.get(id) || null;
|
| 463 |
+
} catch (err) {
|
| 464 |
+
return null;
|
| 465 |
+
}
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
// βββ Migration βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 469 |
+
|
| 470 |
+
/**
|
| 471 |
+
* One-time migration from JSON files to SQLite.
|
| 472 |
+
* Runs automatically on first startup if SQLite tables are empty.
|
| 473 |
+
* Preserves JSON files as .migrated backups.
|
| 474 |
+
*/
|
| 475 |
+
function migrateFromJSON() {
|
| 476 |
+
const historyCount = db.prepare('SELECT COUNT(*) as cnt FROM uploaded_history').get().cnt;
|
| 477 |
+
const stateRow = stmts.selectJobState.get();
|
| 478 |
+
|
| 479 |
+
let migratedHistory = false;
|
| 480 |
+
let migratedState = false;
|
| 481 |
+
|
| 482 |
+
// Migrate uploaded_history.json
|
| 483 |
+
if (historyCount === 0 && fs.existsSync(HISTORY_FILE)) {
|
| 484 |
+
try {
|
| 485 |
+
const raw = fs.readFileSync(HISTORY_FILE, 'utf8');
|
| 486 |
+
const parsed = JSON.parse(raw);
|
| 487 |
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
| 488 |
+
console.log(`[db] Migrating ${parsed.length} records from uploaded_history.json β SQLite...`);
|
| 489 |
+
bulkInsertHistory(parsed);
|
| 490 |
+
fs.renameSync(HISTORY_FILE, HISTORY_FILE + '.migrated');
|
| 491 |
+
console.log(`[db] History migration complete. Backup: ${HISTORY_FILE}.migrated`);
|
| 492 |
+
migratedHistory = true;
|
| 493 |
+
}
|
| 494 |
+
} catch (err) {
|
| 495 |
+
console.error('[db] History migration error:', err);
|
| 496 |
+
}
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
// Migrate job_state.json
|
| 500 |
+
if (!stateRow && fs.existsSync(STATE_FILE)) {
|
| 501 |
+
try {
|
| 502 |
+
const raw = fs.readFileSync(STATE_FILE, 'utf8');
|
| 503 |
+
const parsed = JSON.parse(raw);
|
| 504 |
+
stmts.upsertJobState.run(JSON.stringify(parsed));
|
| 505 |
+
fs.renameSync(STATE_FILE, STATE_FILE + '.migrated');
|
| 506 |
+
console.log(`[db] Job state migration complete. Backup: ${STATE_FILE}.migrated`);
|
| 507 |
+
migratedState = true;
|
| 508 |
+
} catch (err) {
|
| 509 |
+
console.error('[db] State migration error:', err);
|
| 510 |
+
}
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
// Seed .env credentials into credentials table
|
| 514 |
+
const existingCreds = stmts.selectActiveCredentials.all();
|
| 515 |
+
if (existingCreds.length === 0) {
|
| 516 |
+
const envClientId = process.env.GOOGLE_CLIENT_ID;
|
| 517 |
+
const envClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
| 518 |
+
const envRefreshToken = process.env.GOOGLE_REFRESH_TOKEN;
|
| 519 |
+
|
| 520 |
+
if (envClientId && envClientSecret && envRefreshToken) {
|
| 521 |
+
addCredential(envClientId.trim(), envClientSecret.trim(), envRefreshToken.trim(), 'Primary (.env)');
|
| 522 |
+
console.log('[db] Seeded primary credentials from .env into credentials table.');
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
// Parse GOOGLE_EXTRA_CREDENTIALS if present
|
| 526 |
+
const extraCreds = process.env.GOOGLE_EXTRA_CREDENTIALS;
|
| 527 |
+
if (extraCreds) {
|
| 528 |
+
const sets = extraCreds.split(',').map(s => s.trim()).filter(Boolean);
|
| 529 |
+
for (let i = 0; i < sets.length; i++) {
|
| 530 |
+
const parts = sets[i].split(':');
|
| 531 |
+
if (parts.length >= 3) {
|
| 532 |
+
addCredential(parts[0].trim(), parts[1].trim(), parts[2].trim(), `Extra Key ${i + 1}`);
|
| 533 |
+
console.log(`[db] Seeded extra credential set ${i + 1} from GOOGLE_EXTRA_CREDENTIALS.`);
|
| 534 |
+
}
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
// Seed default settings
|
| 540 |
+
if (!getSetting('upload_concurrency')) {
|
| 541 |
+
setSetting('upload_concurrency', process.env.UPLOAD_CONCURRENCY || '3');
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
if (migratedHistory || migratedState) {
|
| 545 |
+
console.log('[db] JSON β SQLite migration finished successfully.');
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
// Run migration on module load
|
| 550 |
+
migrateFromJSON();
|
| 551 |
+
|
| 552 |
+
// βββ Graceful Shutdown βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 553 |
+
|
| 554 |
+
function closeDatabase() {
|
| 555 |
+
try {
|
| 556 |
+
if (_saveStateTimeout) clearTimeout(_saveStateTimeout);
|
| 557 |
+
db.close();
|
| 558 |
+
console.log('[db] Database connection closed.');
|
| 559 |
+
} catch (err) {
|
| 560 |
+
// Ignore close errors during shutdown
|
| 561 |
+
}
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
process.on('SIGINT', () => { closeDatabase(); process.exit(0); });
|
| 565 |
+
process.on('SIGTERM', () => { closeDatabase(); process.exit(0); });
|
| 566 |
+
|
| 567 |
+
// βββ Module Exports ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 568 |
+
|
| 569 |
+
module.exports = {
|
| 570 |
+
// History
|
| 571 |
+
loadUploadedHistory,
|
| 572 |
+
persistUploadedHistory,
|
| 573 |
+
saveCompletedFileToHistory,
|
| 574 |
+
deleteHistoryById,
|
| 575 |
+
isDuplicate,
|
| 576 |
+
findHistoryByDriveId,
|
| 577 |
+
findHistoryByVideoId,
|
| 578 |
+
getHistoryByChannel,
|
| 579 |
+
getUploadsInCycle,
|
| 580 |
+
|
| 581 |
+
// Job State
|
| 582 |
+
loadJobStateFromDB,
|
| 583 |
+
persistJobStateToDB,
|
| 584 |
+
flushJobState,
|
| 585 |
+
|
| 586 |
+
// Settings
|
| 587 |
+
getSetting,
|
| 588 |
+
setSetting,
|
| 589 |
+
getAllSettings,
|
| 590 |
+
deleteSetting,
|
| 591 |
+
|
| 592 |
+
// Credentials
|
| 593 |
+
addCredential,
|
| 594 |
+
getActiveCredentials,
|
| 595 |
+
getAllCredentials,
|
| 596 |
+
getCredentialById,
|
| 597 |
+
incrementCredentialQuota,
|
| 598 |
+
resetAllCredentialQuotas,
|
| 599 |
+
removeCredential,
|
| 600 |
+
|
| 601 |
+
// Lifecycle
|
| 602 |
+
closeDatabase,
|
| 603 |
+
|
| 604 |
+
// Direct DB access (for advanced queries)
|
| 605 |
+
raw: db
|
| 606 |
+
};
|
package-lock.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
| 9 |
"version": "1.0.0",
|
| 10 |
"license": "ISC",
|
| 11 |
"dependencies": {
|
|
|
|
| 12 |
"cors": "^2.8.5",
|
| 13 |
"dotenv": "^16.4.7",
|
| 14 |
"express": "^4.21.2",
|
|
@@ -134,6 +135,18 @@
|
|
| 134 |
],
|
| 135 |
"license": "MIT"
|
| 136 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
"node_modules/bignumber.js": {
|
| 138 |
"version": "9.3.1",
|
| 139 |
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
|
@@ -967,6 +980,15 @@
|
|
| 967 |
"node": ">= 0.6"
|
| 968 |
}
|
| 969 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 970 |
"node_modules/node-fetch": {
|
| 971 |
"version": "2.7.0",
|
| 972 |
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
|
|
|
| 9 |
"version": "1.0.0",
|
| 10 |
"license": "ISC",
|
| 11 |
"dependencies": {
|
| 12 |
+
"better-sqlite3": "^13.0.3",
|
| 13 |
"cors": "^2.8.5",
|
| 14 |
"dotenv": "^16.4.7",
|
| 15 |
"express": "^4.21.2",
|
|
|
|
| 135 |
],
|
| 136 |
"license": "MIT"
|
| 137 |
},
|
| 138 |
+
"node_modules/better-sqlite3": {
|
| 139 |
+
"version": "13.0.3",
|
| 140 |
+
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
| 141 |
+
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
| 142 |
+
"license": "MIT",
|
| 143 |
+
"dependencies": {
|
| 144 |
+
"node-addon-api": "^8.0.0"
|
| 145 |
+
},
|
| 146 |
+
"engines": {
|
| 147 |
+
"node": ">=22"
|
| 148 |
+
}
|
| 149 |
+
},
|
| 150 |
"node_modules/bignumber.js": {
|
| 151 |
"version": "9.3.1",
|
| 152 |
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
|
|
|
| 980 |
"node": ">= 0.6"
|
| 981 |
}
|
| 982 |
},
|
| 983 |
+
"node_modules/node-addon-api": {
|
| 984 |
+
"version": "8.9.2",
|
| 985 |
+
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
|
| 986 |
+
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
|
| 987 |
+
"license": "MIT",
|
| 988 |
+
"engines": {
|
| 989 |
+
"node": "^18 || ^20 || >= 21"
|
| 990 |
+
}
|
| 991 |
+
},
|
| 992 |
"node_modules/node-fetch": {
|
| 993 |
"version": "2.7.0",
|
| 994 |
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
package.json
CHANGED
|
@@ -21,6 +21,7 @@
|
|
| 21 |
"author": "",
|
| 22 |
"license": "ISC",
|
| 23 |
"dependencies": {
|
|
|
|
| 24 |
"cors": "^2.8.5",
|
| 25 |
"dotenv": "^16.4.7",
|
| 26 |
"express": "^4.21.2",
|
|
|
|
| 21 |
"author": "",
|
| 22 |
"license": "ISC",
|
| 23 |
"dependencies": {
|
| 24 |
+
"better-sqlite3": "^13.0.3",
|
| 25 |
"cors": "^2.8.5",
|
| 26 |
"dotenv": "^16.4.7",
|
| 27 |
"express": "^4.21.2",
|
server.js
CHANGED
|
@@ -10,75 +10,18 @@ const path = require('path');
|
|
| 10 |
const fs = require('fs');
|
| 11 |
const { google } = require('googleapis');
|
| 12 |
const { Transform } = require('stream');
|
|
|
|
| 13 |
const helmet = require('helmet');
|
| 14 |
const rateLimit = require('express-rate-limit');
|
| 15 |
|
| 16 |
const app = express();
|
| 17 |
const PORT = process.env.PORT || 3000;
|
| 18 |
const DATA_DIR = path.join(__dirname, 'data');
|
| 19 |
-
const STATE_FILE = path.join(DATA_DIR, 'job_state.json');
|
| 20 |
-
const HISTORY_FILE = path.join(DATA_DIR, 'uploaded_history.json');
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
function loadUploadedHistory() {
|
| 27 |
-
try {
|
| 28 |
-
if (fs.existsSync(HISTORY_FILE)) {
|
| 29 |
-
const data = fs.readFileSync(HISTORY_FILE, 'utf8');
|
| 30 |
-
const parsed = JSON.parse(data);
|
| 31 |
-
if (Array.isArray(parsed)) return parsed;
|
| 32 |
-
}
|
| 33 |
-
} catch (err) {
|
| 34 |
-
console.error('Error reading upload history:', err);
|
| 35 |
-
}
|
| 36 |
-
return [];
|
| 37 |
-
}
|
| 38 |
|
| 39 |
-
function persistUploadedHistory(history) {
|
| 40 |
-
try {
|
| 41 |
-
fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2), 'utf8');
|
| 42 |
-
} catch (err) {
|
| 43 |
-
console.error('Error saving upload history:', err);
|
| 44 |
-
}
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
function saveCompletedFileToHistory(fileObj) {
|
| 48 |
-
if (!fileObj || !fileObj.id) return;
|
| 49 |
-
const history = loadUploadedHistory();
|
| 50 |
-
const existingIdx = history.findIndex(h => (h.videoId && h.videoId === fileObj.videoId) || h.id === fileObj.id);
|
| 51 |
-
const record = {
|
| 52 |
-
id: fileObj.id,
|
| 53 |
-
videoId: fileObj.videoId || fileObj.id,
|
| 54 |
-
name: fileObj.customTitle || fileObj.name || fileObj.originalName,
|
| 55 |
-
originalName: fileObj.originalName || fileObj.name,
|
| 56 |
-
customTitle: fileObj.customTitle || fileObj.name,
|
| 57 |
-
batch: fileObj.batch || 'Batch',
|
| 58 |
-
subject: fileObj.subject || 'Lecture',
|
| 59 |
-
folderPath: fileObj.folderPath || '',
|
| 60 |
-
channelId: fileObj.channelId || activeJobChannelId || null,
|
| 61 |
-
size: fileObj.size || fileObj.totalBytes || 0,
|
| 62 |
-
createdTime: fileObj.createdTime || new Date().toISOString(),
|
| 63 |
-
status: 'completed',
|
| 64 |
-
percentage: 100,
|
| 65 |
-
uploadedBytes: fileObj.totalBytes || fileObj.size || 0,
|
| 66 |
-
totalBytes: fileObj.totalBytes || fileObj.size || 0,
|
| 67 |
-
speedMBps: fileObj.speedMBps || 0,
|
| 68 |
-
etaSeconds: 0,
|
| 69 |
-
youtubeUrl: fileObj.youtubeUrl || (fileObj.videoId ? `https://youtu.be/${fileObj.videoId}` : ''),
|
| 70 |
-
thumbnailUrl: fileObj.thumbnailUrl || (fileObj.videoId ? `https://img.youtube.com/vi/${fileObj.videoId}/mqdefault.jpg` : ''),
|
| 71 |
-
studioUrl: fileObj.studioUrl || (fileObj.videoId ? `https://studio.youtube.com/video/${fileObj.videoId}/edit` : ''),
|
| 72 |
-
error: null
|
| 73 |
-
};
|
| 74 |
-
|
| 75 |
-
if (existingIdx >= 0) {
|
| 76 |
-
history[existingIdx] = { ...history[existingIdx], ...record };
|
| 77 |
-
} else {
|
| 78 |
-
history.unshift(record);
|
| 79 |
-
}
|
| 80 |
-
persistUploadedHistory(history);
|
| 81 |
-
}
|
| 82 |
|
| 83 |
// Middleware
|
| 84 |
app.use(helmet({
|
|
@@ -142,6 +85,24 @@ function filterHistoryByChannel(history, channelId) {
|
|
| 142 |
return history.filter(h => h.channelId === channelId);
|
| 143 |
}
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
// Scopes narrowed to minimum required for upload, playlist, title/thumbnail management
|
| 146 |
const SCOPES = [
|
| 147 |
'https://www.googleapis.com/auth/drive.readonly',
|
|
@@ -173,9 +134,8 @@ function getDefaultJobState() {
|
|
| 173 |
function loadJobState() {
|
| 174 |
const history = loadUploadedHistory();
|
| 175 |
try {
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
const parsed = JSON.parse(data);
|
| 179 |
if (parsed.status === 'processing' || parsed.status === 'scanning') {
|
| 180 |
parsed.status = 'error';
|
| 181 |
if (parsed.logs) {
|
|
@@ -230,14 +190,7 @@ function loadJobState() {
|
|
| 230 |
|
| 231 |
let saveStateTimeout = null;
|
| 232 |
function persistJobState() {
|
| 233 |
-
|
| 234 |
-
if (saveStateTimeout) clearTimeout(saveStateTimeout);
|
| 235 |
-
saveStateTimeout = setTimeout(() => {
|
| 236 |
-
fs.writeFileSync(STATE_FILE, JSON.stringify(jobState, null, 2), 'utf8');
|
| 237 |
-
}, 200);
|
| 238 |
-
} catch (err) {
|
| 239 |
-
console.error('Error saving job state:', err);
|
| 240 |
-
}
|
| 241 |
}
|
| 242 |
|
| 243 |
function broadcastSSE(data) {
|
|
@@ -731,9 +684,6 @@ app.get('/api/history', async (req, res) => {
|
|
| 731 |
app.get('/api/quota-health', async (req, res) => {
|
| 732 |
try {
|
| 733 |
const channelId = await resolveChannelId(req);
|
| 734 |
-
const allHistory = loadUploadedHistory();
|
| 735 |
-
const userHistory = channelId ? filterHistoryByChannel(allHistory, channelId) : allHistory;
|
| 736 |
-
|
| 737 |
// Calculate current IST time (UTC + 5:30)
|
| 738 |
const now = new Date();
|
| 739 |
const istOffsetMs = (5 * 60 + 30) * 60 * 1000;
|
|
@@ -762,11 +712,8 @@ app.get('/api/quota-health', async (req, res) => {
|
|
| 762 |
const resetsInSeconds = Math.max(0, Math.floor((nextResetUtc.getTime() - now.getTime()) / 1000));
|
| 763 |
|
| 764 |
// Count videos uploaded in current cycle
|
| 765 |
-
const uploadsInCycle =
|
| 766 |
-
|
| 767 |
-
if (!created) return false;
|
| 768 |
-
return created >= cycleStartIso;
|
| 769 |
-
}).length;
|
| 770 |
|
| 771 |
const keysCount = Math.max(1, parseInt(req.query.keysCount || '1', 10));
|
| 772 |
const limitPerKey = 100;
|
|
@@ -1003,9 +950,7 @@ app.post('/api/retry-pending', async (req, res) => {
|
|
| 1003 |
(async () => {
|
| 1004 |
activeAbortController = new AbortController();
|
| 1005 |
try {
|
| 1006 |
-
|
| 1007 |
-
const youtube = google.youtube({ version: 'v3', auth });
|
| 1008 |
-
await runUploadQueue(drive, youtube, auth, activeAbortController.signal);
|
| 1009 |
} catch (err) {
|
| 1010 |
console.error('Error during retry-pending queue:', err);
|
| 1011 |
}
|
|
@@ -1142,10 +1087,7 @@ app.post('/api/thumbnail', async (req, res) => {
|
|
| 1142 |
buffer = Buffer.from(rawData, 'base64');
|
| 1143 |
} else if (imageUrl && (imageUrl.startsWith('http://') || imageUrl.startsWith('https://'))) {
|
| 1144 |
try {
|
| 1145 |
-
|
| 1146 |
-
buffer = Buffer.from(imgRes.data);
|
| 1147 |
-
const contentType = imgRes.headers['content-type'];
|
| 1148 |
-
if (contentType) mimeType = contentType.split(';')[0];
|
| 1149 |
} catch (fetchErr) {
|
| 1150 |
console.warn('Could not download image from URL for YouTube:', fetchErr.message);
|
| 1151 |
}
|
|
@@ -1293,10 +1235,7 @@ app.post('/api/edit-video', async (req, res) => {
|
|
| 1293 |
|
| 1294 |
if (!buffer && (thumbnailUrl.startsWith('http://') || thumbnailUrl.startsWith('https://'))) {
|
| 1295 |
try {
|
| 1296 |
-
|
| 1297 |
-
buffer = Buffer.from(imgRes.data);
|
| 1298 |
-
const contentType = imgRes.headers['content-type'];
|
| 1299 |
-
if (contentType) mimeType = contentType.split(';')[0];
|
| 1300 |
} catch (dlErr) {
|
| 1301 |
console.warn('Could not download image from URL:', dlErr.message);
|
| 1302 |
}
|
|
@@ -1563,9 +1502,54 @@ app.post('/api/resume', async (req, res) => {
|
|
| 1563 |
return res.json({ success: true, message: 'Queue resumed successfully.' });
|
| 1564 |
});
|
| 1565 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1566 |
/**
|
| 1567 |
* Drive Scan & File Preview Endpoint (Review Files & Detect Duplicates before Upload)
|
| 1568 |
*/
|
|
|
|
| 1569 |
app.post('/api/scan-preview', async (req, res) => {
|
| 1570 |
const folderInput = req.body.folderInput || req.body.folderUrl || '';
|
| 1571 |
const startDate = req.body.startDate || '';
|
|
@@ -1609,7 +1593,11 @@ app.post('/api/scan-preview', async (req, res) => {
|
|
| 1609 |
let autoDetectedFolderName = null;
|
| 1610 |
|
| 1611 |
for (const fId of folderIds) {
|
| 1612 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1613 |
if (!autoDetectedFolderName && scanResult.rootFolderName) {
|
| 1614 |
autoDetectedFolderName = scanResult.rootFolderName;
|
| 1615 |
}
|
|
@@ -1621,7 +1609,6 @@ app.post('/api/scan-preview', async (req, res) => {
|
|
| 1621 |
}
|
| 1622 |
|
| 1623 |
const rawFiles = Array.from(discoveredMap.values());
|
| 1624 |
-
const history = loadUploadedHistory();
|
| 1625 |
|
| 1626 |
const formattedFiles = rawFiles.map((f, idx) => {
|
| 1627 |
const cleanOriginalName = (f.name || 'Video').replace(/\.[^/.]+$/, '');
|
|
@@ -1644,17 +1631,9 @@ app.post('/api/scan-preview', async (req, res) => {
|
|
| 1644 |
combinedTitle = combinedTitle.substring(0, 95) + '...';
|
| 1645 |
}
|
| 1646 |
|
| 1647 |
-
const
|
| 1648 |
-
|
| 1649 |
-
|
| 1650 |
-
(h.name && h.name.trim().toLowerCase() === f.name.trim().toLowerCase())
|
| 1651 |
-
);
|
| 1652 |
-
|
| 1653 |
-
const existingRecord = isDuplicate ? history.find(h =>
|
| 1654 |
-
(h.id && h.id === f.id) ||
|
| 1655 |
-
(h.customTitle && h.customTitle.trim().toLowerCase() === combinedTitle.trim().toLowerCase()) ||
|
| 1656 |
-
(h.name && h.name.trim().toLowerCase() === f.name.trim().toLowerCase())
|
| 1657 |
-
) : null;
|
| 1658 |
|
| 1659 |
return {
|
| 1660 |
index: idx + 1,
|
|
@@ -1822,7 +1801,11 @@ app.post(['/api/process', '/api/process-folder'], async (req, res) => {
|
|
| 1822 |
let autoDetectedFolderName = null;
|
| 1823 |
|
| 1824 |
for (const fId of folderIds) {
|
| 1825 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1826 |
if (!autoDetectedFolderName && scanResult.rootFolderName) {
|
| 1827 |
autoDetectedFolderName = scanResult.rootFolderName;
|
| 1828 |
}
|
|
@@ -1977,51 +1960,7 @@ app.post(['/api/process', '/api/process-folder'], async (req, res) => {
|
|
| 1977 |
|
| 1978 |
|
| 1979 |
|
| 1980 |
-
/**
|
| 1981 |
-
* Update YouTube Thumbnail Endpoint
|
| 1982 |
-
*/
|
| 1983 |
-
app.post('/api/thumbnail', async (req, res) => {
|
| 1984 |
-
const auth = getOAuth2Client(req);
|
| 1985 |
-
if (!auth) {
|
| 1986 |
-
return res.status(401).json({ success: false, error: 'Google OAuth2 access token missing.' });
|
| 1987 |
-
}
|
| 1988 |
-
|
| 1989 |
-
const { videoId, imageBase64 } = req.body;
|
| 1990 |
-
if (!videoId || !imageBase64) {
|
| 1991 |
-
return res.status(400).json({ success: false, error: 'Missing videoId or image data.' });
|
| 1992 |
-
}
|
| 1993 |
-
|
| 1994 |
-
try {
|
| 1995 |
-
const { google } = require('googleapis');
|
| 1996 |
-
const youtube = google.youtube({ version: 'v3', auth });
|
| 1997 |
-
const { Readable } = require('stream');
|
| 1998 |
-
|
| 1999 |
-
const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, '');
|
| 2000 |
-
const buffer = Buffer.from(base64Data, 'base64');
|
| 2001 |
-
|
| 2002 |
-
// Guess mime type from base64 header if possible, else default to jpeg
|
| 2003 |
-
let mimeType = 'image/jpeg';
|
| 2004 |
-
if (imageBase64.startsWith('data:image/png')) mimeType = 'image/png';
|
| 2005 |
-
|
| 2006 |
-
const readable = new Readable();
|
| 2007 |
-
readable._read = () => {};
|
| 2008 |
-
readable.push(buffer);
|
| 2009 |
-
readable.push(null);
|
| 2010 |
|
| 2011 |
-
const result = await youtube.thumbnails.set({
|
| 2012 |
-
videoId: videoId,
|
| 2013 |
-
media: {
|
| 2014 |
-
mimeType: mimeType,
|
| 2015 |
-
body: readable
|
| 2016 |
-
}
|
| 2017 |
-
});
|
| 2018 |
-
|
| 2019 |
-
res.json({ success: true, url: result.data.items[0].default.url });
|
| 2020 |
-
} catch (err) {
|
| 2021 |
-
console.error('Thumbnail upload error:', err);
|
| 2022 |
-
res.status(500).json({ success: false, error: 'An internal error occurred. Please try again.' });
|
| 2023 |
-
}
|
| 2024 |
-
});
|
| 2025 |
|
| 2026 |
/**
|
| 2027 |
* Proxy Google Drive Images for real-time frontend preview without CORS restrictions
|
|
@@ -2418,302 +2357,424 @@ app.listen(PORT, '0.0.0.0', () => {
|
|
| 2418 |
console.log(`====================================================`);
|
| 2419 |
console.log(` Drive-to-YouTube Background Streaming Service Live `);
|
| 2420 |
console.log(` Web UI: http://localhost:${PORT} `);
|
| 2421 |
-
console.log(`
|
| 2422 |
console.log(`====================================================`);
|
| 2423 |
});
|
| 2424 |
|
| 2425 |
-
|
| 2426 |
-
|
| 2427 |
-
|
| 2428 |
-
const
|
| 2429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2430 |
|
| 2431 |
-
|
| 2432 |
-
|
| 2433 |
-
|
| 2434 |
-
|
| 2435 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2436 |
|
| 2437 |
-
|
| 2438 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2439 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2440 |
|
| 2441 |
-
|
| 2442 |
-
|
| 2443 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2444 |
|
| 2445 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2446 |
|
| 2447 |
-
addJobLog(`[${i + 1}/${jobState.files.length}] Streaming: "${uploadTitle}" (${fileObj.subject})`, 'highlight');
|
| 2448 |
broadcastSSE({
|
| 2449 |
-
type: '
|
| 2450 |
fileId: fileObj.id,
|
| 2451 |
fileName: uploadTitle,
|
| 2452 |
-
|
| 2453 |
-
|
| 2454 |
-
index: i + 1,
|
| 2455 |
-
total: jobState.files.length,
|
| 2456 |
-
totalBytes: fileObj.totalBytes
|
| 2457 |
});
|
| 2458 |
|
| 2459 |
-
|
| 2460 |
-
|
| 2461 |
-
|
| 2462 |
-
|
| 2463 |
-
|
| 2464 |
-
|
| 2465 |
-
});
|
| 2466 |
|
| 2467 |
-
|
| 2468 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2469 |
|
| 2470 |
-
|
| 2471 |
-
|
| 2472 |
-
|
| 2473 |
-
|
| 2474 |
-
fileId: fileObj.id,
|
| 2475 |
-
requestBody: { role: 'reader', type: 'anyone' },
|
| 2476 |
-
supportsAllDrives: true
|
| 2477 |
-
});
|
| 2478 |
-
} catch (permErr) {
|
| 2479 |
-
addJobLog(`Notice: Could not make "${uploadTitle}" public due to domain rules. Using existing sharing settings.`, 'warn');
|
| 2480 |
-
}
|
| 2481 |
|
| 2482 |
-
|
| 2483 |
-
|
| 2484 |
-
|
| 2485 |
-
|
| 2486 |
-
|
| 2487 |
-
|
| 2488 |
-
|
| 2489 |
-
|
| 2490 |
-
|
| 2491 |
-
|
| 2492 |
-
|
| 2493 |
-
addJobLog(`Generated Secure Drive Player for: "${uploadTitle}"`, 'success');
|
| 2494 |
-
saveCompletedFileToHistory(fileObj);
|
| 2495 |
-
persistJobState();
|
| 2496 |
-
|
| 2497 |
-
broadcastSSE({
|
| 2498 |
-
type: 'file_completed',
|
| 2499 |
-
fileId: fileObj.id,
|
| 2500 |
-
fileName: uploadTitle,
|
| 2501 |
-
videoId: fileObj.id,
|
| 2502 |
-
youtubeUrl: embedUrl,
|
| 2503 |
-
studioUrl: embedUrl,
|
| 2504 |
-
thumbnailUrl: fileObj.thumbnailUrl
|
| 2505 |
-
});
|
| 2506 |
-
continue;
|
| 2507 |
-
}
|
| 2508 |
|
| 2509 |
-
|
| 2510 |
-
|
| 2511 |
-
|
| 2512 |
-
|
| 2513 |
-
|
| 2514 |
-
|
| 2515 |
-
|
| 2516 |
-
|
| 2517 |
-
|
| 2518 |
-
let speedMBps = 0;
|
| 2519 |
-
let etaSeconds = 0;
|
| 2520 |
-
|
| 2521 |
-
const progressMonitor = new Transform({
|
| 2522 |
-
transform(chunk, encoding, callback) {
|
| 2523 |
-
uploadedBytes += chunk.length;
|
| 2524 |
-
fileObj.uploadedBytes = uploadedBytes;
|
| 2525 |
-
|
| 2526 |
-
const currentTime = Date.now();
|
| 2527 |
-
const percent = fileObj.totalBytes > 0
|
| 2528 |
-
? Math.min(100, Math.round((uploadedBytes / fileObj.totalBytes) * 100))
|
| 2529 |
-
: 0;
|
| 2530 |
-
fileObj.percentage = percent;
|
| 2531 |
-
|
| 2532 |
-
const timeDiffSec = (currentTime - startTime) / 1000;
|
| 2533 |
-
if (timeDiffSec > 0.5) {
|
| 2534 |
-
speedMBps = ((uploadedBytes / (1024 * 1024)) / timeDiffSec);
|
| 2535 |
-
fileObj.speedMBps = parseFloat(speedMBps.toFixed(2));
|
| 2536 |
-
const remainingBytes = Math.max(0, fileObj.totalBytes - uploadedBytes);
|
| 2537 |
-
etaSeconds = speedMBps > 0 ? Math.round((remainingBytes / (1024 * 1024)) / speedMBps) : 0;
|
| 2538 |
-
fileObj.etaSeconds = etaSeconds;
|
| 2539 |
-
}
|
| 2540 |
|
| 2541 |
-
|
| 2542 |
-
|
| 2543 |
-
|
| 2544 |
-
|
| 2545 |
-
broadcastSSE({
|
| 2546 |
-
type: 'upload_progress',
|
| 2547 |
-
fileId: fileObj.id,
|
| 2548 |
-
fileName: uploadTitle,
|
| 2549 |
-
uploadedBytes,
|
| 2550 |
-
totalBytes: fileObj.totalBytes,
|
| 2551 |
-
percentage: percent,
|
| 2552 |
-
speedMBps: fileObj.speedMBps,
|
| 2553 |
-
etaSeconds: fileObj.etaSeconds
|
| 2554 |
-
});
|
| 2555 |
-
}
|
| 2556 |
|
| 2557 |
-
|
| 2558 |
-
|
| 2559 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2560 |
|
| 2561 |
-
|
| 2562 |
-
|
| 2563 |
-
|
| 2564 |
-
|
| 2565 |
-
|
| 2566 |
-
|
| 2567 |
-
let videoStatus = {
|
| 2568 |
-
privacyStatus: finalPrivacy,
|
| 2569 |
-
selfDeclaredMadeForKids: false,
|
| 2570 |
-
embeddable: true,
|
| 2571 |
-
license: 'youtube'
|
| 2572 |
-
};
|
| 2573 |
-
if (isScheduled) {
|
| 2574 |
-
try {
|
| 2575 |
-
videoStatus.publishAt = new Date(jobState.scheduledPublishAt).toISOString();
|
| 2576 |
-
} catch (e) {}
|
| 2577 |
-
}
|
| 2578 |
|
| 2579 |
-
|
| 2580 |
-
|
| 2581 |
-
|
| 2582 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2583 |
|
| 2584 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2585 |
|
| 2586 |
-
|
| 2587 |
-
|
| 2588 |
-
requestBody: {
|
| 2589 |
-
snippet: {
|
| 2590 |
-
title: uploadTitle,
|
| 2591 |
-
description: fullDescription,
|
| 2592 |
-
tags: allTags,
|
| 2593 |
-
categoryId: '27' // Education
|
| 2594 |
-
},
|
| 2595 |
-
status: videoStatus
|
| 2596 |
-
},
|
| 2597 |
-
media: {
|
| 2598 |
-
body: monitoredStream
|
| 2599 |
-
}
|
| 2600 |
-
});
|
| 2601 |
|
| 2602 |
-
|
| 2603 |
-
|
| 2604 |
-
|
| 2605 |
-
|
| 2606 |
-
|
| 2607 |
-
|
| 2608 |
-
|
| 2609 |
-
|
| 2610 |
-
|
| 2611 |
-
let thumbBuf = null;
|
| 2612 |
-
if (rawThumb.startsWith('data:image/')) {
|
| 2613 |
-
const b64 = rawThumb.replace(/^data:image\/\w+;base64,/, '');
|
| 2614 |
-
thumbBuf = Buffer.from(b64, 'base64');
|
| 2615 |
-
}
|
| 2616 |
-
if (thumbBuf) {
|
| 2617 |
-
await youtube.thumbnails.set({
|
| 2618 |
-
videoId: videoId,
|
| 2619 |
-
media: {
|
| 2620 |
-
mimeType: 'image/jpeg',
|
| 2621 |
-
body: require('stream').Readable.from(thumbBuf)
|
| 2622 |
-
}
|
| 2623 |
-
});
|
| 2624 |
-
thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg?t=${Date.now()}`;
|
| 2625 |
-
addJobLog(`β Branded thumbnail uploaded for "${uploadTitle}"`, 'success');
|
| 2626 |
-
}
|
| 2627 |
-
} catch (tErr) {
|
| 2628 |
-
console.warn('Batch thumbnail push note:', tErr.message);
|
| 2629 |
-
}
|
| 2630 |
-
}
|
| 2631 |
|
| 2632 |
-
|
| 2633 |
-
|
| 2634 |
-
|
| 2635 |
-
|
| 2636 |
-
|
| 2637 |
-
fileObj.thumbnailUrl = thumbnailUrl;
|
| 2638 |
-
|
| 2639 |
-
if (jobState.playlistId && jobState.processingMode !== 'drive_secure') {
|
| 2640 |
-
try {
|
| 2641 |
-
await addVideoToPlaylist(youtube, jobState.playlistId, videoId);
|
| 2642 |
-
addJobLog(`β Added "${uploadTitle}" to Playlist: "${jobState.playlistTitle}"`, 'info');
|
| 2643 |
-
} catch (plErr) {
|
| 2644 |
-
console.warn('Playlist item insert error:', plErr.message);
|
| 2645 |
-
}
|
| 2646 |
-
}
|
| 2647 |
|
| 2648 |
-
|
| 2649 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2650 |
|
| 2651 |
-
|
| 2652 |
-
|
| 2653 |
-
|
| 2654 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2655 |
|
| 2656 |
-
|
| 2657 |
-
type: 'file_completed',
|
| 2658 |
-
fileId: fileObj.id,
|
| 2659 |
-
fileName: uploadTitle,
|
| 2660 |
-
videoId,
|
| 2661 |
-
youtubeUrl,
|
| 2662 |
-
studioUrl,
|
| 2663 |
-
thumbnailUrl
|
| 2664 |
-
});
|
| 2665 |
|
| 2666 |
-
|
| 2667 |
-
|
| 2668 |
-
|
| 2669 |
-
const errMsg = (uploadErr.message || '').toLowerCase();
|
| 2670 |
-
const isQuotaOrLimit = (
|
| 2671 |
-
errMsg.includes('exceeded the number of videos') ||
|
| 2672 |
-
errMsg.includes('uploadlimitexceeded') ||
|
| 2673 |
-
errMsg.includes('quota') ||
|
| 2674 |
-
errMsg.includes('daily upload') ||
|
| 2675 |
-
uploadErr.code === 403 ||
|
| 2676 |
-
(uploadErr.code === 400 && (errMsg.includes('upload') || errMsg.includes('limit') || errMsg.includes('exceeded')))
|
| 2677 |
-
);
|
| 2678 |
-
|
| 2679 |
-
if (isQuotaOrLimit) {
|
| 2680 |
-
fileObj.status = 'failed';
|
| 2681 |
-
fileObj.error = 'YouTube daily limit reached (10-15 videos/day for channel). Click "Use Drive Player" for instant playback.';
|
| 2682 |
-
jobState.status = 'paused_quota';
|
| 2683 |
-
|
| 2684 |
-
jobState.stats.pending = Math.max(0, jobState.stats.pending - 1);
|
| 2685 |
-
jobState.stats.failed += 1;
|
| 2686 |
-
|
| 2687 |
-
addJobLog(`YouTube API Daily Upload Limit reached on your channel. Paused remaining uploads. You can switch remaining videos to Secure Drive Player.`, 'warn');
|
| 2688 |
-
persistJobState();
|
| 2689 |
-
|
| 2690 |
-
broadcastSSE({
|
| 2691 |
-
type: 'quota_exceeded',
|
| 2692 |
-
fileId: fileObj.id,
|
| 2693 |
-
fileName: uploadTitle,
|
| 2694 |
-
error: fileObj.error,
|
| 2695 |
-
message: 'YouTube daily upload limit reached. You can convert remaining videos to Secure Drive Player instantly.'
|
| 2696 |
-
});
|
| 2697 |
-
break; // Stop streaming further files since YouTube will reject all of them
|
| 2698 |
-
}
|
| 2699 |
|
| 2700 |
-
|
| 2701 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2702 |
|
| 2703 |
-
|
| 2704 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2705 |
|
| 2706 |
-
|
| 2707 |
-
|
|
|
|
|
|
|
| 2708 |
|
| 2709 |
-
|
| 2710 |
-
|
| 2711 |
-
|
| 2712 |
-
|
| 2713 |
-
|
| 2714 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2715 |
}
|
|
|
|
| 2716 |
}
|
|
|
|
|
|
|
| 2717 |
|
| 2718 |
-
|
| 2719 |
}
|
|
|
|
| 10 |
const fs = require('fs');
|
| 11 |
const { google } = require('googleapis');
|
| 12 |
const { Transform } = require('stream');
|
| 13 |
+
const db = require('./db');
|
| 14 |
const helmet = require('helmet');
|
| 15 |
const rateLimit = require('express-rate-limit');
|
| 16 |
|
| 17 |
const app = express();
|
| 18 |
const PORT = process.env.PORT || 3000;
|
| 19 |
const DATA_DIR = path.join(__dirname, 'data');
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
function loadUploadedHistory() { return db.loadUploadedHistory(); }
|
| 22 |
+
function persistUploadedHistory(history) { db.persistUploadedHistory(history); }
|
| 23 |
+
function saveCompletedFileToHistory(fileObj) { db.saveCompletedFileToHistory(fileObj); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
// Middleware
|
| 27 |
app.use(helmet({
|
|
|
|
| 85 |
return history.filter(h => h.channelId === channelId);
|
| 86 |
}
|
| 87 |
|
| 88 |
+
function fetchUrlAsBuffer(url) {
|
| 89 |
+
return new Promise((resolve, reject) => {
|
| 90 |
+
const proto = url.startsWith('https') ? require('https') : require('http');
|
| 91 |
+
proto.get(url, (res) => {
|
| 92 |
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
| 93 |
+
return fetchUrlAsBuffer(res.headers.location).then(resolve, reject);
|
| 94 |
+
}
|
| 95 |
+
if (res.statusCode !== 200) {
|
| 96 |
+
return reject(new Error(`HTTP ${res.statusCode}`));
|
| 97 |
+
}
|
| 98 |
+
const chunks = [];
|
| 99 |
+
res.on('data', chunk => chunks.push(chunk));
|
| 100 |
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
| 101 |
+
res.on('error', reject);
|
| 102 |
+
}).on('error', reject);
|
| 103 |
+
});
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
// Scopes narrowed to minimum required for upload, playlist, title/thumbnail management
|
| 107 |
const SCOPES = [
|
| 108 |
'https://www.googleapis.com/auth/drive.readonly',
|
|
|
|
| 134 |
function loadJobState() {
|
| 135 |
const history = loadUploadedHistory();
|
| 136 |
try {
|
| 137 |
+
const parsed = db.loadJobStateFromDB();
|
| 138 |
+
if (parsed) {
|
|
|
|
| 139 |
if (parsed.status === 'processing' || parsed.status === 'scanning') {
|
| 140 |
parsed.status = 'error';
|
| 141 |
if (parsed.logs) {
|
|
|
|
| 190 |
|
| 191 |
let saveStateTimeout = null;
|
| 192 |
function persistJobState() {
|
| 193 |
+
db.persistJobStateToDB(jobState);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
}
|
| 195 |
|
| 196 |
function broadcastSSE(data) {
|
|
|
|
| 684 |
app.get('/api/quota-health', async (req, res) => {
|
| 685 |
try {
|
| 686 |
const channelId = await resolveChannelId(req);
|
|
|
|
|
|
|
|
|
|
| 687 |
// Calculate current IST time (UTC + 5:30)
|
| 688 |
const now = new Date();
|
| 689 |
const istOffsetMs = (5 * 60 + 30) * 60 * 1000;
|
|
|
|
| 712 |
const resetsInSeconds = Math.max(0, Math.floor((nextResetUtc.getTime() - now.getTime()) / 1000));
|
| 713 |
|
| 714 |
// Count videos uploaded in current cycle
|
| 715 |
+
const uploadsInCycle = db.getUploadsInCycle(cycleStartIso, channelId);
|
| 716 |
+
|
|
|
|
|
|
|
|
|
|
| 717 |
|
| 718 |
const keysCount = Math.max(1, parseInt(req.query.keysCount || '1', 10));
|
| 719 |
const limitPerKey = 100;
|
|
|
|
| 950 |
(async () => {
|
| 951 |
activeAbortController = new AbortController();
|
| 952 |
try {
|
| 953 |
+
await runUploadQueue(auth);
|
|
|
|
|
|
|
| 954 |
} catch (err) {
|
| 955 |
console.error('Error during retry-pending queue:', err);
|
| 956 |
}
|
|
|
|
| 1087 |
buffer = Buffer.from(rawData, 'base64');
|
| 1088 |
} else if (imageUrl && (imageUrl.startsWith('http://') || imageUrl.startsWith('https://'))) {
|
| 1089 |
try {
|
| 1090 |
+
buffer = await fetchUrlAsBuffer(imageUrl);
|
|
|
|
|
|
|
|
|
|
| 1091 |
} catch (fetchErr) {
|
| 1092 |
console.warn('Could not download image from URL for YouTube:', fetchErr.message);
|
| 1093 |
}
|
|
|
|
| 1235 |
|
| 1236 |
if (!buffer && (thumbnailUrl.startsWith('http://') || thumbnailUrl.startsWith('https://'))) {
|
| 1237 |
try {
|
| 1238 |
+
buffer = await fetchUrlAsBuffer(thumbnailUrl);
|
|
|
|
|
|
|
|
|
|
| 1239 |
} catch (dlErr) {
|
| 1240 |
console.warn('Could not download image from URL:', dlErr.message);
|
| 1241 |
}
|
|
|
|
| 1502 |
return res.json({ success: true, message: 'Queue resumed successfully.' });
|
| 1503 |
});
|
| 1504 |
|
| 1505 |
+
// βββ Settings API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1506 |
+
app.get('/api/settings', (req, res) => {
|
| 1507 |
+
res.json({ success: true, settings: db.getAllSettings() });
|
| 1508 |
+
});
|
| 1509 |
+
|
| 1510 |
+
app.post('/api/settings', (req, res) => {
|
| 1511 |
+
const { key, value } = req.body;
|
| 1512 |
+
if (!key) return res.status(400).json({ success: false, error: 'Setting key is required.' });
|
| 1513 |
+
db.setSetting(key, value);
|
| 1514 |
+
res.json({ success: true, message: `Setting '${key}' updated.` });
|
| 1515 |
+
});
|
| 1516 |
+
|
| 1517 |
+
app.get('/api/settings/credentials', (req, res) => {
|
| 1518 |
+
res.json({ success: true, credentials: db.getAllCredentials() });
|
| 1519 |
+
});
|
| 1520 |
+
|
| 1521 |
+
app.post('/api/settings/credentials', (req, res) => {
|
| 1522 |
+
const { clientId, clientSecret, refreshToken, label } = req.body;
|
| 1523 |
+
if (!clientId || !clientSecret || !refreshToken) {
|
| 1524 |
+
return res.status(400).json({ success: false, error: 'clientId, clientSecret, and refreshToken are required.' });
|
| 1525 |
+
}
|
| 1526 |
+
db.addCredential(clientId, clientSecret, refreshToken, label || 'New Key');
|
| 1527 |
+
res.json({ success: true, message: 'Credential added successfully.' });
|
| 1528 |
+
});
|
| 1529 |
+
|
| 1530 |
+
app.delete('/api/settings/credentials/:id', (req, res) => {
|
| 1531 |
+
db.removeCredential(parseInt(req.params.id, 10));
|
| 1532 |
+
res.json({ success: true, message: 'Credential removed.' });
|
| 1533 |
+
});
|
| 1534 |
+
|
| 1535 |
+
// In-memory TTL cache for Drive folder scan results (60s)
|
| 1536 |
+
const folderScanCache = new Map();
|
| 1537 |
+
function getCachedScan(folderId, startDate, endDate) {
|
| 1538 |
+
const key = `${folderId}:${startDate || ''}:${endDate || ''}`;
|
| 1539 |
+
const cached = folderScanCache.get(key);
|
| 1540 |
+
if (cached && Date.now() < cached.expiresAt) return cached.data;
|
| 1541 |
+
folderScanCache.delete(key);
|
| 1542 |
+
return null;
|
| 1543 |
+
}
|
| 1544 |
+
function setCachedScan(folderId, startDate, endDate, data) {
|
| 1545 |
+
const key = `${folderId}:${startDate || ''}:${endDate || ''}`;
|
| 1546 |
+
folderScanCache.set(key, { data, expiresAt: Date.now() + 60000 });
|
| 1547 |
+
}
|
| 1548 |
+
|
| 1549 |
/**
|
| 1550 |
* Drive Scan & File Preview Endpoint (Review Files & Detect Duplicates before Upload)
|
| 1551 |
*/
|
| 1552 |
+
|
| 1553 |
app.post('/api/scan-preview', async (req, res) => {
|
| 1554 |
const folderInput = req.body.folderInput || req.body.folderUrl || '';
|
| 1555 |
const startDate = req.body.startDate || '';
|
|
|
|
| 1593 |
let autoDetectedFolderName = null;
|
| 1594 |
|
| 1595 |
for (const fId of folderIds) {
|
| 1596 |
+
let scanResult = getCachedScan(fId, startDateIso, endDateIso);
|
| 1597 |
+
if (!scanResult) {
|
| 1598 |
+
scanResult = await scanDriveFolderRecursively(drive, fId, startDateIso, endDateIso);
|
| 1599 |
+
setCachedScan(fId, startDateIso, endDateIso, scanResult);
|
| 1600 |
+
}
|
| 1601 |
if (!autoDetectedFolderName && scanResult.rootFolderName) {
|
| 1602 |
autoDetectedFolderName = scanResult.rootFolderName;
|
| 1603 |
}
|
|
|
|
| 1609 |
}
|
| 1610 |
|
| 1611 |
const rawFiles = Array.from(discoveredMap.values());
|
|
|
|
| 1612 |
|
| 1613 |
const formattedFiles = rawFiles.map((f, idx) => {
|
| 1614 |
const cleanOriginalName = (f.name || 'Video').replace(/\.[^/.]+$/, '');
|
|
|
|
| 1631 |
combinedTitle = combinedTitle.substring(0, 95) + '...';
|
| 1632 |
}
|
| 1633 |
|
| 1634 |
+
const duplicateCheck = db.isDuplicate(f.id, combinedTitle, f.name);
|
| 1635 |
+
const isDuplicate = duplicateCheck.isDuplicate;
|
| 1636 |
+
const existingRecord = duplicateCheck.existing;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1637 |
|
| 1638 |
return {
|
| 1639 |
index: idx + 1,
|
|
|
|
| 1801 |
let autoDetectedFolderName = null;
|
| 1802 |
|
| 1803 |
for (const fId of folderIds) {
|
| 1804 |
+
let scanResult = getCachedScan(fId, startDateIso, endDateIso);
|
| 1805 |
+
if (!scanResult) {
|
| 1806 |
+
scanResult = await scanDriveFolderRecursively(drive, fId, startDateIso, endDateIso);
|
| 1807 |
+
setCachedScan(fId, startDateIso, endDateIso, scanResult);
|
| 1808 |
+
}
|
| 1809 |
if (!autoDetectedFolderName && scanResult.rootFolderName) {
|
| 1810 |
autoDetectedFolderName = scanResult.rootFolderName;
|
| 1811 |
}
|
|
|
|
| 1960 |
|
| 1961 |
|
| 1962 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1963 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1964 |
|
| 1965 |
/**
|
| 1966 |
* Proxy Google Drive Images for real-time frontend preview without CORS restrictions
|
|
|
|
| 2357 |
console.log(`====================================================`);
|
| 2358 |
console.log(` Drive-to-YouTube Background Streaming Service Live `);
|
| 2359 |
console.log(` Web UI: http://localhost:${PORT} `);
|
| 2360 |
+
console.log(` Database: ${path.join(DATA_DIR, 'app.db')} `);
|
| 2361 |
console.log(`====================================================`);
|
| 2362 |
});
|
| 2363 |
|
| 2364 |
+
// βββ Concurrency Limiter βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2365 |
+
function createLimiter(concurrency) {
|
| 2366 |
+
let active = 0;
|
| 2367 |
+
const queue = [];
|
| 2368 |
+
function next() {
|
| 2369 |
+
while (active < concurrency && queue.length > 0) {
|
| 2370 |
+
active++;
|
| 2371 |
+
const { fn, resolve, reject } = queue.shift();
|
| 2372 |
+
fn().then(resolve, reject).finally(() => { active--; next(); });
|
| 2373 |
+
}
|
| 2374 |
+
}
|
| 2375 |
+
return function limit(fn) {
|
| 2376 |
+
return new Promise((resolve, reject) => {
|
| 2377 |
+
queue.push({ fn, resolve, reject });
|
| 2378 |
+
next();
|
| 2379 |
+
});
|
| 2380 |
+
};
|
| 2381 |
+
}
|
| 2382 |
|
| 2383 |
+
// βββ Retry & Quota Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2384 |
+
const RETRY_DELAYS = [2000, 8000, 30000]; // 2s, 8s, 30s exponential backoff
|
| 2385 |
+
|
| 2386 |
+
function isQuotaError(err) {
|
| 2387 |
+
const msg = (err.message || '').toLowerCase();
|
| 2388 |
+
return (
|
| 2389 |
+
msg.includes('exceeded the number of videos') ||
|
| 2390 |
+
msg.includes('uploadlimitexceeded') ||
|
| 2391 |
+
msg.includes('quota') ||
|
| 2392 |
+
msg.includes('daily upload') ||
|
| 2393 |
+
err.code === 403 ||
|
| 2394 |
+
(err.code === 400 && (msg.includes('upload') || msg.includes('limit') || msg.includes('exceeded')))
|
| 2395 |
+
);
|
| 2396 |
+
}
|
| 2397 |
+
|
| 2398 |
+
function scheduleQuotaResume(auth) {
|
| 2399 |
+
const now = new Date();
|
| 2400 |
+
const istOffsetMs = (5 * 60 + 30) * 60 * 1000;
|
| 2401 |
+
const nowIst = new Date(now.getTime() + istOffsetMs);
|
| 2402 |
+
const todayReset = new Date(nowIst);
|
| 2403 |
+
todayReset.setUTCHours(12, 30, 0, 0);
|
| 2404 |
+
let nextReset = todayReset;
|
| 2405 |
+
if (nowIst >= todayReset) {
|
| 2406 |
+
nextReset = new Date(todayReset.getTime() + 24 * 3600 * 1000);
|
| 2407 |
+
}
|
| 2408 |
+
const msUntilReset = new Date(nextReset.getTime() - istOffsetMs).getTime() - now.getTime();
|
| 2409 |
+
const resumeMs = Math.max(msUntilReset + 60000, 60000); // +1 min buffer
|
| 2410 |
+
|
| 2411 |
+
addJobLog(`Quota exhausted. Remaining uploads scheduled for auto-resume in ${Math.round(resumeMs / 60000)} minutes.`, 'warn');
|
| 2412 |
+
|
| 2413 |
+
setTimeout(async () => {
|
| 2414 |
+
// Reset credential quotas
|
| 2415 |
+
db.resetAllCredentialQuotas();
|
| 2416 |
+
|
| 2417 |
+
// Move scheduled_for_tomorrow β queued
|
| 2418 |
+
jobState.files.forEach(f => {
|
| 2419 |
+
if (f.status === 'scheduled_for_tomorrow') f.status = 'queued';
|
| 2420 |
+
});
|
| 2421 |
+
jobState.status = 'processing';
|
| 2422 |
+
persistJobState();
|
| 2423 |
+
addJobLog('Quota reset detected β auto-resuming scheduled uploads.', 'highlight');
|
| 2424 |
+
broadcastSSE({ type: 'state_sync', state: jobState });
|
| 2425 |
|
| 2426 |
+
// Try to get auth from stored credentials
|
| 2427 |
+
try {
|
| 2428 |
+
const nextAuth = await getNextAvailableAuth();
|
| 2429 |
+
if (nextAuth) {
|
| 2430 |
+
await runUploadQueue(nextAuth.auth, nextAuth.credentialId);
|
| 2431 |
+
if (jobState.status !== 'cancelled' && jobState.status !== 'paused_quota') {
|
| 2432 |
+
jobState.status = 'completed';
|
| 2433 |
+
jobState.finishedAt = new Date().toISOString();
|
| 2434 |
+
addJobLog('All scheduled uploads completed successfully after quota reset.', 'success');
|
| 2435 |
+
broadcastSSE({ type: 'process_completed', message: 'All videos processed successfully.' });
|
| 2436 |
+
persistJobState();
|
| 2437 |
}
|
| 2438 |
+
} else {
|
| 2439 |
+
addJobLog('No valid credentials available for auto-resume. Please connect Google account manually.', 'error');
|
| 2440 |
+
broadcastSSE({ type: 'auth_required', message: 'Auto-resume failed: no stored credentials. Please re-authorize.' });
|
| 2441 |
+
}
|
| 2442 |
+
} catch (err) {
|
| 2443 |
+
console.error('Auto-resume error:', err);
|
| 2444 |
+
addJobLog('Auto-resume failed: ' + err.message, 'error');
|
| 2445 |
+
}
|
| 2446 |
+
}, resumeMs);
|
| 2447 |
+
}
|
| 2448 |
|
| 2449 |
+
async function getNextAvailableAuth() {
|
| 2450 |
+
const creds = db.getActiveCredentials(); // Ordered by quotaUsedToday ASC
|
| 2451 |
+
for (const cred of creds) {
|
| 2452 |
+
if (cred.quotaUsedToday < 100) {
|
| 2453 |
+
try {
|
| 2454 |
+
const { google } = require('googleapis');
|
| 2455 |
+
const oauth2 = new google.auth.OAuth2(cred.clientId, cred.clientSecret);
|
| 2456 |
+
oauth2.setCredentials({ refresh_token: cred.refreshToken });
|
| 2457 |
+
// Verify token works by refreshing
|
| 2458 |
+
await oauth2.getAccessToken();
|
| 2459 |
+
return { auth: oauth2, credentialId: cred.id };
|
| 2460 |
+
} catch (err) {
|
| 2461 |
+
console.warn(`Credential ${cred.label} (id=${cred.id}) failed auth: ${err.message}`);
|
| 2462 |
+
continue;
|
| 2463 |
+
}
|
| 2464 |
+
}
|
| 2465 |
+
}
|
| 2466 |
+
return null;
|
| 2467 |
+
}
|
| 2468 |
+
|
| 2469 |
+
// βββ Single File Upload with Retry βββββββββββββββββββββββββββββββββββββββββββ
|
| 2470 |
+
async function uploadSingleFile(drive, youtube, auth, fileObj, index, total, credentialId) {
|
| 2471 |
+
const uploadTitle = fileObj.customTitle || fileObj.name || fileObj.originalName;
|
| 2472 |
+
fileObj.status = 'uploading';
|
| 2473 |
+
persistJobState();
|
| 2474 |
+
|
| 2475 |
+
addJobLog(`[${index + 1}/${total}] Streaming: "${uploadTitle}" (${fileObj.subject})`, 'highlight');
|
| 2476 |
+
broadcastSSE({
|
| 2477 |
+
type: 'file_start',
|
| 2478 |
+
fileId: fileObj.id,
|
| 2479 |
+
fileName: uploadTitle,
|
| 2480 |
+
subject: fileObj.subject,
|
| 2481 |
+
batch: fileObj.batch,
|
| 2482 |
+
index: index + 1,
|
| 2483 |
+
total: total,
|
| 2484 |
+
totalBytes: fileObj.totalBytes
|
| 2485 |
+
});
|
| 2486 |
+
|
| 2487 |
+
let lastError = null;
|
| 2488 |
+
|
| 2489 |
+
for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
|
| 2490 |
+
if (jobState.status === 'cancelled' || jobState.status === 'paused_quota') return;
|
| 2491 |
+
|
| 2492 |
+
try {
|
| 2493 |
+
await executeFileUpload(drive, youtube, auth, fileObj, uploadTitle, credentialId);
|
| 2494 |
+
return; // Success
|
| 2495 |
+
} catch (err) {
|
| 2496 |
+
lastError = err;
|
| 2497 |
+
console.error(`Error processing ${uploadTitle} (attempt ${attempt + 1}):`, err.message);
|
| 2498 |
|
| 2499 |
+
if (isQuotaError(err)) {
|
| 2500 |
+
// Quota error β don't retry, handle at queue level
|
| 2501 |
+
fileObj.status = 'failed';
|
| 2502 |
+
fileObj.error = 'YouTube daily limit reached (10-15 videos/day for channel). Click "Use Drive Player" for instant playback.';
|
| 2503 |
+
|
| 2504 |
+
jobState.stats.pending = Math.max(0, jobState.stats.pending - 1);
|
| 2505 |
+
jobState.stats.failed += 1;
|
| 2506 |
+
|
| 2507 |
+
addJobLog(`YouTube API Daily Upload Limit reached. Paused remaining uploads.`, 'warn');
|
| 2508 |
+
persistJobState();
|
| 2509 |
|
|
|
|
| 2510 |
broadcastSSE({
|
| 2511 |
+
type: 'quota_exceeded',
|
| 2512 |
fileId: fileObj.id,
|
| 2513 |
fileName: uploadTitle,
|
| 2514 |
+
error: fileObj.error,
|
| 2515 |
+
message: 'YouTube daily upload limit reached. You can convert remaining videos to Secure Drive Player instantly.'
|
|
|
|
|
|
|
|
|
|
| 2516 |
});
|
| 2517 |
|
| 2518 |
+
// Mark remaining queued files as scheduled_for_tomorrow
|
| 2519 |
+
jobState.status = 'paused_quota';
|
| 2520 |
+
jobState.files.forEach(f => {
|
| 2521 |
+
if (f.status === 'queued') f.status = 'scheduled_for_tomorrow';
|
| 2522 |
+
});
|
| 2523 |
+
persistJobState();
|
|
|
|
| 2524 |
|
| 2525 |
+
// Try rotating to next credential
|
| 2526 |
+
const nextAuth = await getNextAvailableAuth();
|
| 2527 |
+
if (nextAuth) {
|
| 2528 |
+
addJobLog(`Rotating to next API credential: ${db.getCredentialById(nextAuth.credentialId)?.label || 'Unknown'}`, 'highlight');
|
| 2529 |
+
jobState.files.forEach(f => {
|
| 2530 |
+
if (f.status === 'scheduled_for_tomorrow') f.status = 'queued';
|
| 2531 |
+
});
|
| 2532 |
+
jobState.status = 'processing';
|
| 2533 |
+
persistJobState();
|
| 2534 |
+
broadcastSSE({ type: 'state_sync', state: jobState });
|
| 2535 |
+
// The caller (runUploadQueue) will detect credential rotation
|
| 2536 |
+
throw Object.assign(new Error('CREDENTIAL_ROTATION'), { nextAuth });
|
| 2537 |
+
}
|
| 2538 |
|
| 2539 |
+
// No more credentials β schedule for tomorrow
|
| 2540 |
+
scheduleQuotaResume(auth);
|
| 2541 |
+
throw err; // Propagate to stop the queue
|
| 2542 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2543 |
|
| 2544 |
+
if (attempt < RETRY_DELAYS.length) {
|
| 2545 |
+
const delay = RETRY_DELAYS[attempt];
|
| 2546 |
+
addJobLog(`Retry ${attempt + 1}/3 for "${uploadTitle}" in ${delay / 1000}s...`, 'warn');
|
| 2547 |
+
broadcastSSE({ type: 'file_retry', fileId: fileObj.id, fileName: uploadTitle, attempt: attempt + 1, delayMs: delay });
|
| 2548 |
+
await new Promise(r => setTimeout(r, delay));
|
| 2549 |
+
fileObj.status = 'uploading';
|
| 2550 |
+
fileObj.percentage = 0;
|
| 2551 |
+
fileObj.uploadedBytes = 0;
|
| 2552 |
+
}
|
| 2553 |
+
}
|
| 2554 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2555 |
|
| 2556 |
+
// All retries exhausted
|
| 2557 |
+
fileObj.status = 'failed';
|
| 2558 |
+
fileObj.error = lastError?.message || 'Upload failed after 3 retries';
|
| 2559 |
+
jobState.stats.pending = Math.max(0, jobState.stats.pending - 1);
|
| 2560 |
+
jobState.stats.failed += 1;
|
| 2561 |
+
addJobLog(`Failed to upload "${uploadTitle}" after 3 retries: ${fileObj.error}`, 'error');
|
| 2562 |
+
persistJobState();
|
| 2563 |
+
broadcastSSE({ type: 'file_error', fileId: fileObj.id, fileName: uploadTitle, error: fileObj.error });
|
| 2564 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2565 |
|
| 2566 |
+
// βββ Execute Single Upload (Drive β YouTube stream) ββββββββββββββββββββββββββ
|
| 2567 |
+
async function executeFileUpload(drive, youtube, auth, fileObj, uploadTitle, credentialId) {
|
| 2568 |
+
const { Transform } = require('stream');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2569 |
|
| 2570 |
+
if (jobState.processingMode === 'drive_secure') {
|
| 2571 |
+
// Drive Secure mode β just set permissions
|
| 2572 |
+
fileObj.percentage = 10;
|
| 2573 |
+
broadcastSSE({
|
| 2574 |
+
type: 'upload_progress', fileId: fileObj.id, fileName: uploadTitle,
|
| 2575 |
+
uploadedBytes: 0, totalBytes: fileObj.totalBytes, percentage: 10, speedMBps: 0, etaSeconds: 0
|
| 2576 |
+
});
|
| 2577 |
|
| 2578 |
+
fileObj.percentage = 50;
|
| 2579 |
+
broadcastSSE({
|
| 2580 |
+
type: 'upload_progress', fileId: fileObj.id, fileName: uploadTitle,
|
| 2581 |
+
uploadedBytes: 0, totalBytes: fileObj.totalBytes, percentage: 50, speedMBps: 0, etaSeconds: 0
|
| 2582 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2583 |
|
| 2584 |
+
try {
|
| 2585 |
+
await drive.permissions.create({
|
| 2586 |
+
fileId: fileObj.id,
|
| 2587 |
+
requestBody: { role: 'reader', type: 'anyone' },
|
| 2588 |
+
supportsAllDrives: true
|
| 2589 |
+
});
|
| 2590 |
+
} catch (permErr) {
|
| 2591 |
+
if (!permErr.message?.includes('already has access')) throw permErr;
|
| 2592 |
+
}
|
| 2593 |
|
| 2594 |
+
const embedUrl = `https://drive.google.com/file/d/${fileObj.id}/preview`;
|
| 2595 |
+
fileObj.status = 'completed';
|
| 2596 |
+
fileObj.percentage = 100;
|
| 2597 |
+
fileObj.videoId = fileObj.id;
|
| 2598 |
+
fileObj.youtubeUrl = embedUrl;
|
| 2599 |
+
fileObj.studioUrl = '';
|
| 2600 |
+
fileObj.thumbnailUrl = `https://drive.google.com/thumbnail?id=${fileObj.id}&sz=w320`;
|
| 2601 |
|
| 2602 |
+
jobState.stats.pending = Math.max(0, jobState.stats.pending - 1);
|
| 2603 |
+
jobState.stats.completed += 1;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2604 |
|
| 2605 |
+
saveCompletedFileToHistory(fileObj);
|
| 2606 |
+
persistJobState();
|
| 2607 |
+
broadcastSSE({
|
| 2608 |
+
type: 'file_completed', fileId: fileObj.id, fileName: uploadTitle,
|
| 2609 |
+
videoId: fileObj.videoId, youtubeUrl: embedUrl,
|
| 2610 |
+
studioUrl: '', thumbnailUrl: fileObj.thumbnailUrl
|
| 2611 |
+
});
|
| 2612 |
+
return;
|
| 2613 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2614 |
|
| 2615 |
+
// YouTube Standard mode β stream from Drive to YouTube
|
| 2616 |
+
const driveStreamResponse = await drive.files.get(
|
| 2617 |
+
{ fileId: fileObj.id, alt: 'media', supportsAllDrives: true },
|
| 2618 |
+
{ responseType: 'stream' }
|
| 2619 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2620 |
|
| 2621 |
+
let uploadedBytes = 0;
|
| 2622 |
+
let lastReportedPercent = -1;
|
| 2623 |
+
let lastReportTime = Date.now();
|
| 2624 |
+
let startTime = Date.now();
|
| 2625 |
+
let speedMBps = 0;
|
| 2626 |
+
let etaSeconds = 0;
|
| 2627 |
+
|
| 2628 |
+
const progressMonitor = new Transform({
|
| 2629 |
+
highWaterMark: 2 * 1024 * 1024, // 2MB buffer for reduced chunking overhead
|
| 2630 |
+
transform(chunk, encoding, callback) {
|
| 2631 |
+
uploadedBytes += chunk.length;
|
| 2632 |
+
fileObj.uploadedBytes = uploadedBytes;
|
| 2633 |
+
|
| 2634 |
+
const currentTime = Date.now();
|
| 2635 |
+
const percent = fileObj.totalBytes > 0
|
| 2636 |
+
? Math.min(100, Math.round((uploadedBytes / fileObj.totalBytes) * 100))
|
| 2637 |
+
: 0;
|
| 2638 |
+
fileObj.percentage = percent;
|
| 2639 |
+
|
| 2640 |
+
const timeDiffSec = (currentTime - startTime) / 1000;
|
| 2641 |
+
if (timeDiffSec > 0.5) {
|
| 2642 |
+
speedMBps = ((uploadedBytes / (1024 * 1024)) / timeDiffSec);
|
| 2643 |
+
fileObj.speedMBps = parseFloat(speedMBps.toFixed(2));
|
| 2644 |
+
const remainingBytes = Math.max(0, fileObj.totalBytes - uploadedBytes);
|
| 2645 |
+
etaSeconds = speedMBps > 0 ? Math.round((remainingBytes / (1024 * 1024)) / speedMBps) : 0;
|
| 2646 |
+
fileObj.etaSeconds = etaSeconds;
|
| 2647 |
+
}
|
| 2648 |
|
| 2649 |
+
// Throttle SSE to max 1 event per 500ms per file
|
| 2650 |
+
if ((percent !== lastReportedPercent && (currentTime - lastReportTime >= 500 || percent === 100)) || uploadedBytes === chunk.length) {
|
| 2651 |
+
lastReportedPercent = percent;
|
| 2652 |
+
lastReportTime = currentTime;
|
| 2653 |
+
broadcastSSE({
|
| 2654 |
+
type: 'upload_progress',
|
| 2655 |
+
fileId: fileObj.id, fileName: uploadTitle,
|
| 2656 |
+
uploadedBytes, totalBytes: fileObj.totalBytes,
|
| 2657 |
+
percentage: percent, speedMBps: fileObj.speedMBps, etaSeconds: fileObj.etaSeconds
|
| 2658 |
+
});
|
| 2659 |
+
}
|
| 2660 |
+
callback(null, chunk);
|
| 2661 |
+
}
|
| 2662 |
+
});
|
| 2663 |
|
| 2664 |
+
const monitoredStream = driveStreamResponse.data.pipe(progressMonitor);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2665 |
|
| 2666 |
+
const targetPrivacy = jobState.privacyStatus || 'unlisted';
|
| 2667 |
+
const isScheduled = targetPrivacy === 'scheduled' && jobState.scheduledPublishAt;
|
| 2668 |
+
const finalPrivacy = isScheduled ? 'private' : (targetPrivacy === 'public' ? 'public' : (targetPrivacy === 'private' ? 'private' : 'unlisted'));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2669 |
|
| 2670 |
+
let videoStatus = {
|
| 2671 |
+
privacyStatus: finalPrivacy,
|
| 2672 |
+
selfDeclaredMadeForKids: false,
|
| 2673 |
+
embeddable: true,
|
| 2674 |
+
};
|
| 2675 |
+
if (isScheduled) {
|
| 2676 |
+
videoStatus.publishAt = jobState.scheduledPublishAt;
|
| 2677 |
+
}
|
| 2678 |
|
| 2679 |
+
const insertResponse = await youtube.videos.insert({
|
| 2680 |
+
part: ['snippet', 'status'],
|
| 2681 |
+
requestBody: {
|
| 2682 |
+
snippet: {
|
| 2683 |
+
title: uploadTitle,
|
| 2684 |
+
description: `${fileObj.batch || 'Batch'} β ${fileObj.subject || 'Lecture'}\nUploaded via Drive2YT Pipeline`,
|
| 2685 |
+
tags: [fileObj.batch, fileObj.subject, 'lecture', 'education'].filter(Boolean),
|
| 2686 |
+
categoryId: '27'
|
| 2687 |
+
},
|
| 2688 |
+
status: videoStatus
|
| 2689 |
+
},
|
| 2690 |
+
media: { body: monitoredStream }
|
| 2691 |
+
});
|
| 2692 |
|
| 2693 |
+
const videoId = insertResponse.data.id;
|
| 2694 |
+
const youtubeUrl = `https://youtu.be/${videoId}`;
|
| 2695 |
+
const studioUrl = `https://studio.youtube.com/video/${videoId}/edit`;
|
| 2696 |
+
let thumbnailUrl = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
|
| 2697 |
|
| 2698 |
+
// Increment credential quota counter
|
| 2699 |
+
if (credentialId) {
|
| 2700 |
+
db.incrementCredentialQuota(credentialId);
|
| 2701 |
+
}
|
| 2702 |
+
|
| 2703 |
+
// Custom thumbnail upload
|
| 2704 |
+
const customThumbData = jobState.customThumbnails?.[fileObj.id];
|
| 2705 |
+
if (customThumbData) {
|
| 2706 |
+
try {
|
| 2707 |
+
const thumbBuf = Buffer.from(customThumbData, 'base64');
|
| 2708 |
+
await youtube.thumbnails.set({
|
| 2709 |
+
videoId: videoId,
|
| 2710 |
+
media: { mimeType: 'image/jpeg', body: require('stream').Readable.from(thumbBuf) }
|
| 2711 |
+
});
|
| 2712 |
+
addJobLog(`Custom thumbnail applied for "${uploadTitle}"`, 'info');
|
| 2713 |
+
} catch (thumbErr) {
|
| 2714 |
+
addJobLog(`Thumbnail upload failed for "${uploadTitle}": ${thumbErr.message}`, 'warn');
|
| 2715 |
+
}
|
| 2716 |
+
}
|
| 2717 |
+
|
| 2718 |
+
// Playlist insertion
|
| 2719 |
+
if (jobState.playlistId) {
|
| 2720 |
+
try {
|
| 2721 |
+
await addVideoToPlaylist(youtube, jobState.playlistId, videoId);
|
| 2722 |
+
} catch (plErr) {
|
| 2723 |
+
addJobLog(`Playlist insert error for "${uploadTitle}": ${plErr.message}`, 'warn');
|
| 2724 |
+
}
|
| 2725 |
+
}
|
| 2726 |
+
|
| 2727 |
+
fileObj.status = 'completed';
|
| 2728 |
+
fileObj.percentage = 100;
|
| 2729 |
+
fileObj.videoId = videoId;
|
| 2730 |
+
fileObj.youtubeUrl = youtubeUrl;
|
| 2731 |
+
fileObj.studioUrl = studioUrl;
|
| 2732 |
+
fileObj.thumbnailUrl = thumbnailUrl;
|
| 2733 |
+
|
| 2734 |
+
jobState.stats.pending = Math.max(0, jobState.stats.pending - 1);
|
| 2735 |
+
jobState.stats.completed += 1;
|
| 2736 |
+
|
| 2737 |
+
saveCompletedFileToHistory(fileObj);
|
| 2738 |
+
persistJobState();
|
| 2739 |
+
|
| 2740 |
+
addJobLog(`β Uploaded "${uploadTitle}" β ${youtubeUrl}`, 'success');
|
| 2741 |
+
broadcastSSE({
|
| 2742 |
+
type: 'file_completed', fileId: fileObj.id, fileName: uploadTitle,
|
| 2743 |
+
videoId, youtubeUrl, studioUrl, thumbnailUrl
|
| 2744 |
+
});
|
| 2745 |
+
}
|
| 2746 |
+
|
| 2747 |
+
// βββ Concurrent Upload Queue βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2748 |
+
async function runUploadQueue(auth, credentialId) {
|
| 2749 |
+
const concurrency = parseInt(db.getSetting('upload_concurrency') || '3', 10);
|
| 2750 |
+
const limit = createLimiter(Math.max(1, Math.min(concurrency, 10)));
|
| 2751 |
+
const { google } = require('googleapis');
|
| 2752 |
+
const drive = google.drive({ version: 'v3', auth });
|
| 2753 |
+
const youtube = google.youtube({ version: 'v3', auth });
|
| 2754 |
+
|
| 2755 |
+
const pendingFiles = jobState.files.filter(f =>
|
| 2756 |
+
f.status !== 'completed' && f.status !== 'failed' && f.status !== 'scheduled_for_tomorrow'
|
| 2757 |
+
);
|
| 2758 |
+
|
| 2759 |
+
if (pendingFiles.length === 0) return;
|
| 2760 |
+
|
| 2761 |
+
addJobLog(`Starting concurrent upload queue (${pendingFiles.length} files, concurrency=${concurrency})`, 'highlight');
|
| 2762 |
+
|
| 2763 |
+
const uploadTasks = pendingFiles.map((fileObj, idx) =>
|
| 2764 |
+
limit(async () => {
|
| 2765 |
+
if (jobState.status === 'cancelled' || jobState.status === 'paused_quota') return;
|
| 2766 |
+
try {
|
| 2767 |
+
await uploadSingleFile(drive, youtube, auth, fileObj, idx, pendingFiles.length, credentialId);
|
| 2768 |
+
} catch (err) {
|
| 2769 |
+
if (err.message === 'CREDENTIAL_ROTATION' && err.nextAuth) {
|
| 2770 |
+
// Credential rotation β restart queue with new auth
|
| 2771 |
+
addJobLog('Restarting upload queue with rotated credentials...', 'highlight');
|
| 2772 |
+
await runUploadQueue(err.nextAuth.auth, err.nextAuth.credentialId);
|
| 2773 |
}
|
| 2774 |
+
// Other errors are already handled in uploadSingleFile
|
| 2775 |
}
|
| 2776 |
+
})
|
| 2777 |
+
);
|
| 2778 |
|
| 2779 |
+
await Promise.allSettled(uploadTasks);
|
| 2780 |
}
|